Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions gitgalaxy/core/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -2508,15 +2508,23 @@ def _count_assembly_register_args(self, matches: list[str]) -> int:

# galaxyscope:ignore sec_high_risk_execution

# #1973: per-language line-continuation marker used to extend a Mode A
# #1973/#2483: per-language line-continuation marker used to extend a Mode A
# label's args-search window past its own first physical line, keyed by
# primary_lang_id. Deliberately NOT a generic "any trailing symbol"
# rule -- cobol's fixed-format continuation is a column-7 indicator on
# the CONTINUING line, not a trailing marker on the line before, so
# cobol legitimately gets no entry here and stays single-line-only.
# jcl's marker is a bare trailing comma: any `//` statement line ending in
# `,` continues onto the next `//` line by real JCL syntax (no separate
# indicator column the way cobol/fixed-format languages use) -- this
# window bound doesn't validate that the following line actually starts
# with `//` (it just extends by one full line once the marker is seen),
# but within an already-matched JCL step's own block that's always true
# for well-formed input.
_MODE_A_ARGS_CONTINUATION_MARKER: ClassVar[dict[str, str]] = {
"dockerfile": "\\",
"fortran": "&",
"jcl": ",",
}

def _mode_a_args_window_end(self, code: str, start_idx: int, hard_limit_idx: int) -> int:
Expand Down Expand Up @@ -2716,8 +2724,8 @@ def _slice_by_labels(
if asm_matches:
args_count_override = self._count_assembly_register_args(asm_matches)

# #1973: for the 3 Mode A languages that never get a dedicated
# args_count_override at all (cobol, fortran, dockerfile), the
# #1973/#2483: for the 4 Mode A languages that never get a dedicated
# args_count_override at all (cobol, fortran, dockerfile, jcl), the
# generic args-count derivation in _calculate_block_metrics
# defaults to searching the WHOLE greedy `block` -- which can span
# many unrelated statements past the matched label's own
Expand Down Expand Up @@ -2754,8 +2762,19 @@ def _slice_by_labels(
# `send_receive`: real args 1, regressed to 0). Those 3
# languages' own body-idiom scans are intentionally left on the
# original unbounded path -- this issue never covered them.
#
# #2483: jcl joined this bound for the same reason dockerfile did --
# its `args` construct (`PARM=` on an EXEC step) is a genuinely
# separate, unrelated-to-other-steps statement, and an unbounded
# whole-`block` search could sweep a multi-line `PARM='...'` string
# (or, worse, an unrelated later step's own PARM=) into this step's
# count (confirmed real: ZOSCSEC.jcl's BPXIT step read `args=7` off
# an unbounded sweep of its own multi-line `PARM='SH chmod ...'`
# string, documented in docs/language_status/jcl.md). jcl's own
# continuation marker is a trailing comma (`,`), unlike dockerfile's
# backslash -- see _MODE_A_ARGS_CONTINUATION_MARKER.
args_search_text = None
if self.primary_lang_id in ("cobol", "fortran", "dockerfile"):
if self.primary_lang_id in ("cobol", "fortran", "dockerfile", "jcl"):
args_window_end = self._mode_a_args_window_end(code, start_idx, start_idx + end_offset)
args_search_text = code[start_idx:args_window_end]
if self.primary_lang_id == "fortran":
Expand Down
30 changes: 29 additions & 1 deletion gitgalaxy/standards/language_standards.py
Original file line number Diff line number Diff line change
Expand Up @@ -14038,8 +14038,36 @@ class PrismConfigSchema(TypedDict):
# Control flow in JCL (IF/THEN/ELSE/ENDIF)
"branch": re.compile(r"^[ \t]*//[A-Za-z0-9_#$@]*[ \t]+(?:IF|ELSE|ENDIF)\b", re.M | re.I),
# Extract arguments from EXEC PARM= strings or PROC symbolics definitions.
# #2482: PARM= routinely sits on a JCL continuation line, not the EXEC
# line itself -- a trailing comma on a `//` statement line means "this
# statement keeps going on the next `//` line," and real corpus JCL
# chains this more than once before PARM= appears (cics-genapp's
# CICSTS56.jcl: EXEC line ends in a comma, then a COND=(...) continuation
# line ALSO ends in a comma, and only the third line carries PARM=).
# `(?:\n//[ \t]*(?:[^\n]*,[ \t]*\n//[ \t]*){0,7})?` models this: the whole
# thing is optional (same-line PARM=, the common case, is untouched), but
# once triggered it crosses at least one continuation boundary
# (`\n//[ \t]*`) and then allows up to 7 more hops, each of which must
# itself end in a real trailing comma (`[^\n]*,` -- greedy, so it lands on
# the LAST comma on that line, the actual continuation indicator, not an
# incidental earlier one) before crossing again. Every repeated unit is
# bounded to a single physical line (`[^\n]*` cannot cross a newline), so
# this cannot backtrack catastrophically even on adversarial input -- see
# test_jcl_args_parm_continuation_line_regression's ReDoS case. The value
# capture itself (`\([^)]*\)` / `'...'`) already spanned newlines before
# this fix (`[^)]*`/`[^']*` don't exclude `\n`), so a PARM=(...) that
# keeps going across further continuation lines (cics-genapp's
# defdrep.jcl, 5 more lines after the opening paren) is captured whole --
# EXCEPT when the value itself contains a nested, unquoted `(...)` (e.g.
# `'AMODE(31)'` inside a larger PARM=(...) list), where the capture still
# stops at that inner `)` -- a separate, pre-existing limitation this
# issue doesn't attempt to fix (nested-paren balancing isn't expressible
# in a single bounded regex pass the way this engine requires).
"args": re.compile(
r"^[ \t]*//[A-Za-z0-9_#$@]*[ \t]+(?:EXEC(?:[ \t].*?)?,[ \t]*PARM=('(?:[^']|'')*'|\([^)]*\)|[^ \t\n,]+)|PROC[ \t]+(\S.*))",
r"^[ \t]*//[A-Za-z0-9_#$@]*[ \t]+"
r"(?:EXEC(?:[ \t].*?)?,[ \t]*(?:\n//[ \t]*(?:[^\n]*,[ \t]*\n//[ \t]*){0,7})?"
r"PARM=('(?:[^']|'')*'|\([^)]*\)|[^ \t\n,]+)"
r"|PROC[ \t]+(\S.*))",
re.M | re.I,
),
# Structural boundaries (Any line starting with // and a command)
Expand Down
45 changes: 40 additions & 5 deletions tests/extraction/languages/test_jcl.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,9 @@ def test_jcl_class_start_invalid(payload):
"'THIS STRING CONTINUES X\n// AT COLUMN 16'",
marks=pytest.mark.xfail(reason="Known limitation: Engine cannot parse JCL line continuations"),
),
pytest.param(
"//STEP EXEC PGM=X, \n// PARM='A=B'",
"'A=B'",
marks=pytest.mark.xfail(reason="Known limitation: Engine cannot parse JCL line continuations"),
),
# #2482: PARM= on a `//` continuation line, reached via the trailing-comma
# continuation marker -- no longer an xfail, this is the exact shape fixed.
("//STEP EXEC PGM=X, \n// PARM='A=B'", "'A=B'"),
]

