Skip to content
Open
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
2 changes: 2 additions & 0 deletions dsgai_scanner_tool/cli/dsgai_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""]
Expand Down
8 changes: 4 additions & 4 deletions dsgai_scanner_tool/dist/dsgai.semgrep.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions dsgai_scanner_tool/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
9 changes: 5 additions & 4 deletions dsgai_scanner_tool/rules/dsgai-rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions dsgai_scanner_tool/rules/dsgai-rules.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions dsgai_scanner_tool/rules/rules.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
8 changes: 7 additions & 1 deletion dsgai_scanner_tool/tests/expected-findings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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: []
2 changes: 2 additions & 0 deletions dsgai_scanner_tool/tests/fixtures/vulnerable-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Intentionally vulnerable unbounded LLM retry loop."""


def ask_until_success(prompt):
while True:
response = chat(prompt)
if response:
return response
5 changes: 4 additions & 1 deletion dsgai_scanner_tool/tests/regen_expected.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
12 changes: 11 additions & 1 deletion dsgai_scanner_tool/tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down