Skip to content

Commit ce6dbbb

Browse files
authored
feat(scanner): extract detection rules to data (PR-03) (#26)
Extract all 106 patterns from dsgai_scanner_tool.md Step 2 into rules/dsgai-rules.yaml as the source of truth. Patterns are copied VERBATIM (v0.2 bugs preserved — fixes land in PR-11 as reviewable diffs); every PCRE verified byte-identical to the skill and confirmed to compile under rg --pcre2. - rules/rules.schema.json: draft 2020-12 schema; the full file validates. - Encoded compound logic from the Step 2 prose: subtract (P04.1-P04.2), requires_nearby (P11.1/P18.4/P20.5/P05.1/P06.5), exclude_globs (P12.6), gated_on (multimodal/synthetic_data/labeling), plus notes for absence-group rules not yet fully formalized. - Per-rule classification/signal/confidence seeded; all DSGAI02/13/14/15 rules are value_bearing (21 total). - build/generate_rules.py: one-time extraction bootstrap (kept for audit). - build/build_rules_json.py: repeatable YAML->JSON build (--check gates CI); the CLI loads the JSON with stdlib only, preserving the one-file install. - rules/README.md documents the format; CONTRIBUTING now links it; skill Step 2 banner marks the YAML canonical. Acceptance: schema validation passes; rule-ID set == skill pattern-ID set (diff empty); all four value-bearing controls carry classification:value_bearing.
1 parent 089d42b commit ce6dbbb

9 files changed

Lines changed: 3855 additions & 5 deletions

File tree

‎dsgai_scanner_tool/CHANGES_v0.3.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ dates are ISO-8601. The previous line is recorded in [`CHANGES_v0.2.md`](CHANGES
1717
internal-link check), a deterministic markdown internal-link checker
1818
(`scripts/check_md_links.py`), and a `.gitattributes` forcing LF on scripts/YAML so
1919
Windows checkouts can't ship CRLF that breaks Linux CI. (PR-02)
20+
- **Rules as data**: all 106 detection patterns extracted verbatim from the skill's
21+
Step 2 into `rules/dsgai-rules.yaml` (source of truth), validated by
22+
`rules/rules.schema.json`, compiled to `rules/dsgai-rules.json` by
23+
`build/build_rules_json.py`. Compound logic (`subtract`, `requires_nearby`,
24+
`exclude_globs`, `gated_on`) and per-rule `classification`/`signal`/`confidence` are
25+
encoded. `rules/README.md` documents the format. Skill Step 2 now marks the YAML as
26+
canonical. (PR-03)
2027

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

‎dsgai_scanner_tool/CONTRIBUTING.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ make the tool measurably better — a good repro is the contribution.
2828
2929
## Contributing a rule
3030

31-
Detection rules are moving from prose in `dsgai_scanner_tool.md` into data at
32-
`rules/dsgai-rules.yaml`, validated by `rules/rules.schema.json`. **(Landing soon —
33-
tracked by PR-03.)** Until that lands, describe your rule in a
34-
[new-rule issue](../.github/ISSUE_TEMPLATE/scanner-new-rule.yml) using the format below;
35-
once the YAML rule format ships, this section will point at `rules/README.md`.
31+
Detection rules live as data in [`rules/dsgai-rules.yaml`](rules/dsgai-rules.yaml),
32+
validated by `rules/rules.schema.json` and compiled to `rules/dsgai-rules.json`. See
33+
[`rules/README.md`](rules/README.md) for the full field reference. You can also propose
34+
a rule without writing YAML via a
35+
[new-rule issue](../.github/ISSUE_TEMPLATE/scanner-new-rule.yml).
3636

3737
Every rule needs:
3838

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env python3
2+
"""Build rules/dsgai-rules.json from rules/dsgai-rules.yaml.
3+
4+
This is the repeatable build step (unlike the one-time generate_rules.py). The
5+
deterministic CLI loads the JSON with the standard library only, so the
6+
curl-one-file install story needs no PyYAML at runtime. PyYAML is required only
7+
to edit rules and to run this build / the self-test that asserts JSON == YAML.
8+
9+
Validates against rules/rules.schema.json before writing. Output is
10+
deterministic (sorted keys, stable rule order, trailing newline) so the PR-06
11+
self-test can assert the checked-in JSON matches a fresh build.
12+
13+
Usage:
14+
python build/build_rules_json.py # write rules/dsgai-rules.json
15+
python build/build_rules_json.py --check # verify checked-in JSON is current (CI)
16+
"""
17+
import json
18+
import sys
19+
from pathlib import Path
20+
21+
RULES_DIR = Path(__file__).resolve().parent.parent / "rules"
22+
YAML_PATH = RULES_DIR / "dsgai-rules.yaml"
23+
JSON_PATH = RULES_DIR / "dsgai-rules.json"
24+
SCHEMA_PATH = RULES_DIR / "rules.schema.json"
25+
26+
27+
def build():
28+
import yaml # only needed for build/edit, not at CLI runtime
29+
data = yaml.safe_load(YAML_PATH.read_text(encoding="utf-8"))
30+
try:
31+
import jsonschema
32+
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
33+
jsonschema.validate(data, schema)
34+
except ImportError:
35+
sys.stderr.write("warning: jsonschema not installed; skipping validation\n")
36+
return json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
37+
38+
39+
def main(argv):
40+
rendered = build()
41+
if "--check" in argv:
42+
current = JSON_PATH.read_text(encoding="utf-8") if JSON_PATH.exists() else ""
43+
if current != rendered:
44+
sys.stderr.write(
45+
"rules/dsgai-rules.json is out of date. Run: "
46+
"python build/build_rules_json.py\n")
47+
return 1
48+
print("rules/dsgai-rules.json is up to date.")
49+
return 0
50+
JSON_PATH.write_text(rendered, encoding="utf-8", newline="\n")
51+
print(f"Wrote {JSON_PATH} ({rendered.count(chr(10))} lines).")
52+
return 0
53+
54+
55+
if __name__ == "__main__":
56+
sys.exit(main(sys.argv[1:]))
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
#!/usr/bin/env python3
2+
"""One-time bootstrap: extract DSGAI scanner patterns from dsgai_scanner_tool.md
3+
Step 2 into rules/dsgai-rules.yaml.
4+
5+
Patterns are copied VERBATIM (bug-for-bug faithful — fixes come in PR-11 as
6+
reviewable diffs). Classification, signal, confidence, and compound logic
7+
(subtract / requires_nearby / exclude_globs / gated_on / notes) are augmented
8+
from the skill's prose and the improvement plan's Appendix B seeds.
9+
10+
After this bootstrap runs, rules/dsgai-rules.yaml is the hand-maintained source
11+
of truth; the skill prose becomes descriptive. Re-running would overwrite manual
12+
edits, so it is kept only for provenance / audit.
13+
14+
Usage: python build/generate_rules.py > rules/dsgai-rules.yaml
15+
"""
16+
import re
17+
import sys
18+
from pathlib import Path
19+
20+
SKILL = Path(__file__).resolve().parent.parent / "dsgai_scanner_tool.md"
21+
VALUE_BEARING_CONTROLS = {2, 13, 14, 15}
22+
FRAMEWORK = "dsgai-2026-v1.0"
23+
RULESET_VERSION = "0.3.0"
24+
25+
HEADER_RE = re.compile(r'^### DSGAI(\d{2}) Scan .*\[(STRUCTURAL|VALUE-BEARING)')
26+
FILES_RE = re.compile(r'^Files:\s*(.+)$')
27+
# Pattern line: ID <desc up to first colon>: <pcre to EOL>. ':' separator is
28+
# the FIRST colon — no rule description contains an internal colon.
29+
PAT_RE = re.compile(r'^(P\d{2}\.\d+)\s+(.+?):\s+(.+?)\s*$')
30+
GLOB_RE = re.compile(r'`([^`]+)`')
31+
32+
# Signal overrides for rules whose FAIL/PASS status comes from prose, not a
33+
# marker in the description text.
34+
SIGNAL_OVERRIDE = {
35+
"P02.1": "fail", "P02.2": "fail", "P02.3": "fail",
36+
"P02.4": "fail", "P02.5": "fail",
37+
}
38+
39+
# Compound logic and gating, transcribed from the Step 2 prose notes.
40+
SUBTRACT = {"P04.1": ["P04.2"]}
41+
REQUIRES_NEARBY = {
42+
"P05.1": {"rules": ["P05.2", "P05.3"], "scope": "module"},
43+
"P06.5": {"rule": "P06.2", "scope": "module", "absent": True},
44+
"P11.1": {"rule": "P11.2", "lines": 15},
45+
"P18.4": {"rule": "P18.5", "lines": 10},
46+
"P20.5": {"rules": ["P20.1", "P20.2"], "lines": 15},
47+
}
48+
EXCLUDE_GLOBS = {
49+
"P12.6": ["**/migrations/**", "**/fixtures/**", "**/tests/**", "**/test/**"],
50+
}
51+
GATED_ON = {
52+
"P09.1": "multimodal", "P09.2": "multimodal", "P09.3": "multimodal",
53+
"P09.4": "multimodal", "P09.5": "multimodal",
54+
"P10.1": "synthetic_data", "P10.2": "synthetic_data", "P10.3": "synthetic_data",
55+
"P10.4": "synthetic_data", "P10.5": "synthetic_data",
56+
"P19.1": "labeling", "P19.2": "labeling", "P19.3": "labeling", "P19.4": "labeling",
57+
}
58+
# Free-text prose that resists full formalization at import time (refined later).
59+
NOTES = {
60+
"P07.1": "Absence of P07.1-P07.4 in a multi-tenant or PII-handling repo = WARN.",
61+
"P08.1": "Absence of P08.1-P08.6 in a production GenAI service = WARN; "
62+
"absence in a high-risk EU AI Act use case = FAIL.",
63+
"P09.5": "Absence = note only (advanced control); P09.1-P09.4 absence in a "
64+
"multimodal pipeline = WARN.",
65+
"P10.1": "Synthetic data pipeline without any of P10.1/P10.2/P10.4 = FAIL.",
66+
"P12.6": "DDL in migrations/fixtures/tests is benign — excluded from FAIL.",
67+
"P14.4": "May contain inline PII in the format string — treated as VALUE-BEARING.",
68+
"P16.1": "Filename-existence check (not content grep). Absence in a repo with "
69+
".env or secrets/ = WARN.",
70+
"P16.2": "Matched inside any AI-ignore file.",
71+
"P17.1": "LLM-calling module with none of P17.1-P17.5 = WARN.",
72+
"P21.2": "P21.2 in an agent module without P21.3 = WARN.",
73+
}
74+
# P16 rules carry a mode prefix ('filename match:' / 'in any ignore file:') in
75+
# the pcre slot; strip it to the bare regex.
76+
MODE_PREFIX_RE = re.compile(r'^(filename match|in any ignore file):\s*')
77+
78+
79+
def slugify(text):
80+
text = re.sub(r'\([^)]*\)', '', text) # drop (FAIL)/(PASS)/... markers
81+
text = text.lower().strip()
82+
text = re.sub(r'[^a-z0-9]+', '-', text).strip('-')
83+
return text or "rule"
84+
85+
86+
def derive_signal(pid, desc):
87+
if pid in SIGNAL_OVERRIDE:
88+
return SIGNAL_OVERRIDE[pid]
89+
d = desc.lower()
90+
if "fail" in d:
91+
return "fail"
92+
if "warn" in d:
93+
return "warn"
94+
if "pass" in d:
95+
return "pass_signal"
96+
if "count" in d:
97+
return "count"
98+
return "info"
99+
100+
101+
def derive_confidence(pid, control, signal, pcre):
102+
# Value-bearing credential-literal FAILs are the high-confidence anchors.
103+
if control in VALUE_BEARING_CONTROLS and signal == "fail":
104+
return "high"
105+
if signal == "warn":
106+
return "low" # heuristic / absence-adjacent — weak evidence
107+
if signal in ("pass_signal", "count"):
108+
return "medium" # an import is not proof of correct use
109+
if signal == "fail":
110+
return "medium" # structural heuristic FAILs (e.g. P12.1, P17.6)
111+
return "low" # bare detection/info
112+
113+
114+
def yaml_scalar(s):
115+
"""Single-quote a scalar for YAML, escaping embedded single quotes."""
116+
return "'" + s.replace("'", "''") + "'"
117+
118+
119+
def emit_list(vals):
120+
return "[" + ", ".join(yaml_scalar(v) for v in vals) + "]"
121+
122+
123+
def main():
124+
# Ensure UTF-8 output regardless of the platform console codepage (Windows
125+
# defaults to cp1252, which corrupts em-dashes / non-ASCII on redirect).
126+
try:
127+
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
128+
except AttributeError:
129+
pass
130+
lines = SKILL.read_text(encoding="utf-8").splitlines()
131+
control = None
132+
classification = None
133+
file_globs = []
134+
in_fence = False
135+
rules = []
136+
unparsed = []
137+
138+
# Only scan Step 2 (between its header and Step 3).
139+
start = next(i for i, l in enumerate(lines) if l.startswith("## Step 2:"))
140+
end = next(i for i, l in enumerate(lines) if l.startswith("## Step 3:"))
141+
142+
for raw in lines[start:end]:
143+
line = raw.rstrip("\n")
144+
m = HEADER_RE.match(line)
145+
if m:
146+
control = int(m.group(1))
147+
classification = "value_bearing" if control in VALUE_BEARING_CONTROLS else "structural"
148+
file_globs = []
149+
in_fence = False
150+
continue
151+
fm = FILES_RE.match(line)
152+
if fm:
153+
file_globs = GLOB_RE.findall(fm.group(1))
154+
continue
155+
if line.strip().startswith("```"):
156+
in_fence = not in_fence
157+
continue
158+
if in_fence and control is not None:
159+
pm = PAT_RE.match(line)
160+
if not pm:
161+
if line.strip():
162+
unparsed.append(line)
163+
continue
164+
pid, desc, pcre = pm.group(1), pm.group(2).strip(), pm.group(3)
165+
pcre = MODE_PREFIX_RE.sub("", pcre).strip()
166+
signal = derive_signal(pid, desc)
167+
rules.append({
168+
"id": pid,
169+
"control": f"DSGAI{control:02d}",
170+
"name": slugify(desc),
171+
"classification": classification,
172+
"signal": signal,
173+
"confidence": derive_confidence(pid, control, signal, pcre),
174+
"pcre": pcre,
175+
"file_globs": list(file_globs),
176+
"exclude_globs": EXCLUDE_GLOBS.get(pid, []),
177+
"framework": FRAMEWORK,
178+
"description": desc,
179+
"subtract": SUBTRACT.get(pid),
180+
"requires_nearby": REQUIRES_NEARBY.get(pid),
181+
"gated_on": GATED_ON.get(pid),
182+
"notes": NOTES.get(pid),
183+
})
184+
185+
if unparsed:
186+
sys.stderr.write("UNPARSED LINES:\n" + "\n".join(unparsed) + "\n")
187+
188+
# Emit YAML by hand (deterministic ordering, faithful quoting of PCREs).
189+
out = []
190+
out.append("# DSGAI scanner detection rules — source of truth.")
191+
out.append("# Generated once from dsgai_scanner_tool.md Step 2 by build/generate_rules.py,")
192+
out.append("# then hand-maintained. Validated by rules/rules.schema.json (see rules/README.md).")
193+
out.append(f"ruleset_version: '{RULESET_VERSION}'")
194+
out.append(f"framework: '{FRAMEWORK}'")
195+
out.append("rules:")
196+
for r in rules:
197+
out.append(f" - id: {r['id']}")
198+
out.append(f" control: {r['control']}")
199+
out.append(f" name: {r['name']}")
200+
out.append(f" classification: {r['classification']}")
201+
out.append(f" signal: {r['signal']}")
202+
out.append(f" confidence: {r['confidence']}")
203+
out.append(f" pcre: {yaml_scalar(r['pcre'])}")
204+
out.append(f" file_globs: {emit_list(r['file_globs'])}")
205+
out.append(f" exclude_globs: {emit_list(r['exclude_globs'])}")
206+
out.append(f" framework: '{r['framework']}'")
207+
out.append(f" description: {yaml_scalar(r['description'])}")
208+
if r["subtract"]:
209+
out.append(f" subtract: {emit_list(r['subtract'])}")
210+
if r["requires_nearby"]:
211+
rn = r["requires_nearby"]
212+
parts = []
213+
if "rule" in rn:
214+
parts.append(f"rule: {rn['rule']}")
215+
if "rules" in rn:
216+
parts.append("rules: " + emit_list(rn["rules"]))
217+
if "lines" in rn:
218+
parts.append(f"lines: {rn['lines']}")
219+
if "scope" in rn:
220+
parts.append(f"scope: {rn['scope']}")
221+
if rn.get("absent"):
222+
parts.append("absent: true")
223+
out.append(" requires_nearby: {" + ", ".join(parts) + "}")
224+
if r["gated_on"]:
225+
out.append(f" gated_on: {r['gated_on']}")
226+
if r["notes"]:
227+
out.append(f" notes: {yaml_scalar(r['notes'])}")
228+
sys.stdout.write("\n".join(out) + "\n")
229+
sys.stderr.write(f"\nExtracted {len(rules)} rules.\n")
230+
231+
232+
if __name__ == "__main__":
233+
main()

‎dsgai_scanner_tool/dsgai_scanner_tool.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,8 @@ Also identify:
564564

565565
## Step 2: Scan for DSGAI Issues
566566

567+
> **Canonical rule definitions live in [`rules/dsgai-rules.yaml`](rules/dsgai-rules.yaml)** (validated by `rules/rules.schema.json`, compiled to `rules/dsgai-rules.json`). The pattern listings in this Step are descriptive — the YAML is authoritative and is what the deterministic CLI executes. When they disagree, the YAML wins. (Full skill rewrite to CLI-first orchestration is PR-07.)
568+
567569
### Search Engine Prerequisite
568570

569571
All patterns below use **PCRE / Perl-compatible regex syntax** — `\s`, `{n,m}`, character classes inside groups, alternation. Inside Claude Code, the Grep tool (ripgrep) supports this natively. Outside Claude Code, use `rg` (ripgrep) or `grep -P` (GNU grep with PCRE). Plain POSIX BRE/ERE will *not* match `\s`, `\d`, or `{n,m}` correctly and will produce false negatives.

‎dsgai_scanner_tool/rules/README.md‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# DSGAI scanner rules
2+
3+
`dsgai-rules.yaml` is the **source of truth** for every detection pattern. It is
4+
validated by `rules.schema.json` and compiled to `dsgai-rules.json` (which the
5+
deterministic CLI loads with the standard library only — no PyYAML at runtime).
6+
7+
- **Edit** `dsgai-rules.yaml`, then rebuild the JSON:
8+
`python build/build_rules_json.py`
9+
- **Never edit** `dsgai-rules.json` by hand — it is generated. CI checks it is
10+
in sync (`python build/build_rules_json.py --check`).
11+
12+
## Rule schema
13+
14+
```yaml
15+
- id: P02.1 # P<control>.<n>, unique
16+
control: DSGAI02 # DSGAI01 .. DSGAI21
17+
name: hardcoded-openai-api-key # kebab-case slug
18+
classification: value_bearing # structural | value_bearing
19+
signal: fail # fail | warn | pass_signal | count | info
20+
confidence: high # high | medium | low
21+
pcre: '(?i)(OPENAI_API_KEY|...)\s*[:=]\s*["'']?sk-[A-Za-z0-9_\-]{20,}'
22+
file_globs: ['*.py', '*.env*'] # which files the rule runs against
23+
exclude_globs: [] # paths excluded from this rule
24+
framework: dsgai-2026-v1.0 # framework version binding
25+
description: 'Hardcoded OpenAI API key assignment'
26+
```
27+
28+
### Field reference
29+
30+
| Field | Required | Meaning |
31+
|---|---|---|
32+
| `id` | yes | `P<NN>.<n>`, matches the control number |
33+
| `control` | yes | `DSGAI01`–`DSGAI21` |
34+
| `name` | yes | kebab-case identifier |
35+
| `classification` | yes | `structural` (match may be shown) or `value_bearing` (match content is a secret/PII — never shown; located in `--replace ''` mode) |
36+
| `signal` | yes | how a hit is weighted: `fail`, `warn`, `pass_signal`, `count`, `info` |
37+
| `confidence` | yes | `high` / `medium` / `low` — feeds SARIF `level` and report rendering |
38+
| `pcre` | yes | PCRE2 pattern; must compile under `rg --pcre2` (checked in CI, not by the schema) |
39+
| `file_globs` | yes | globs the rule scans |
40+
| `exclude_globs` | no | globs excluded from the rule (e.g. migrations/tests) |
41+
| `framework` | yes | `dsgai-YYYY-vX.Y` binding |
42+
| `description` | yes | human-readable summary |
43+
| `remediation` | no | fix guidance |
44+
| `references` | no | CVE IDs / links |
45+
| `subtract` | no | rule IDs whose match on the same line cancels this hit (e.g. `torch.load(` minus `weights_only=True`) |
46+
| `requires_nearby` | no | compound proximity logic: `{rule\|rules, lines\|scope, absent}` |
47+
| `gated_on` | no | only evaluated when the stack is detected: `multimodal`, `synthetic_data`, `labeling` |
48+
| `notes` | no | control-level prose that isn't yet fully formalized |
49+
50+
## Classification: STRUCTURAL vs VALUE-BEARING
51+
52+
See [`../CONTRIBUTING.md`](../CONTRIBUTING.md#structural-vs-value-bearing). In
53+
short: if a rule can match a line whose *content is* a secret or PII, it is
54+
`value_bearing` and runs in location-only mode so the value never leaves
55+
ripgrep. All rules under DSGAI02/13/14/15 are value-bearing.
56+
57+
## Provenance
58+
59+
The initial ruleset was extracted verbatim from `dsgai_scanner_tool.md` Step 2 by
60+
`build/generate_rules.py` (a one-time bootstrap, kept for audit). Pattern *fixes*
61+
land as reviewable diffs against this baseline starting in PR-11.

0 commit comments

Comments
 (0)