Skip to content

Commit 0874a0b

Browse files
authored
feat(scanner): checkpoint schema, redaction guard, CI self-test (PR-06) (#35)
- schemas/dsgai-scan.schema.json: formal schema for DSGAI-scan.json that FORBIDS match_text/content/value/raw_grep_output on every finding ('field': false, not 'not/required' which would miss a lone field). The redaction guarantee is now machine-checkable. - CLI self-validates the checkpoint (stdlib, no jsonschema at runtime) before writing and refuses to emit a finding carrying match content (exit 2). - checkpoint_is_valid(): cache invalidation — a checkpoint may be reused only at the current HEAD, on a clean tree, with the current ruleset. The skill's resume logic uses this from PR-07 on; never serve stale findings with a fresh date. - .github/workflows/scanner-selftest.yml: installs ripgrep, runs pytest, validates the ruleset + a fixture scan against their schemas, and asserts the JSON stays in sync with the YAML. This is the gate that makes external rule PRs safely mergeable. - Fixed the PCRE compile check to key on rg's exit code (>=2) instead of the string 'regex parse error' — PCRE2 emits a different message, so the old check would have missed a broken PCRE2 pattern (and not failed CI). Added a guard-the-guard test. Acceptance: pytest green (16 checks); schema rejects a match_text finding (negative test); a deliberately broken PCRE is detected by exit code.
1 parent c6a119e commit 0874a0b

5 files changed

Lines changed: 240 additions & 2 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: scanner-selftest
2+
3+
# The gate that makes external rule PRs safely mergeable: installs ripgrep,
4+
# runs the pytest suite, and validates the ruleset + a fixture scan against
5+
# their schemas. Path-filtered to the scanner subproject.
6+
on:
7+
push:
8+
branches: [main]
9+
paths:
10+
- 'dsgai_scanner_tool/**'
11+
- '.github/workflows/scanner-selftest.yml'
12+
pull_request:
13+
paths:
14+
- 'dsgai_scanner_tool/**'
15+
- '.github/workflows/scanner-selftest.yml'
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
selftest:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- name: Checkout
25+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
26+
27+
- name: Set up Python
28+
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
29+
with:
30+
python-version: '3.11'
31+
32+
- name: Install ripgrep (with PCRE2)
33+
run: sudo apt-get update -qq && sudo apt-get install -y ripgrep
34+
35+
- name: Install dev dependencies
36+
run: python -m pip install --quiet -r dsgai_scanner_tool/requirements-dev.txt
37+
38+
- name: Validate ruleset against its schema
39+
run: |
40+
python -c "import yaml, json, jsonschema; \
41+
jsonschema.validate(yaml.safe_load(open('dsgai_scanner_tool/rules/dsgai-rules.yaml')), \
42+
json.load(open('dsgai_scanner_tool/rules/rules.schema.json'))); \
43+
print('ruleset schema OK')"
44+
45+
- name: Assert compiled JSON is in sync with YAML
46+
run: python dsgai_scanner_tool/build/build_rules_json.py --check
47+
48+
- name: Run the self-test suite
49+
working-directory: dsgai_scanner_tool
50+
run: python -m pytest tests/test_runner.py -q
51+
52+
- name: Validate a fixture scan against the checkpoint + SARIF schemas
53+
working-directory: dsgai_scanner_tool
54+
run: |
55+
python cli/dsgai_scan.py scan tests/fixtures/vulnerable-app \
56+
--json-out /tmp/DSGAI-scan.json --sarif /tmp/scan.sarif --format none || true
57+
python -c "import json, jsonschema; \
58+
jsonschema.validate(json.load(open('/tmp/DSGAI-scan.json')), \
59+
json.load(open('schemas/dsgai-scan.schema.json'))); \
60+
print('checkpoint schema OK')"
61+
python -c "import json; s=json.load(open('/tmp/scan.sarif')); \
62+
assert s['version']=='2.1.0' and s['runs'][0]['tool']['driver']['name']=='dsgai-scan'; \
63+
print('SARIF OK')"

dsgai_scanner_tool/CHANGES_v0.3.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ dates are ISO-8601. The previous line is recorded in [`CHANGES_v0.2.md`](CHANGES
4444
(9 tests) asserts every PCRE compiles, the fixture scan matches the answer sheet
4545
exactly, SARIF validity, and the redaction guarantee. `requirements-dev.txt` +
4646
dependabot `pip` for the scanner. (PR-05)
47+
- **Checkpoint schema + CI self-test**: `schemas/dsgai-scan.schema.json` formalizes
48+
`DSGAI-scan.json` and **forbids** `match_text`/`content`/`value`/`raw_grep_output` on
49+
every finding (`"field": false`), making the redaction guarantee machine-checkable.
50+
The CLI self-validates its checkpoint (stdlib) before writing and gained a
51+
cache-invalidation check (`checkpoint_is_valid`: reuse only at current HEAD, clean
52+
tree, matching ruleset). New `.github/workflows/scanner-selftest.yml` installs
53+
ripgrep, runs pytest, and validates the ruleset + a fixture scan against their schemas
54+
— the gate that makes external rule PRs safely mergeable. The PCRE compile check now
55+
keys on rg's exit code (catches PCRE2 errors the old substring check missed). (PR-06)
4756

4857
### Changed
4958
- `DSGAI-samplereport.png` compressed from ~5.0 MB to ~0.35 MB (14×) as an interim fix;

dsgai_scanner_tool/cli/dsgai_scan.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@
3030
SKILL_VERSION = "0.3.0"
3131

3232
VALUE_BEARING_CONTROLS = {"DSGAI02", "DSGAI13", "DSGAI14", "DSGAI15"}
33+
# Fields that must never appear on a finding — the redaction guarantee, enforced
34+
# in code before the checkpoint is written (and by schemas/dsgai-scan.schema.json).
35+
BANNED_FINDING_FIELDS = {"match_text", "content", "value", "raw_grep_output"}
36+
CHECKPOINT_REQUIRED = {
37+
"schema_version", "ruleset_version", "skill_version", "framework", "engine",
38+
"git_commit", "scanned_at", "scan_scope", "obfuscation", "controls",
39+
"findings", "cves", "file_map_ref",
40+
}
3341
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "env",
3442
"dist", "build", ".mypy_cache", ".pytest_cache", ".tox", ".idea"}
3543

@@ -349,6 +357,41 @@ def build_checkpoint(ruleset, findings, controls, scope, obfuscation, target):
349357
}
350358

