diff --git a/dsgai_scanner_tool/cli/dsgai_scan.py b/dsgai_scanner_tool/cli/dsgai_scan.py index cd25870..4c98583 100644 --- a/dsgai_scanner_tool/cli/dsgai_scan.py +++ b/dsgai_scanner_tool/cli/dsgai_scan.py @@ -243,6 +243,8 @@ def run_rule(rg, rule, files, scan_root): if not files: return set() base = [rg, "--pcre2", "--no-config", "--with-filename", "--line-number"] + if rule.get("multiline"): + base.append("--multiline") if rule["classification"] == "value_bearing": # LOCATION-ONLY: rg erases the matched text before emitting anything. base += ["--only-matching", "--replace", ""] diff --git a/dsgai_scanner_tool/dist/dsgai.semgrep.yaml b/dsgai_scanner_tool/dist/dsgai.semgrep.yaml index 701c81b..63cf463 100644 --- a/dsgai_scanner_tool/dist/dsgai.semgrep.yaml +++ b/dsgai_scanner_tool/dist/dsgai.semgrep.yaml @@ -849,16 +849,16 @@ rules: framework: 'dsgai-2026-v1.0' - id: dsgai-P17.6 languages: [generic] - severity: ERROR - message: 'DSGAI17 P17.6: Unbounded retry (FAIL)' + severity: WARNING + message: 'DSGAI17 P17.6: Unbounded LLM retry loop (WARN)' patterns: - - pattern-regex: '(?m)while\s+True[^}]{0,200}(generate|complete|chat)|retry\s*\(\s*\)' + - pattern-regex: '(?s)while\s+True\b(?=(?:(?!\bbreak\b|\bmax_retries|\btimeout).){0,200}\b(?:generate|complete|chat)\s*\()' paths: include: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] metadata: dsgai_control: DSGAI17 dsgai_rule: P17.6 - confidence: medium + confidence: low framework: 'dsgai-2026-v1.0' - id: dsgai-P18.1 languages: [generic] diff --git a/dsgai_scanner_tool/rules/README.md b/dsgai_scanner_tool/rules/README.md index 62301f7..b93eed5 100644 --- a/dsgai_scanner_tool/rules/README.md +++ b/dsgai_scanner_tool/rules/README.md @@ -19,6 +19,7 @@ deterministic CLI loads with the standard library only — no PyYAML at runtime) signal: fail # fail | warn | pass_signal | count | info confidence: high # high | medium | low pcre: '(?i)(OPENAI_API_KEY|...)\s*[:=]\s*["'']?sk-[A-Za-z0-9_\-]{20,}' + multiline: false # optional: allow matches to inspect across lines file_globs: ['*.py', '*.env*'] # which files the rule runs against exclude_globs: [] # paths excluded from this rule framework: dsgai-2026-v1.0 # framework version binding @@ -36,6 +37,7 @@ deterministic CLI loads with the standard library only — no PyYAML at runtime) | `signal` | yes | how a hit is weighted: `fail`, `warn`, `pass_signal`, `count`, `info` | | `confidence` | yes | `high` / `medium` / `low` — feeds SARIF `level` and report rendering | | `pcre` | yes | PCRE2 pattern; must compile under `rg --pcre2` (checked in CI, not by the schema) | +| `multiline` | no | run ripgrep with `--multiline`, allowing a PCRE such as `(?s)…` to inspect across line boundaries | | `file_globs` | yes | globs the rule scans | | `exclude_globs` | no | globs excluded from the rule (e.g. migrations/tests) | | `framework` | yes | `dsgai-YYYY-vX.Y` binding | diff --git a/dsgai_scanner_tool/rules/dsgai-rules.json b/dsgai_scanner_tool/rules/dsgai-rules.json index 05009cf..e22b4ba 100644 --- a/dsgai_scanner_tool/rules/dsgai-rules.json +++ b/dsgai_scanner_tool/rules/dsgai-rules.json @@ -1936,9 +1936,9 @@ }, { "classification": "structural", - "confidence": "medium", + "confidence": "low", "control": "DSGAI17", - "description": "Unbounded retry (FAIL)", + "description": "Unbounded LLM retry loop (WARN)", "exclude_globs": [], "file_globs": [ "*.py", @@ -1949,9 +1949,10 @@ ], "framework": "dsgai-2026-v1.0", "id": "P17.6", + "multiline": true, "name": "unbounded-retry", - "pcre": "while\\s+True[^}]{0,200}(generate|complete|chat)|retry\\s*\\(\\s*\\)", - "signal": "fail" + "pcre": "(?s)while\\s+True\\b(?=(?:(?!\\bbreak\\b|\\bmax_retries|\\btimeout).){0,200}\\b(?:generate|complete|chat)\\s*\\()", + "signal": "warn" }, { "classification": "structural", diff --git a/dsgai_scanner_tool/rules/dsgai-rules.yaml b/dsgai_scanner_tool/rules/dsgai-rules.yaml index a3d1c0d..997ead2 100644 --- a/dsgai_scanner_tool/rules/dsgai-rules.yaml +++ b/dsgai_scanner_tool/rules/dsgai-rules.yaml @@ -1002,13 +1002,14 @@ rules: control: DSGAI17 name: unbounded-retry classification: structural - signal: fail - confidence: medium - pcre: 'while\s+True[^}]{0,200}(generate|complete|chat)|retry\s*\(\s*\)' + signal: warn + confidence: low + pcre: '(?s)while\s+True\b(?=(?:(?!\bbreak\b|\bmax_retries|\btimeout).){0,200}\b(?:generate|complete|chat)\s*\()' + multiline: true file_globs: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] exclude_globs: [] framework: 'dsgai-2026-v1.0' - description: 'Unbounded retry (FAIL)' + description: 'Unbounded LLM retry loop (WARN)' - id: P18.1 control: DSGAI18 name: output-guardrails diff --git a/dsgai_scanner_tool/rules/rules.schema.json b/dsgai_scanner_tool/rules/rules.schema.json index 552b8c5..602c2fc 100644 --- a/dsgai_scanner_tool/rules/rules.schema.json +++ b/dsgai_scanner_tool/rules/rules.schema.json @@ -33,6 +33,7 @@ "signal": { "enum": ["fail", "warn", "pass_signal", "count", "info"] }, "confidence": { "enum": ["high", "medium", "low"] }, "pcre": { "type": "string", "minLength": 1 }, + "multiline": { "type": "boolean" }, "file_globs": { "type": "array", "items": { "type": "string", "minLength": 1 } diff --git a/dsgai_scanner_tool/tests/expected-findings.yaml b/dsgai_scanner_tool/tests/expected-findings.yaml index 49032cf..b3b674d 100644 --- a/dsgai_scanner_tool/tests/expected-findings.yaml +++ b/dsgai_scanner_tool/tests/expected-findings.yaml @@ -54,8 +54,11 @@ findings: - {control: DSGAI15, rule_id: P15.1, path: system_prompt.py, line: 8, status: fail, classification: value_bearing} # DSGAI14 — telemetry content capture - {control: DSGAI14, rule_id: P14.2, path: telemetry.py, line: 11, status: warn, classification: value_bearing} - # DSGAI17 / DSGAI20 — PASS signals on the rate-limited endpoint + # DSGAI17 — bounded-loop PASS signal and low-confidence unbounded-loop WARN + - {control: DSGAI17, rule_id: P17.3, path: bounded_retry_timeout.py, line: 6, status: pass_signal} - {control: DSGAI17, rule_id: P17.4, path: rate_limited_api.py, line: 8, status: pass_signal} + - {control: DSGAI17, rule_id: P17.6, path: unbounded_retry.py, line: 5, status: warn} + # DSGAI20 — PASS signals on the rate-limited endpoint - {control: DSGAI20, rule_id: P20.2, path: rate_limited_api.py, line: 8, status: pass_signal} - {control: DSGAI20, rule_id: P20.5, path: rate_limited_api.py, line: 19, status: pass_signal} - {control: DSGAI20, rule_id: P20.2, path: rate_limited_api.py, line: 20, status: pass_signal} @@ -67,6 +70,9 @@ must_not_flag: - {rule_id: P02.9, path: good_config.py, reason: "no raw token literal — Vault retrieval"} - {rule_id: P20.5, path: rate_limited_api.py, reason: "endpoint is authenticated and rate limited"} - {rule_id: P12.1, path: webhook.py, reason: "innocent webhook — no LLM call nearby"} + - {rule_id: P17.6, path: bounded_retry_break.py, reason: "explicit break condition bounds the loop"} + - {rule_id: P17.6, path: bounded_retry_timeout.py, reason: "model call has an explicit timeout"} + - {rule_id: P17.6, path: bounded_retry_limit.py, reason: "configured retry limit bounds attempts"} # No known false negatives remain (the JS /chat endpoint is now caught by P20.5). known_false_negatives: [] diff --git a/dsgai_scanner_tool/tests/fixtures/vulnerable-app/README.md b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/README.md index 2d25cf0..752452a 100644 --- a/dsgai_scanner_tool/tests/fixtures/vulnerable-app/README.md +++ b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/README.md @@ -28,6 +28,8 @@ scanner does not fail its own repository. | `docs/NOTES.md` | — | adversarial prompt-injection; must have zero effect | | `good_config.py` | DSGAI02 | PASS — Vault retrieval, no hardcoded secret | | `rate_limited_api.py` | DSGAI20 | PASS — authenticated + rate-limited endpoint | +| `unbounded_retry.py` | DSGAI17 | WARN — unbounded multiline LLM retry loop | +| `bounded_retry_*.py` | DSGAI17 | **no P17.6 finding** — break, timeout, or retry limit bounds the loop | The authoritative, line-pinned expectations live in [`../../expected-findings.yaml`](../../expected-findings.yaml). diff --git a/dsgai_scanner_tool/tests/fixtures/vulnerable-app/bounded_retry_break.py b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/bounded_retry_break.py new file mode 100644 index 0000000..cd7275f --- /dev/null +++ b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/bounded_retry_break.py @@ -0,0 +1,8 @@ +"""Safe retry loop: an explicit break condition bounds the loop.""" + + +def ask_while_available(prompt, should_stop): + while True: + if should_stop(): + break + generate(prompt) diff --git a/dsgai_scanner_tool/tests/fixtures/vulnerable-app/bounded_retry_limit.py b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/bounded_retry_limit.py new file mode 100644 index 0000000..a520649 --- /dev/null +++ b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/bounded_retry_limit.py @@ -0,0 +1,12 @@ +"""Safe retry loop: a configured retry limit bounds attempts.""" + + +def ask_with_retry_limit(prompt, max_retries): + attempts = 0 + while True: + if attempts >= max_retries: + return None + attempts += 1 + response = chat(prompt) + if response: + return response diff --git a/dsgai_scanner_tool/tests/fixtures/vulnerable-app/bounded_retry_timeout.py b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/bounded_retry_timeout.py new file mode 100644 index 0000000..4e18bb6 --- /dev/null +++ b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/bounded_retry_timeout.py @@ -0,0 +1,9 @@ +"""Safe retry loop: the model call has an explicit timeout.""" + + +def ask_with_timeout(prompt): + while True: + timeout = 10 + response = complete(prompt, timeout=timeout) + if response: + return response diff --git a/dsgai_scanner_tool/tests/fixtures/vulnerable-app/unbounded_retry.py b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/unbounded_retry.py new file mode 100644 index 0000000..211d927 --- /dev/null +++ b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/unbounded_retry.py @@ -0,0 +1,8 @@ +"""Intentionally vulnerable unbounded LLM retry loop.""" + + +def ask_until_success(prompt): + while True: + response = chat(prompt) + if response: + return response diff --git a/dsgai_scanner_tool/tests/regen_expected.py b/dsgai_scanner_tool/tests/regen_expected.py index d700159..f06012e 100644 --- a/dsgai_scanner_tool/tests/regen_expected.py +++ b/dsgai_scanner_tool/tests/regen_expected.py @@ -58,7 +58,10 @@ def raw_matches(rg): if r.get("match") == "file_exists": out.add((r["id"], rel, 1)) # presence of the file is the signal continue - p = subprocess.run([rg, "--pcre2", "-n", "-o", "-e", r["pcre"], f], + cmd = [rg, "--pcre2", "-n", "-o"] + if r.get("multiline"): + cmd.append("--multiline") + p = subprocess.run(cmd + ["-e", r["pcre"], f], capture_output=True, text=True) for line in p.stdout.splitlines(): lno = line.split(":", 1)[0] diff --git a/dsgai_scanner_tool/tests/test_runner.py b/dsgai_scanner_tool/tests/test_runner.py index 47de37c..2150ca9 100644 --- a/dsgai_scanner_tool/tests/test_runner.py +++ b/dsgai_scanner_tool/tests/test_runner.py @@ -67,7 +67,10 @@ def test_all_pcres_compile(): rg = _rg() bad = [] for r in _rules(): - p = subprocess.run([rg, "--pcre2", "-q", "-e", r["pcre"]], + cmd = [rg, "--pcre2", "-q"] + if r.get("multiline"): + cmd.append("--multiline") + p = subprocess.run(cmd + ["-e", r["pcre"]], input="x\n", capture_output=True, text=True) # rg exit codes: 0 match, 1 no match, >=2 error (incl. any regex/PCRE2 # compile failure — the message differs between engines, so key on the @@ -87,6 +90,13 @@ def test_compile_check_catches_a_broken_pattern(): assert p.returncode >= 2 +def test_p17_6_remains_a_low_confidence_multiline_warning(): + rule = next(r for r in _rules() if r["id"] == "P17.6") + assert rule["signal"] == "warn" + assert rule["confidence"] == "low" + assert rule["multiline"] is True + + @requires_rg def test_scan_matches_sheet_exactly(scan, sheet): def key(f):