ARGS_INVALID = [
Expand All @@ -137,6 +135,43 @@ def test_jcl_args_invalid(payload):
assert_invalid_no_match(JCL_RULES["args"], payload, "jcl.args")


def test_jcl_args_mode_a_window_bounded_no_over_count():
"""
Pipeline-level regression for #2483: Mode A's generic args-count
derivation used to search the WHOLE greedy block (this step's own
signature through to the next EXEC step), not just this step's own
statement -- a real corpus bug (docs/language_status/jcl.md:
ZOSCSEC.jcl's BPXIT step read `args=7` off an unbounded sweep of its own
multi-line `PARM='SH chmod ...'` string) and, more seriously, a step
with NO `PARM=` of its own could pick up a LATER, unrelated step's
PARM= instead. `args_search_text` must now be bounded to just this
step's own (possibly continuation-extended) statement via jcl's `,`
entry in `_MODE_A_ARGS_CONTINUATION_MARKER`.
"""
from gitgalaxy.core.detector import StructuralExtractor

extractor = StructuralExtractor("jcl", LANGUAGE_DEFINITIONS)

# A step with no PARM= of its own, followed by an unrelated step that
# DOES have one -- the first step must read args=0, never borrowing the
# second step's PARM=.
two_steps = "//STEP1 EXEC PGM=FOO\n//STEP2 EXEC PGM=BAR,PARM='SHOULDNOTBLEED'\n"
segments = extractor._partition_segments(two_steps, "jcl")
functions, _ = extractor._function_slice(segments, [{} for _ in segments], {}, {}, None)
step1 = next(f for f in functions if f["name"] == "STEP1")
step2 = next(f for f in functions if f["name"] == "STEP2")
assert step1["args"] == 0, f"STEP1 must not borrow STEP2's PARM=, got args={step1['args']}"
assert step2["args"] == 1, f"STEP2's own PARM= should still count normally, got args={step2['args']}"

# A single-value PARM= must not be over-counted just because the window
# now spans a multi-line continuation -- one PARM= is still one value.
continued = "//CICS EXEC PGM=DFHSIP,REGION=&REG,TIME=1440,\n// COND=(1,NE,CICSCNTL),\n// PARM='START=&START,SYSIN',MEMLIMIT=16G\n//NEXT EXEC PGM=OTHER\n"
segments2 = extractor._partition_segments(continued, "jcl")
functions2, _ = extractor._function_slice(segments2, [{} for _ in segments2], {}, {}, None)
cics_step = next(f for f in functions2 if f["name"] == "CICS")
assert cics_step["args"] == 1, f"a single PARM= value must count as 1, got args={cics_step['args']}"


# ==============================================================================
# DEPENDENCY CAPTURE (_dependency_capture)
# ==============================================================================
Expand Down
82 changes: 79 additions & 3 deletions tests/extraction/languages/test_jcl_strict.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,10 @@ def test_jcl_cross_line_false_match_regression():
line falsely bind to a keyword starting an *entirely different* line
that has no `//` prefix of its own -- JCL statements never span a
physical line via bare whitespace (only via explicit continuation
columns, which this engine doesn't need to model since the "practical
reality" per Rule 1 is that continued PARAMETER lists, not the
name+keyword pair itself, are what wraps). Bounded to `[ \\t]+`.
columns; the name+keyword pair itself never wraps, only a continued
PARAMETER list can -- see test_jcl_args_parm_continuation_line_regression
for the `args` rule's own bounded support for that, added by #2482).
Bounded to `[ \\t]+`.
"""
old_func_start = re.compile(r"^[ \t]*//([A-Za-z0-9_#$@]+)\s+EXEC\b", re.M | re.I)
old_class_start = re.compile(r"^[ \t]*//([A-Za-z0-9_#$@]+)\s+JOB\b", re.M | re.I)
Expand Down Expand Up @@ -218,6 +219,81 @@ def test_jcl_cross_line_false_match_regression():
assert JCL_RULES["ownership"].search("//*Author: Jane Doe").group(1) == "Jane Doe"


def test_jcl_args_parm_continuation_line_regression():
"""
Regression test for #2482: the `args` regex only ever saw `PARM=` when it
sat on the EXEC statement's own physical line -- a real, common JCL idiom
(docs/language_status/jcl.md documented ~16-18 corpus occurrences missed
this way) since `PARM=` routinely lands on a `//` continuation line
instead, sometimes more than one hop away.
"""
old_pattern = re.compile(
r"^[ \t]*//[A-Za-z0-9_#$@]*[ \t]+(?:EXEC(?:[ \t].*?)?,[ \t]*PARM=('(?:[^']|'')*'|\([^)]*\)|[^ \t\n,]+)|PROC[ \t]+(\S.*))",
re.M | re.I,
)
pattern = JCL_RULES["args"]

# cics-genapp/cobol.jcl:68 shape -- PARM= one continuation line down.
one_hop = "//LKED EXEC PGM=HEWL,COND=(7,LT,COBL),\n// PARM='LIST,XREF,RENT,NAME=&MEM'\n"
assert not old_pattern.search(one_hop), "sanity check: bug must reproduce against the old pattern"
m = pattern.search(one_hop)
assert m and m.group(1) == "'LIST,XREF,RENT,NAME=&MEM'", "single-continuation PARM= still not found"

# cics-banking-sample-application-cbsa/CICSTS56.jcl:45 shape -- a second
# continuation line (COND=...,) sits between EXEC and the PARM= line.
two_hop = "//CICS EXEC PGM=DFHSIP,REGION=&REG,TIME=1440,\n// COND=(1,NE,CICSCNTL),\n// PARM='START=&START,SYSIN',MEMLIMIT=16G\n"
m2 = pattern.search(two_hop)
assert m2 and m2.group(1) == "'START=&START,SYSIN'", "two-hop continuation PARM= still not found"

# cics-genapp/defdrep.jcl shape -- PARM=(...) itself keeps going across
# several MORE continuation lines after the hop that reaches it; the
# value capture already spans newlines (`[^)]*` doesn't exclude `\n`),
# this only needed the hop to reach the opening `PARM=(` at all.
multiline_value = (
"//DREPINIT EXEC PGM=EYU9XDUT,\n"
"// COND=(8,LT),\n"
"// PARM=('CMASNAME=<CMASAPPL>',\n"
"// 'DAYLIGHT=N',\n"
"// 'ZONEOFFSET=0')\n"
)
m3 = pattern.search(multiline_value)
assert m3 and m3.group(1).startswith("('CMASNAME=<CMASAPPL>'") and m3.group(1).endswith("'ZONEOFFSET=0')"), (
"multi-line PARM=(...) continuation value not fully captured"
)

# Same-line PARM= (the common case) must be unaffected.
assert pattern.search("//STEP1 EXEC PGM=FOO,PARM='SAME-LINE'").group(1) == "'SAME-LINE'"

# A step with NO PARM= anywhere, even across a trailing-comma
# continuation, must still not match -- the hop must not manufacture a
# match out of thin air.
no_parm = "//STEP2 EXEC PGM=FOO,REGION=1M,\n// COND=(4,LT)\n"
assert not pattern.search(no_parm), "hop mechanism must not match when no PARM= is ever present"

# A later, unrelated step's own PARM= must never be attributed to an
# earlier step that has none of its own (no cross-step bleed).
two_steps = "//STEP1 EXEC PGM=FOO\n//STEP2 EXEC PGM=BAR,PARM='STEP2ONLY'\n"
all_matches = list(pattern.finditer(two_steps))
assert len(all_matches) == 1, "exactly one match expected (STEP2's own), not a bleed onto STEP1"
assert all_matches[0].group(1) == "'STEP2ONLY'"
assert all_matches[0].start() == two_steps.index("//STEP2"), "match must anchor to STEP2's own line, not STEP1's"


def test_jcl_args_redos_immunity():
"""
ReDoS immunity for #2482's continuation-hop addition specifically --
the hop group is bounded ({0,7} repetitions) and every repetition's
`[^\\n]*` is itself bounded to a single physical line, so this can't
backtrack catastrophically even when fed a long run of comma-heavy
lines that never actually reach a `//`-prefixed continuation (the
shape that would matter if the bound were missing).
"""
pattern = JCL_RULES["args"]
assert_redos_immune(pattern, "//X EXEC PGM=Y," + "A," * 20000, timeout_sec=3.0)
many_fake_hops = "//X EXEC PGM=Y,\n" + "\n".join(f"// FIELD{i}=VAL{i}," for i in range(20000))
assert_redos_immune(pattern, many_fake_hops, timeout_sec=3.0)


def test_jcl_lexical_family_no_block_terminator_state_to_confuse():
"""
Lexical-family audit: jcl is `line_exclusive` -- no block comment
Expand Down
Loading
Loading