diff --git a/dsgai_scanner_tool/build/export_semgrep.py b/dsgai_scanner_tool/build/export_semgrep.py index 0e1bd57..a385190 100644 --- a/dsgai_scanner_tool/build/export_semgrep.py +++ b/dsgai_scanner_tool/build/export_semgrep.py @@ -35,6 +35,8 @@ def render(): for r in data["rules"]: if r["classification"] != "structural": continue + if r.get("match") == "file_exists": + continue # file-existence rules aren't expressible as pattern-regex sev = SEVERITY.get(r["signal"], "INFO") includes = ", ".join(yq(g) for g in r["file_globs"]) # Semgrep pattern-regex runs against whole-file text; force multiline so a diff --git a/dsgai_scanner_tool/cli/dsgai_scan.py b/dsgai_scanner_tool/cli/dsgai_scan.py index 230cd59..5fcc8e8 100644 --- a/dsgai_scanner_tool/cli/dsgai_scan.py +++ b/dsgai_scanner_tool/cli/dsgai_scan.py @@ -616,7 +616,12 @@ def cmd_scan(args): candidates = [f for f in files if glob_match(f[1], rule["file_globs"]) and not glob_match(f[1], rule.get("exclude_globs") or [])] - matches_by_rule[rule["id"]] = run_rule(rg, rule, candidates, scan_root) + if rule.get("match") == "file_exists": + # Presence of a matching FILE is the signal (e.g. an AI-ignore + # file exists → PASS). No content grep. Anchor at line 1. + matches_by_rule[rule["id"]] = {(f[1], 1) for f in candidates} + else: + matches_by_rule[rule["id"]] = run_rule(rg, rule, candidates, scan_root) rn = rule.get("requires_nearby") or {} if rn.get("pattern"): # Locate the corroborating pattern (always structural — a code diff --git a/dsgai_scanner_tool/dist/dsgai.semgrep.yaml b/dsgai_scanner_tool/dist/dsgai.semgrep.yaml index 3177a77..701c81b 100644 --- a/dsgai_scanner_tool/dist/dsgai.semgrep.yaml +++ b/dsgai_scanner_tool/dist/dsgai.semgrep.yaml @@ -743,19 +743,6 @@ rules: dsgai_rule: P12.7 confidence: medium framework: 'dsgai-2026-v1.0' - - id: dsgai-P16.1 - languages: [generic] - severity: INFO - message: 'DSGAI16 P16.1: AI-ignore file present (PASS)' - patterns: - - pattern-regex: '(\.(copilot|ai|cursor|continue|codeium)ignore)' - paths: - include: ['.copilotignore', '.aiignore', '.cursorignore', '.continueignore', '.codeiumignore', '.vscode/settings.json', 'cursor.json', 'continue.json'] - metadata: - dsgai_control: DSGAI16 - dsgai_rule: P16.1 - confidence: medium - framework: 'dsgai-2026-v1.0' - id: dsgai-P16.2 languages: [generic] severity: INFO @@ -997,7 +984,7 @@ rules: patterns: - pattern-regex: '(?m)(api_key[^\n]{0,15}(verify|validate)|bearer_token[^\n]{0,15}validate|Authorization[^\n]{0,15}required|@require_auth|auth_middleware|Depends\(.*auth)' paths: - include: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] + include: ['*.py', '*.ts', '*.java', '*.kt', '*.go', '*.js'] metadata: dsgai_control: DSGAI20 dsgai_rule: P20.1 @@ -1010,7 +997,7 @@ rules: patterns: - pattern-regex: '(rate_limit|RateLimiter|@throttle|slowapi|flask_limiter|express-rate-limit|@limiter\.limit)' paths: - include: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] + include: ['*.py', '*.ts', '*.java', '*.kt', '*.go', '*.js'] metadata: dsgai_control: DSGAI20 dsgai_rule: P20.2 @@ -1023,7 +1010,7 @@ rules: patterns: - pattern-regex: '(max_input_length\s*=|max_input_tokens\s*=|input[._-]truncat|validate[._-]length)' paths: - include: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] + include: ['*.py', '*.ts', '*.java', '*.kt', '*.go', '*.js'] metadata: dsgai_control: DSGAI20 dsgai_rule: P20.3 @@ -1036,7 +1023,7 @@ rules: patterns: - pattern-regex: '(prompt_injection_detect|detect_injection|llm_guard|rebuff|input_guard|nemo[._-]guard)' paths: - include: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] + include: ['*.py', '*.ts', '*.java', '*.kt', '*.go', '*.js'] metadata: dsgai_control: DSGAI20 dsgai_rule: P20.4 @@ -1049,7 +1036,7 @@ rules: patterns: - pattern-regex: '(?m)(@app\.(post|get)\(["''][^"'']*(chat|generate|completion|infer|predict)|@router\.(post|get)\(["''][^"'']*(chat|generate|completion|infer|predict)|app\.(post|get)\(["''][^"'']*(chat|generate|completion|infer|predict))' paths: - include: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] + include: ['*.py', '*.ts', '*.java', '*.kt', '*.go', '*.js'] metadata: dsgai_control: DSGAI20 dsgai_rule: P20.5 diff --git a/dsgai_scanner_tool/rules/dsgai-rules.json b/dsgai_scanner_tool/rules/dsgai-rules.json index 8446de7..05009cf 100644 --- a/dsgai_scanner_tool/rules/dsgai-rules.json +++ b/dsgai_scanner_tool/rules/dsgai-rules.json @@ -1765,6 +1765,7 @@ ], "framework": "dsgai-2026-v1.0", "id": "P16.1", + "match": "file_exists", "name": "ai-ignore-file-present", "notes": "Filename-existence check (not content grep). Absence in a repo with .env or secrets/ = WARN.", "pcre": "(\\.(copilot|ai|cursor|continue|codeium)ignore)", @@ -2138,7 +2139,8 @@ "*.ts", "*.java", "*.kt", - "*.go" + "*.go", + "*.js" ], "framework": "dsgai-2026-v1.0", "id": "P20.1", @@ -2157,7 +2159,8 @@ "*.ts", "*.java", "*.kt", - "*.go" + "*.go", + "*.js" ], "framework": "dsgai-2026-v1.0", "id": "P20.2", @@ -2176,7 +2179,8 @@ "*.ts", "*.java", "*.kt", - "*.go" + "*.go", + "*.js" ], "framework": "dsgai-2026-v1.0", "id": "P20.3", @@ -2195,7 +2199,8 @@ "*.ts", "*.java", "*.kt", - "*.go" + "*.go", + "*.js" ], "framework": "dsgai-2026-v1.0", "id": "P20.4", @@ -2214,7 +2219,8 @@ "*.ts", "*.java", "*.kt", - "*.go" + "*.go", + "*.js" ], "framework": "dsgai-2026-v1.0", "id": "P20.5", diff --git a/dsgai_scanner_tool/rules/dsgai-rules.yaml b/dsgai_scanner_tool/rules/dsgai-rules.yaml index 2ddd68d..a3d1c0d 100644 --- a/dsgai_scanner_tool/rules/dsgai-rules.yaml +++ b/dsgai_scanner_tool/rules/dsgai-rules.yaml @@ -899,6 +899,7 @@ rules: control: DSGAI16 name: ai-ignore-file-present classification: structural + match: file_exists signal: pass_signal confidence: medium pcre: '(\.(copilot|ai|cursor|continue|codeium)ignore)' @@ -1119,7 +1120,7 @@ rules: signal: pass_signal confidence: medium pcre: '(api_key[^\n]{0,15}(verify|validate)|bearer_token[^\n]{0,15}validate|Authorization[^\n]{0,15}required|@require_auth|auth_middleware|Depends\(.*auth)' - file_globs: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] + file_globs: ['*.py', '*.ts', '*.java', '*.kt', '*.go', '*.js'] exclude_globs: [] framework: 'dsgai-2026-v1.0' description: 'API auth required (PASS)' @@ -1130,7 +1131,7 @@ rules: signal: pass_signal confidence: medium pcre: '(rate_limit|RateLimiter|@throttle|slowapi|flask_limiter|express-rate-limit|@limiter\.limit)' - file_globs: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] + file_globs: ['*.py', '*.ts', '*.java', '*.kt', '*.go', '*.js'] exclude_globs: [] framework: 'dsgai-2026-v1.0' description: 'Rate limit (PASS)' @@ -1141,7 +1142,7 @@ rules: signal: pass_signal confidence: medium pcre: '(max_input_length\s*=|max_input_tokens\s*=|input[._-]truncat|validate[._-]length)' - file_globs: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] + file_globs: ['*.py', '*.ts', '*.java', '*.kt', '*.go', '*.js'] exclude_globs: [] framework: 'dsgai-2026-v1.0' description: 'Input length validation (PASS)' @@ -1152,7 +1153,7 @@ rules: signal: pass_signal confidence: medium pcre: '(prompt_injection_detect|detect_injection|llm_guard|rebuff|input_guard|nemo[._-]guard)' - file_globs: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] + file_globs: ['*.py', '*.ts', '*.java', '*.kt', '*.go', '*.js'] exclude_globs: [] framework: 'dsgai-2026-v1.0' description: 'Prompt injection detect (PASS)' @@ -1163,7 +1164,7 @@ rules: signal: count confidence: medium pcre: '(@app\.(post|get)\(["''][^"'']*(chat|generate|completion|infer|predict)|@router\.(post|get)\(["''][^"'']*(chat|generate|completion|infer|predict)|app\.(post|get)\(["''][^"'']*(chat|generate|completion|infer|predict))' - file_globs: ['*.py', '*.ts', '*.java', '*.kt', '*.go'] + file_globs: ['*.py', '*.ts', '*.java', '*.kt', '*.go', '*.js'] exclude_globs: [] framework: 'dsgai-2026-v1.0' description: 'Inference endpoint (count)' diff --git a/dsgai_scanner_tool/rules/rules.schema.json b/dsgai_scanner_tool/rules/rules.schema.json index 64d3f61..552b8c5 100644 --- a/dsgai_scanner_tool/rules/rules.schema.json +++ b/dsgai_scanner_tool/rules/rules.schema.json @@ -29,6 +29,7 @@ "control": { "type": "string", "pattern": "^DSGAI[0-9]{2}$" }, "name": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, "classification": { "enum": ["structural", "value_bearing"] }, + "match": { "enum": ["content", "file_exists"] }, "signal": { "enum": ["fail", "warn", "pass_signal", "count", "info"] }, "confidence": { "enum": ["high", "medium", "low"] }, "pcre": { "type": "string", "minLength": 1 }, diff --git a/dsgai_scanner_tool/tests/expected-findings.yaml b/dsgai_scanner_tool/tests/expected-findings.yaml index 187daa7..49032cf 100644 --- a/dsgai_scanner_tool/tests/expected-findings.yaml +++ b/dsgai_scanner_tool/tests/expected-findings.yaml @@ -5,24 +5,25 @@ # PR-05 self-test asserts the scan reproduces this set exactly. Line numbers are # load-bearing — regenerate with tests/regen_expected.py after editing a fixture. # -# As of PR-11 there are NO known_bug entries: the confirmed v0.2 false negative -# (unquoted .env) and false positive (innocent webhook) are fixed and reflected -# below. P02.9 (raw token literals) now catches keys assigned to arbitrary names. +# Post-audit: no known_bug entries remain; DSGAI16 now PASSes via the .aiignore +# file-existence rule (P16.1), and the JS /chat endpoint is caught (P20.5) now +# that DSGAI20 scans *.js. framework: dsgai-2026-v1.0 findings: - # DSGAI02 — credentials. P02.1 is now quote-optional (catches the unquoted .env); - # P02.9 catches raw token literals (sk-proj-, xoxb-, ...) by any variable name. + # DSGAI16 — AI-ignore file present (P16.1 file-existence → PASS) + sensitive paths listed + - {control: DSGAI16, rule_id: P16.1, path: .aiignore, line: 1, status: pass_signal} + - {control: DSGAI16, rule_id: P16.2, path: .aiignore, line: 1, status: info} + - {control: DSGAI16, rule_id: P16.2, path: .aiignore, line: 2, status: info} + # DSGAI02 — credentials (P02.1 quote-optional; P02.9 raw tokens, incl. gitignored .env) - {control: DSGAI02, rule_id: P02.1, path: .env, line: 3, status: fail, classification: value_bearing} - {control: DSGAI02, rule_id: P02.9, path: .env, line: 3, status: fail, classification: value_bearing} - {control: DSGAI02, rule_id: P02.1, path: config.py, line: 7, status: fail, classification: value_bearing} - {control: DSGAI02, rule_id: P02.9, path: config.py, line: 7, status: fail, classification: value_bearing} - {control: DSGAI02, rule_id: P02.7, path: good_config.py, line: 11, status: pass_signal, classification: value_bearing} - {control: DSGAI02, rule_id: P02.9, path: js-service/index.js, line: 11, status: fail, classification: value_bearing} - # Multi-language credential coverage (PR-15): P02.9 now scans *.cs/*.rs/*.rb - - {control: DSGAI02, rule_id: P02.9, path: polyglot/Config.cs, line: 5, status: fail, classification: value_bearing} - - {control: DSGAI02, rule_id: P02.9, path: polyglot/config.rb, line: 3, status: fail, classification: value_bearing} - - {control: DSGAI02, rule_id: P02.9, path: polyglot/config.rs, line: 3, status: fail, classification: value_bearing} + # DSGAI20 — unauthenticated JS /chat endpoint (now caught: *.js coverage) + - {control: DSGAI20, rule_id: P20.5, path: js-service/index.js, line: 13, status: fail} # DSGAI04 — supply chain - {control: DSGAI04, rule_id: P04.1, path: loader.py, line: 10, status: fail} - {control: DSGAI04, rule_id: P04.4, path: requirements.txt, line: 6, status: warn} @@ -30,8 +31,12 @@ findings: # DSGAI06 — MCP / plugin - {control: DSGAI06, rule_id: P06.1, path: mcp_config.json, line: 4, status: fail} - {control: DSGAI06, rule_id: P06.5, path: server.py, line: 18, status: fail} - # DSGAI13 — P13.3 also matches host 0.0.0.0 in server.py under v0.2 globs + # DSGAI13 — P13.3 host 0.0.0.0 in server.py - {control: DSGAI13, rule_id: P13.3, path: server.py, line: 18, status: warn, classification: value_bearing} + # DSGAI02 polyglot (C#/Rust/Ruby credential coverage) + - {control: DSGAI02, rule_id: P02.9, path: polyglot/Config.cs, line: 5, status: fail, classification: value_bearing} + - {control: DSGAI02, rule_id: P02.9, path: polyglot/config.rb, line: 3, status: fail, classification: value_bearing} + - {control: DSGAI02, rule_id: P02.9, path: polyglot/config.rs, line: 3, status: fail, classification: value_bearing} # DSGAI11 — multi-tenant isolation - {control: DSGAI11, rule_id: P11.1, path: retriever.py, line: 12, status: fail} - {control: DSGAI11, rule_id: P11.1, path: retriever.py, line: 30, status: pass_signal} @@ -39,12 +44,12 @@ findings: # DSGAI05 — RAG tenant-filter PASS signals - {control: DSGAI05, rule_id: P05.2, path: retriever.py, line: 30, status: pass_signal} - {control: DSGAI05, rule_id: P05.3, path: retriever.py, line: 30, status: pass_signal} - # DSGAI12 — database agent. P12.1 now fires only with an LLM call nearby (webhook dropped). + # DSGAI12 — database agent - {control: DSGAI12, rule_id: P12.2, path: sql_agent.py, line: 7, status: fail} - {control: DSGAI12, rule_id: P12.2, path: sql_agent.py, line: 8, status: fail} - {control: DSGAI12, rule_id: P12.2, path: sql_agent.py, line: 13, status: fail} - {control: DSGAI12, rule_id: P12.1, path: sql_agent.py, line: 20, status: fail} - # DSGAI15 / DSGAI02 — secret in system prompt (P15.1 + P02.9 on the same line) + # DSGAI15 / DSGAI02 — secret in system prompt - {control: DSGAI02, rule_id: P02.9, path: system_prompt.py, line: 8, status: fail, classification: value_bearing} - {control: DSGAI15, rule_id: P15.1, path: system_prompt.py, line: 8, status: fail, classification: value_bearing} # DSGAI14 — telemetry content capture @@ -61,8 +66,7 @@ must_not_flag: - {rule_id: P02.1, path: good_config.py, reason: "secret comes from Vault, not hardcoded"} - {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 (fixed in PR-11)"} + - {rule_id: P12.1, path: webhook.py, reason: "innocent webhook — no LLM call nearby"} -# Things the ruleset still misses, tracked to the PR that fixes each. -known_false_negatives: - - {control: DSGAI20, rule_id: P20.5, path: js-service/index.js, line: 12, reason: "unauthenticated /chat endpoint; DSGAI20 globs exclude *.js", fixed_in: PR-15} +# 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/.aiignore b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/.aiignore new file mode 100644 index 0000000..305fac2 --- /dev/null +++ b/dsgai_scanner_tool/tests/fixtures/vulnerable-app/.aiignore @@ -0,0 +1,2 @@ +.env +secrets/ diff --git a/dsgai_scanner_tool/tests/regen_expected.py b/dsgai_scanner_tool/tests/regen_expected.py index 89e9435..d700159 100644 --- a/dsgai_scanner_tool/tests/regen_expected.py +++ b/dsgai_scanner_tool/tests/regen_expected.py @@ -55,6 +55,9 @@ def raw_matches(rg): rel = os.path.relpath(f, FIXTURE).replace(os.sep, "/") if not glob_match(rel, r["file_globs"]): continue + 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], capture_output=True, text=True) for line in p.stdout.splitlines():