351359

360+
def self_validate_checkpoint(cp):
361+
"""Stdlib self-check run before writing the checkpoint (no jsonschema at
362+
runtime). Enforces required fields and the redaction guarantee."""
363+
missing = CHECKPOINT_REQUIRED - set(cp)
364+
if missing:
365+
raise ValueError(f"checkpoint missing required fields: {sorted(missing)}")
366+
for f in cp["findings"]:
367+
leaked = BANNED_FINDING_FIELDS & set(f)
368+
if leaked:
369+
raise ValueError(f"finding would leak match content via {sorted(leaked)}: "
370+
f"{f.get('rule_id')} {f.get('path')}")
371+
return True
372+
373+
374+
def checkpoint_is_valid(cp, target, current_ruleset_version):
375+
"""Cache invalidation: a checkpoint may be reused only if it was produced at
376+
the current HEAD, on a clean working tree, with the current ruleset. Anything
377+
else means the findings could be stale — delete and rescan. A compliance
378+
artifact must never serve stale findings with a fresh date. (The skill's
379+
resume logic calls this from PR-07 on.)"""
380+
if cp.get("ruleset_version") != current_ruleset_version:
381+
return False
382+
head = git_commit(target)
383+
if not head or cp.get("git_commit") != head:
384+
return False
385+
try:
386+
r = subprocess.run(["git", "-C", os.path.abspath(target), "status", "--porcelain"],
387+
capture_output=True, text=True)
388+
if r.returncode != 0 or r.stdout.strip():
389+
return False # dirty working tree
390+
except Exception:
391+
return False
392+
return True
393+
394+
352395
SARIF_LEVEL = {"fail": "error", "warn": "warning", "pass_signal": "note",
353396
"count": "note", "info": "note"}
354397

