|
| 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() |
0 commit comments