From 67993deb4cb6663f9e16927aec9d541d419ad6cb Mon Sep 17 00:00:00 2001 From: emmanuelgjr Date: Sat, 18 Jul 2026 14:29:21 -0400 Subject: [PATCH] fix(scanner): report escaping + dead-code cleanup + changelog (audit L1/L3) - L3: STRICT report loc() no longer falls back to the real path on a filemap miss (renders ); the line number is HTML-escaped (defense-in-depth). - L1: remove dead nearby() and VALUE_BEARING_CONTROLS; checkpoint_is_valid now exercised by a unit test (cache-invalidation logic verified) rather than unused. - Remove the Action's unused dsgai-tool/schemas mkdir. - CHANGES: consolidated 'hard-audit follow-up' section documenting all fixes. 26 tests pass; actionlint clean. --- dsgai_scanner_tool/CHANGES_v0.3.md | 20 +++++++++++ dsgai_scanner_tool/cli/dsgai_report.py | 6 ++-- dsgai_scanner_tool/cli/dsgai_scan.py | 11 ------- .../integrations/dsgai-scan.yml | 2 +- dsgai_scanner_tool/tests/test_runner.py | 33 +++++++++++++++++++ 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/dsgai_scanner_tool/CHANGES_v0.3.md b/dsgai_scanner_tool/CHANGES_v0.3.md index 375d64d..e821987 100644 --- a/dsgai_scanner_tool/CHANGES_v0.3.md +++ b/dsgai_scanner_tool/CHANGES_v0.3.md @@ -8,6 +8,26 @@ dates are ISO-8601. The previous line is recorded in [`CHANGES_v0.2.md`](CHANGES ## [Unreleased] +### Fixed (hard-audit follow-up) +- **CRITICAL — gitignored `.env` files are now scanned.** Discovery honored `.gitignore`, + so the flagship "hardcoded key in `.env`" detection silently didn't run on real repos. + Credential files (`.env*`) are now always scanned. +- **Action exfiltration channel closed.** The narrate job no longer runs an LLM over the + raw untrusted repo with a secret + Bash egress — it renders deterministically via + `cli/dsgai_report.py` (no LLM, no API key in the workflow at all). +- **CVE severity/coverage:** severity computed locally from OSV's CVSS vector (a 9.8 no + longer shows INFO when NVD is down; deterministic); go.mod + package-lock.json parsers + (Go/npm had zero coverage); failed fetches no longer poison the cache. +- **Engine:** `rg` invocations are batched so large repos don't crash the arg limit + (a crash exited 1, masquerading as findings; now exits 2). +- **Coverage:** P16.1 file-existence rule mode makes DSGAI16 PASS reachable; DSGAI20 now + scans `*.js` (unauthenticated JS `/chat` endpoint caught). +- **Packs/docs:** gitleaks catches `AZURE_OPENAI_KEY`/`GCP_*`/`AWS_*` names; Semgrep + export forces `(?m)` for line-anchored rules; the tool-neutral prompt variant no longer + leaks Claude-Code-isms; the pre-commit hook probes for PCRE2 instead of failing open. +- **Report/cleanup:** STRICT report never falls back to a real path on a filemap miss; + line numbers escaped; removed dead code; wired the `run_cve_enrichment` toggle. + ### Added - **Benchmark methodology + announcement drafts** (PR-16 hand-off). `docs/BENCHMARK.md` (corpus selection, deterministic run steps, a labeling-sheet template, a per-rule diff --git a/dsgai_scanner_tool/cli/dsgai_report.py b/dsgai_scanner_tool/cli/dsgai_report.py index 15aa396..c148582 100644 --- a/dsgai_scanner_tool/cli/dsgai_report.py +++ b/dsgai_scanner_tool/cli/dsgai_report.py @@ -50,9 +50,11 @@ def build_filemap(cp): def loc(f, filemap, internal): - p = f["path"] if internal else filemap.get(f["path"], f["path"]) + # In STRICT mode, a filemap miss must NOT fall back to the real path — that + # would leak it into the "shareable" report. Use a placeholder (audit L3). + p = f["path"] if internal else filemap.get(f["path"], "") redacted = " (value redacted)" if f.get("classification") == "value_bearing" else "" - return f"{esc(p)}:{f['line']}{esc(redacted)}" + return f"{esc(p)}:{esc(f['line'])}{esc(redacted)}" # escape line too (defense-in-depth) def render(cp, prose, internal): diff --git a/dsgai_scanner_tool/cli/dsgai_scan.py b/dsgai_scanner_tool/cli/dsgai_scan.py index 5fcc8e8..12571e9 100644 --- a/dsgai_scanner_tool/cli/dsgai_scan.py +++ b/dsgai_scanner_tool/cli/dsgai_scan.py @@ -29,7 +29,6 @@ SCHEMA_VERSION = "1.0" SKILL_VERSION = "0.3.0" -VALUE_BEARING_CONTROLS = {"DSGAI02", "DSGAI13", "DSGAI14", "DSGAI15"} # Fields that must never appear on a finding — the redaction guarantee, enforced # in code before the checkpoint is written (and by schemas/dsgai-scan.schema.json). BANNED_FINDING_FIELDS = {"match_text", "content", "value", "raw_grep_output"} @@ -283,16 +282,6 @@ def detect_stack(rg, files, scan_root): return detected -def nearby(matches_by_rule, required_ids, path, line, window, module_scope): - for rid in required_ids: - for (mp, ml) in matches_by_rule.get(rid, ()): # noqa: E501 - if mp != path: - continue - if module_scope or abs(ml - line) <= window: - return True - return False - - def resolve_findings(rules, matches_by_rule, detected, nearby_matches=None): """Apply subtract / requires_nearby / gating to produce resolved findings.""" nearby_matches = nearby_matches or {} diff --git a/dsgai_scanner_tool/integrations/dsgai-scan.yml b/dsgai_scanner_tool/integrations/dsgai-scan.yml index f5cbc7f..22120b5 100644 --- a/dsgai_scanner_tool/integrations/dsgai-scan.yml +++ b/dsgai_scanner_tool/integrations/dsgai-scan.yml @@ -73,7 +73,7 @@ jobs: - name: Fetch the DSGAI scanner (pinned commit) run: | - mkdir -p dsgai-tool/cli dsgai-tool/rules dsgai-tool/schemas + mkdir -p dsgai-tool/cli dsgai-tool/rules base="https://raw.githubusercontent.com/GenAI-Security-Project/GenAI-Data-Security-Initiative/${DSGAI_PIN}/dsgai_scanner_tool" curl -fsSL "$base/cli/dsgai_scan.py" -o dsgai-tool/cli/dsgai_scan.py curl -fsSL "$base/cli/dsgai_cve.py" -o dsgai-tool/cli/dsgai_cve.py diff --git a/dsgai_scanner_tool/tests/test_runner.py b/dsgai_scanner_tool/tests/test_runner.py index 7165f15..8e60645 100644 --- a/dsgai_scanner_tool/tests/test_runner.py +++ b/dsgai_scanner_tool/tests/test_runner.py @@ -375,6 +375,39 @@ def test_rg_arg_batching(): assert sum(len(b) for b in batches) == len(files) # no file dropped +def test_checkpoint_is_valid_logic(tmp_path): + """Exercise the cache-invalidation helper (a stale ruleset invalidates).""" + ds = _import_cli() + repo = tmp_path / "r" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + (repo / "a.txt").write_text("x", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run(["git", "-c", "user.email=a@b.c", "-c", "user.name=t", + "commit", "-qm", "i"], cwd=repo, check=True) + head = subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, text=True).stdout.strip() + cp = {"git_commit": head, "ruleset_version": "0.3.0"} + assert ds.checkpoint_is_valid(cp, str(repo), "0.3.0") is True + assert ds.checkpoint_is_valid(cp, str(repo), "9.9.9") is False # ruleset drift + assert ds.checkpoint_is_valid({"git_commit": "deadbeef", "ruleset_version": "0.3.0"}, + str(repo), "0.3.0") is False # HEAD mismatch + + +def test_report_no_path_leak_on_filemap_miss(tmp_path): + """STRICT report must never fall back to a real path, and must escape the + line number (audit L3).""" + sys.path.insert(0, os.path.join(SCANNER, "cli")) + import dsgai_report + # a finding whose path is NOT in the (empty) filemap, with a malicious line + f = {"path": "app/secret/config.py", "line": "1