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
20 changes: 20 additions & 0 deletions dsgai_scanner_tool/CHANGES_v0.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions dsgai_scanner_tool/cli/dsgai_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"], "<unmapped>")
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):
Expand Down
11 changes: 0 additions & 11 deletions dsgai_scanner_tool/cli/dsgai_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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 {}
Expand Down
2 changes: 1 addition & 1 deletion dsgai_scanner_tool/integrations/dsgai-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions dsgai_scanner_tool/tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<script>",
"classification": "structural"}
rendered = dsgai_report.loc(f, {}, internal=False)
assert "app/secret/config.py" not in rendered # no real-path leak
assert "unmapped" in rendered # placeholder (HTML-escaped)
assert "<script>" not in rendered # line escaped


def test_rules_json_in_sync():
from_yaml = yaml.safe_load(open(RULES_YAML, encoding="utf-8"))
rebuilt = json.dumps(from_yaml, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
Expand Down
Loading