@@ -440,6 +483,12 @@ def cmd_scan(args):
440483
checkpoint = build_checkpoint(ruleset, findings, controls, scope,
441484
args.obfuscation, args.target)
442485

486+
try:
487+
self_validate_checkpoint(checkpoint)
488+
except ValueError as exc:
489+
eprint(f"error: refusing to write an invalid checkpoint: {exc}")
490+
return 2
491+
443492
if args.format == "table":
444493
print_table(controls, findings)
445494
if args.json_out:
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://github.com/GenAI-Security-Project/GenAI-Data-Security-Initiative/dsgai_scanner_tool/schemas/dsgai-scan.schema.json",
4+
"title": "DSGAI scan checkpoint (DSGAI-scan.json)",
5+
"description": "Formal schema for the scanner checkpoint. Forbidding match_text/content/value/raw_grep_output on every finding makes the redaction guarantee machine-checkable.",
6+
"type": "object",
7+
"required": [
8+
"schema_version", "ruleset_version", "skill_version", "framework",
9+
"engine", "git_commit", "scanned_at", "scan_scope", "obfuscation",
10+
"controls", "findings", "cves", "file_map_ref"
11+
],
12+
"additionalProperties": false,
13+
"properties": {
14+
"schema_version": { "const": "1.0" },
15+
"ruleset_version": { "type": "string" },
16+
"skill_version": { "type": "string" },
17+
"framework": { "type": "string", "pattern": "^dsgai-[0-9]{4}-v[0-9]+\\.[0-9]+$" },
18+
"engine": { "enum": ["deterministic-cli", "llm-grep"] },
19+
"git_commit": { "type": ["string", "null"] },
20+
"scanned_at": { "type": "string" },
21+
"scan_scope": { "type": "string" },
22+
"obfuscation": { "enum": ["strict", "internal"] },
23+
"controls": {
24+
"type": "object",
25+
"additionalProperties": {
26+
"enum": ["PASS", "WARN", "FAIL", "NOT VALIDATED", "NOT APPLICABLE",
27+
"VENDOR ATTESTATION REQUIRED"]
28+
}
29+
},
30+
"findings": { "type": "array", "items": { "$ref": "#/$defs/finding" } },
31+
"cves": { "type": "array" },
32+
"file_map_ref": { "type": ["string", "null"] }
33+
},
34+
"$defs": {
35+
"finding": {
36+
"type": "object",
37+
"required": ["control", "rule_id", "path", "line", "status", "classification"],
38+
"additionalProperties": false,
39+
"properties": {
40+
"control": { "type": "string", "pattern": "^DSGAI[0-9]{2}$" },
41+
"rule_id": { "type": "string", "pattern": "^P[0-9]{2}\\.[0-9]+$" },
42+
"path": { "type": "string", "minLength": 1 },
43+
"line": { "type": "integer", "minimum": 1 },
44+
"status": { "enum": ["fail", "warn", "pass_signal", "count", "info"] },
45+
"classification": { "enum": ["structural", "value_bearing"] },
46+
"note": { "type": "string" },
47+
48+
"match_text": false,
49+
"content": false,
50+
"value": false,
51+
"raw_grep_output": false
52+
}
53+
}
54+
}
55+
}

dsgai_scanner_tool/tests/test_runner.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
SHEET = os.path.join(HERE, "expected-findings.yaml")
2626
RULES_YAML = os.path.join(SCANNER, "rules", "dsgai-rules.yaml")
2727
RULES_JSON = os.path.join(SCANNER, "rules", "dsgai-rules.json")
28+
RULES_SCHEMA = os.path.join(SCANNER, "rules", "rules.schema.json")
29+
SCAN_SCHEMA = os.path.join(SCANNER, "schemas", "dsgai-scan.schema.json")
2830
BANNED_FIELDS = {"match_text", "content", "value", "raw_grep_output"}
2931

3032

@@ -67,11 +69,24 @@ def test_all_pcres_compile():
6769
for r in _rules():
6870
p = subprocess.run([rg, "--pcre2", "-q", "-e", r["pcre"]],
6971
input="x\n", capture_output=True, text=True)
70-
if p.returncode >= 2 and "regex parse error" in p.stderr:
71-
bad.append(r["id"])
72+
# rg exit codes: 0 match, 1 no match, >=2 error (incl. any regex/PCRE2
73+
# compile failure — the message differs between engines, so key on the
74+
# exit code, not a substring).
75+
if p.returncode >= 2:
76+
bad.append((r["id"], p.stderr.strip()[:120]))
7277
assert not bad, f"PCREs failed to compile: {bad}"
7378

7479

80+
@requires_rg
81+
def test_compile_check_catches_a_broken_pattern():
82+
"""Guard the guard: a deliberately invalid PCRE must be detected, so a
83+
corrupted rule really does fail CI."""
84+
rg = _rg()
85+
p = subprocess.run([rg, "--pcre2", "-q", "-e", "(unterminated[class"],
86+
input="x\n", capture_output=True, text=True)
87+
assert p.returncode >= 2
88+
89+
7590
@requires_rg
7691
def test_scan_matches_sheet_exactly(scan, sheet):
7792
def key(f):
@@ -144,6 +159,53 @@ def test_value_bearing_execution_always_replaces():
144159
assert "--replace" in window, "value-bearing branch does not pass --replace"
145160

146161

162+
@requires_rg
163+
def test_checkpoint_validates_against_schema(scan):
164+
jsonschema = pytest.importorskip("jsonschema")
165+
schema = json.loads(open(SCAN_SCHEMA, encoding="utf-8").read())
166+
jsonschema.validate(scan["json"], schema)
167+
168+
169+
def test_scan_schema_rejects_match_text():
170+
"""The redaction guarantee is machine-checkable: a finding carrying match
171+
content must be rejected by the checkpoint schema."""
172+
jsonschema = pytest.importorskip("jsonschema")
173+
schema = json.loads(open(SCAN_SCHEMA, encoding="utf-8").read())
174+
bad = {
175+
"schema_version": "1.0", "ruleset_version": "0.3.0", "skill_version": "0.3.0",
176+
"framework": "dsgai-2026-v1.0", "engine": "deterministic-cli",
177+
"git_commit": None, "scanned_at": "2023-11-14T22:13:20+00:00",
178+
"scan_scope": ".", "obfuscation": "strict", "controls": {},
179+
"findings": [{
180+
"control": "DSGAI02", "rule_id": "P02.1", "path": "config.py",
181+
"line": 7, "status": "fail", "classification": "value_bearing",
182+
"match_text": "sk-proj-LEAKED",
183+
}],
184+
"cves": [], "file_map_ref": None,
185+
}
186+
with pytest.raises(jsonschema.ValidationError):
187+
jsonschema.validate(bad, schema)
188+
189+
190+
def test_rules_validate_against_schema():
191+
jsonschema = pytest.importorskip("jsonschema")
192+
rules = yaml.safe_load(open(RULES_YAML, encoding="utf-8"))
193+
schema = json.loads(open(RULES_SCHEMA, encoding="utf-8").read())
194+
jsonschema.validate(rules, schema)
195+
196+
197+
def test_cli_self_guard_rejects_leaked_finding():
198+
"""The CLI's runtime (stdlib) self-check must refuse to write a checkpoint
199+
whose finding carries match content — independent of jsonschema."""
200+
sys.path.insert(0, os.path.join(SCANNER, "cli"))
201+
import dsgai_scan
202+
cp = {k: v for k, v in zip(dsgai_scan.CHECKPOINT_REQUIRED,
203+
[None] * len(dsgai_scan.CHECKPOINT_REQUIRED))}
204+
cp["findings"] = [{"rule_id": "P02.1", "path": "x", "match_text": "sk-LEAK"}]
205+
with pytest.raises(ValueError):
206+
dsgai_scan.self_validate_checkpoint(cp)
207+
208+
147209
def test_rules_json_in_sync():
148210
from_yaml = yaml.safe_load(open(RULES_YAML, encoding="utf-8"))
149211
rebuilt = json.dumps(from_yaml, indent=2, sort_keys=True, ensure_ascii=False) + "\n"

0 commit comments

Comments
 (0)