What this is. The build plan that turns the
0.1.0scaffold into a working, tested, documented0.2.0taint-tracking SAST CLI. It is the source of truth for the build; per-task detail lives in the task tables (§6) and the principles in.claude/rules/principles.mdwin on any conflict. Derived from a parallel design pass (1 prior-art researcher + 8 subsystem architects).
Goal: scanipy scan <path> performs real taint analysis on Python code, follows
untrusted data from sources to sinks (through sanitizers), and reports each finding
with its source → … → sink witness — deterministically, locally, zero-config.
Locked decisions (v1):
| # | Decision |
|---|---|
| Engine depth | Intraprocedural + intra-file interprocedural via TITO function summaries. No cross-file/project-wide analysis. |
| Detector catalog | Core 6: os-command (CWE-78), sql (CWE-89), code-injection (CWE-94), path-traversal (CWE-22), ssrf (CWE-918), unsafe-deserialization (CWE-502). + up to 2 stretch: xxe (CWE-611), tls-verify-disabled (CWE-295). |
| Merge flow | Dependency-ordered PRs into protected main, auto-merged once CI is green (one PR per component). |
| Definition of done | Working scan, full tests incl. per-detector TP/TN, docs/README/CHANGELOG updated, __version__ → 0.2.0, CI green on 3.10–3.13. No PyPI publish. |
Out of scope for v1 (state honestly — P7): cross-file/whole-program taint; languages other than Python; implicit/control-dependence flows; full alias soundness (taint is tracked per access-path, not per heap object); PyPI publication.
A single, linear, detector-agnostic pipeline. Detection knowledge lives entirely in the YAML DSL specs (P4); the engine only matches patterns and moves taint labels.
discovery → frontend(AST→IR) → [per file] taint engine ─uses→ matcher(DSL Pattern ↔ IR)
│ ↑
│ registry → parse_spec → DetectorSpec pack
▼
findings (witness-backed) → aggregate/filter/sort/dedup
▼
reporter (text/json/sarif) → stdout/-o → exit code
Load-bearing design choices (grounded in PyT / Semgrep taint-mode / Pysa TITO; see
docs/design/ archive):
- Import/alias canonicalization is a first-class, pre-matching step. Every
Name/Attributeis resolved to a canonical dotted path via a per-module import table soimport os; os.system,from os import system; system,import os as o; o.system, andimport os.path as p; p.joinall match the dotted DSL patterns. Skipping this causes silent false negatives. - Taint env keyed by access paths (base var + bounded
.attr/const-subscript suffix, depth cap 2–3). At the cap, over-approximate (collapse to the prefix, may taint siblings) — biases to false positives, never false negatives (P5-safe). - Flow-sensitive forward dataflow over a per-function CFG. Transfer per statement:
sources add labels; assignments kill then reassign the LHS path; propagators
move taint per
from→to; sanitizers remove the label on the path where they definitely run. Joins union, never intersect (sanitized in one branch only ⇒ still tainted) — the load-bearing P5 rule. Loops iterate a bounded fixpoint. - Intra-file interprocedural via TITO function summaries, not inlining. Build the
in-file call graph, condense SCCs, compute summaries in reverse-topological order;
recursion via a bounded worklist fixpoint. A summary is a sorted set of transfer
flows (
param_i→return,param_i→sink S,source→return, …), each carrying a compact sub-trace for witness splicing at call sites. Formal params are handled as engine-internal symbolic taint — no DSL change required for interprocedural. - Witness-backed findings (P2): every finding carries the ordered
WitnessStepchain; interprocedural hits splice the callee fragment in. - Determinism (P3): total order on findings — primary
(file, line, col, detector_id), final tie-break on a witness fingerprint (sha256 of the ordered(role, file, line, col)tuples). Witness selection: shortest path, then lexicographically smallest locations. All spec/source/sink/worklist iteration sorted; never depend on dict/set/filesystem order.
Documented unsoundness (P7, ship in docs/ir-reference.md + docs/dsl-reference.md):
alias-through-mutation, dynamic subscripts, dynamic/* imports, and implicit flows are
best-effort or out of scope. P5's one-sidedness covers sanitizers, not overall
soundness.
The parallel design produced two proposals each for "the IR" and "the matcher". These are the binding decisions; ignore the per-component file names where they differ.
src/scanipy/
ir.py # NEW — the ONE shared normalized IR (frontend produces; engine + matcher consume).
# Resolves frontends/ir.py vs engine/ir.py: neutral top-level module, no import cycle.
frontends/
base.py # EDIT — Frontend.parse(path) -> ir.IRModule | None
resolver.py # NEW — import/alias table + canonical_dotted()
python_frontend.py # REWRITE — ast -> ir.IRModule; return None on SyntaxError/decode/OS error
engine/
matcher.py # NEW — the ONE matcher: match(Pattern, node) -> MatchResult|None (consolidates matcher.py/matching.py)
taint_state.py # NEW — access-path taint lattice
propagation.py # NEW — generic built-in + DSL propagators
witness.py # NEW — witness build / select / fingerprint
summaries.py # NEW — TITO summaries, fixpoint, call-site application + splicing
taint.py # IMPLEMENT — TaintEngine.analyze orchestrates the above
dsl/parser.py # IMPLEMENT — parse_spec + DSLError (location-aware)
registry.py # IMPLEMENT — load_builtin_detectors()
config.py # IMPLEMENT — layered config (.scanipy.yml + [tool.scanipy])
discovery.py # NEW — file walk + excludes + .gitignore
scanner.py # NEW — orchestrator (discover→parse→analyze→aggregate→report→exit)
cli.py # REWRITE scan + rules (thin; delegates to scanner)
reporting/ # EXISTS — enforce SARIF sort_keys determinism
detectors/<class>/<name>.yml # catalog (data only, P4)
docs/ ir-reference.md(NEW) testing.md(NEW) examples/end-to-end.md(NEW)
dsl-reference.md/usage.md/writing-detectors.md (UPDATE)
tests/ unit/ integration/ fixtures/ _support/
| WP | Component | Owns | Depends on |
|---|---|---|---|
| A | DSL parser & registry | dsl/parser.py, registry.load_builtin_detectors |
(scaffold types) |
| B | Python frontend & IR | ir.py, frontends/{resolver,python_frontend,base}.py |
(scaffold types) |
| C | Pattern matcher | engine/matcher.py |
B (ir) |
| D | Taint engine | engine/{taint,taint_state,propagation,witness,summaries}.py |
A, B, C |
| E | Detector catalog | detectors/**/*.yml, tests/fixtures/** |
A, B, D |
| F | CLI / scan pipeline | scanner.py, discovery.py, config.py, cli.py, reporting/* |
A, B, D |
| G | Testing & QA (cross-cutting) | tests/integration/**, tests/_support/**, coverage gate |
D, E, F |
| H | Docs / changelog / version | README.md, docs/**, CHANGELOG.md, __version__ |
all |
Phase 0 baseline: merge scaffold PR #1 → main
Phase 1 ║ A DSL parser ║ B frontend+IR ║ (independent, parallel)
Phase 2 C matcher (needs B)
Phase 3 D taint engine (needs A,B,C)
Phase 4 ║ E detector catalog ║ F CLI pipeline ║ (need A,B,D; disjoint files, parallel)
Phase 5 G testing & QA (cross-cutting) (needs D,E,F)
Phase 6 H docs / version 0.2.0 / changelog (needs all)
Rules: a phase's PRs merge into main (auto, once CI green) before the next phase
branches off; parallel PRs within a phase must touch disjoint files; each PR is
self-contained and CI-green on its own.
Sizes: S ≈ <100 LOC, M ≈ 100–300, L ≈ 300+. ⇐ = depends-on.
- S
DSL_PARSER_1DSLError carries spec id + field + source location - M
DSL_PARSER_2YAML node-tree loader w/ location tracking ⇐ 1 - M
DSL_PARSER_3Top-level field validation (required/optional/unknown/enums) ⇐ 2 - L
DSL_PARSER_4Pattern parsing + dotted/wildcard grammar + args + when ⇐ 3 - M
DSL_PARSER_5Implementparameter&importkinds (lift from PLANNED) ⇐ 4 - M
DSL_PARSER_6Propagator parsing + flow vocabulary ⇐ 4 - M
DSL_PARSER_7Assemble + return DetectorSpec; finalize parse_spec ⇐ 3,4,5,6 - S
DSL_PARSER_8Wireregistry.load_builtin_detectors⇐ 7 - S
DSL_PARSER_9Validate bundled specs parse + reconcile DSL surface ⇐ 8 - L
DSL_PARSER_10Tests: happy-path + every rejection + locations ⇐ 7 - M
DSL_PARSER_11Tests: registry loader + bundled-pack invariants ⇐ 8 - S
DSL_PARSER_12Docs: dsl-reference + CHANGELOG for parser ⇐ 5,7
- M
FRONTEND_IR_1Define IR dataclasses (ir.py) - M
FRONTEND_IR_2Import/alias resolution (resolver.py) ⇐ 1 - L
FRONTEND_IR_3Expression lowering (ast.expr → Expr) ⇐ 1,2 - M
FRONTEND_IR_4Binder/target lowering (full inventory) ⇐ 1,3 - L
FRONTEND_IR_5Statement lowering + minimal CFG builder ⇐ 1,3,4 - M
FRONTEND_IR_6Scope/function table + module-as-scope ⇐ 2,4,5 - S
FRONTEND_IR_7PythonFrontend.parsewiring + graceful errors ⇐ 6 - S
FRONTEND_IR_8IR contract docs (docs/ir-reference.md) ⇐ 7 - M
FRONTEND_IR_9Frontend/IR unit tests ⇐ 7
- S
MATCHER_1ResolvedNode / KeywordValue protocols (consumeir.py) - M
MATCHER_2Segment-wise wildcard matcher_match_dotted - S
MATCHER_3_resolve_arg_indices(positional, receiver-excluded) - M
MATCHER_4_match_when(keyword literal-equality, AND, sorted) ⇐ 1 - M
MATCHER_5Publicmatch()+MatchResult⇐ 1,2,3,4 - S
MATCHER_6Export matcher API fromengine/__init__.py⇐ 5 - M
MATCHER_7Tests with fake ResolvedNodes ⇐ 5 - S
MATCHER_8Pin wildcard + constraint semantics in dsl-reference ⇐ 5 - S
MATCHER_9Confirm parser validates args/when shape & placement ⇐ 2,4
- M
ENGINE_1IR-consumption contract assertions (usesir.py) - M
ENGINE_2Pattern matching glue (usesmatcher.py) ⇐ 1 - L
ENGINE_3Taint state lattice (taint_state.py) ⇐ 1 - M
ENGINE_4Witness construction, selection, fingerprints (witness.py) ⇐ 1 - L
ENGINE_5Generic built-in propagation (propagation.py) ⇐ 2,3,4 - L
ENGINE_6Intraprocedural CFG dataflow + seeding + sink emission ⇐ 5 - L
ENGINE_7Function summaries to fixpoint over call graph (summaries.py) ⇐ 6 - M
ENGINE_8Summary application + witness splicing at call sites ⇐ 7 - M
ENGINE_9WireTaintEngine.analyze: phases, dedup, sort, fingerprints ⇐ 6,8 - M
ENGINE_10Unit tests: matching ⇐ 2 - L
ENGINE_11Unit tests: intraprocedural TP/TN (hand-built IR) ⇐ 6 - L
ENGINE_12Unit tests: interprocedural summaries + splicing + recursion ⇐ 7,8 - M
ENGINE_13Unit tests: determinism + fingerprints ⇐ 9 - M
ENGINE_14End-to-end integration over real fixtures ⇐ 9 - M
ENGINE_15Docs: engine + DSL semantics + honest scope ⇐ 9
- S
DETECTOR_1Validate existing os-command & sql specs vs finished schema ⇐ A - S
DETECTOR_2Author sql TP/TN fixtures (missing today) - M
DETECTOR_3code-injection spec (CWE-94, critical) + TP/TN - M
DETECTOR_4path-traversal spec (CWE-22, high) + TP/TN - M
DETECTOR_5ssrf spec (CWE-918, high) + TP/TN - M
DETECTOR_6unsafe-deserialization spec (CWE-502, critical) + TP/TN - M
DETECTOR_7xxe stretch spec (CWE-611, high) + TP/TN - M
DETECTOR_8tls-verify-disabled stretch (CWE-295) — gated on engine presence-sink; else defer - M
DETECTOR_9Interprocedural TP/TN fixtures (exercise summaries) - L
DETECTOR_10Per-detector TP/TN integration matrix ⇐ 1–7,9 + A,B,D - S
DETECTOR_11dsl-reference: v1 known-limitations + forced DSL extensions ⇐ A,D - S
DETECTOR_12Wire catalog into rules list/show, scan; CHANGELOG ⇐ 10,11
- M
CLI_1Config loader:.scanipy.yml+[tool.scanipy]discovery & validation - S
CLI_2Config merge: CLI > file > defaults (click param-source) ⇐ 1 - M
CLI_3File discovery: default + glob excludes, deterministic order - M
CLI_4.gitignorehonoring (stdlib-only, default-on,--no-gitignore) ⇐ 3 - S
CLI_5Registry:load_builtin_detectors+load_detector_specs(selected)⇐ A - M
CLI_6Orchestratorscanner.run_scan+ per-file isolation ⇐ 3,5,B,D - M
CLI_7Aggregation: severity filter, deterministic dedup, total-order sort ⇐ 6 - S
CLI_8Exit-code computation ⇐ 7 - M
CLI_9Wirescancommand (thin) ⇐ 2,8 - M
CLI_10Implementrules list/show/validate⇐ 5 - L
CLI_11Tests: config, discovery, scanner, exit codes, CLI, e2e ⇐ 9,10 - M
CLI_12Docs + CHANGELOG ⇐ 11
- S
QA_1Test-support: fixture pairing index ⇐ A - S
QA_2Output normalizers (version + path tolerant) - M
QA_3Extend conftest: corpus + parametrize hook ⇐ 1,F - M
QA_4DSL parser unit tests (pos/neg/purity) ⇐ A · LQA_5Frontend/IR tests + resilience ⇐ B - M
QA_6Matcher unit tests ⇐ C · LQA_7Engine transfer-function tests ⇐ D - L
QA_8Interprocedural summary tests ⇐ D · SQA_9Config tests ⇐ F - M
QA_10Scanner orchestration tests ⇐ F · SQA_11Reporter determinism / SARIF sort_keys ⇐ F - S
QA_12Registry parse-all + self-validation ⇐ A - M
QA_13End-to-end exact-findings integration ⇐ D,F,A - M
QA_14P5 catalog enforcement matrix (auto-parametrized from fixtures) ⇐ 3,E - S
QA_15Determinism integration (P3) ⇐ 2,F · MQA_16Golden json+sarif snapshots ⇐ 2,F,E - M
QA_17Unparsable/binary-file resilience ⇐ F,5 · SQA_18Performance smoke ⇐ F,D - M
QA_19CLI scan/rules integration (CliRunner) ⇐ F · SQA_20Migrate stub-asserting CLI tests ⇐ F,19 - S
QA_21Coverage gate (--cov-fail-under=90) + CI wiring ⇐ 13,14 - S
QA_22docs/testing.md⇐ 16,21
- S
DOCS_1Bump__version__→ 0.2.0 - M
DOCS_2De-stub README ⇐ E,F · MDOCS_3De-stub docs/usage.md ⇐ F,7 - M
DOCS_4Finalize/lock dsl-reference (promote parameter/import) ⇐ A,D - M
DOCS_5Refresh writing-detectors.md ⇐ 4,E - M
DOCS_6CHANGELOG 0.2.0 section ⇐ 1,E - M
DOCS_7Verified end-to-end example (docs/examples/end-to-end.md) ⇐ F,D,1 - S
DOCS_8Release-readiness checklist (NO publish) ⇐ 1,6 - S
DOCS_TEST_1Version+changelog consistency test ⇐ 1,2,3,6 - M
DOCS_TEST_2End-to-end example matches real CLI output ⇐ 7 - M
DOCS_TEST_3Docs reflect real catalog (bijection) ⇐ 4,6,E,A
- One PR per work package, branch
feat/<wp>(ortest/,docs/), intomain. - Each PR: SPDX headers, conventional-commit title, CI-green standing alone (ruff,
ruff format, mypy --strict, pytest on 3.10–3.13), and the
protect-mainruleset's required checks satisfied → auto-merged by the orchestrator. - Dependency-ordered: a phase's PRs merge before the next phase branches off
main. - Parallel PRs within a phase touch disjoint files; the orchestrator sequences merges and rebases on the rare conflict.
- Commits within a PR map to tasks (e.g.
feat(engine): intraprocedural CFG dataflow (ENGINE_6)).
- A green:
parse_specvalidates all forms with location-awareDSLError; bundled specs parse;load_builtin_detectorsreturns sorted, unique, ≥1 source/≥1 sink each. - B green: four import styles canonicalize identically; value-rooted chains
(
conn.cursor.execute) preserved; full binder inventory; parse returnsNone(never raises) on bad files; CFG with union-joins emitted; zero detector vocabulary infrontends/. - C green: pure matcher; exact/trailing-single/leading-greedy wildcard semantics;
args/whenhonored; never widens on unknowns. - D green:
TaintEngine.analyzereturns findings; zero per-CWE branching (grep-verified); os-command TP flagged / TN silent; SQL bound-params not flagged; one-sided sanitizers (union-at-join); interprocedural splice works; recursion terminates; byte-identical across runs and spec-order shuffles; stable fingerprints. - E green: 6 core (+shipped stretch) specs parse and use only the frozen DSL; every detector has TP+TN fixtures; per-detector matrix passes; tls-verify shipped-or-deferred honestly.
- F green:
scanipy scan <vuln>exits 1 with witness;<safe>exits 0; zero-config works; no network; deterministic stdout for text/json/sarif; excludes + gitignore; config precedence CLI>file>defaults;rules list/show/validatework; thin cli.py. - G green: P5 matrix auto-parametrized from fixtures; determinism (byte-identical json+sarif); golden snapshots; unparsable-file resilience; perf smoke bounded; hermetic (no network/subprocess); coverage ≥ 90%.
- H green:
__version__ == 0.2.0; README/usage de-stubbed and honest that it's install-from-source (not on PyPI); dsl-reference locked; CHANGELOG 0.2.0; verified end-to-end example; docs↔catalog bijection enforced by test.
Global DoD: all eight PRs merged to main; CI green on 3.10–3.13; the working
scanipy scan demonstrated on the fixture corpus; no PyPI publish (release-readiness
checklist ends with an explicit STOP).
| Risk | Mitigation |
|---|---|
| Import/alias resolution missed ⇒ silent FNs | First-class canonicalization step (WP-B); integration test over all 4 import styles. |
| Determinism regressions (collisions, set order) | Total order + witness-fingerprint tie-break; sorted iteration everywhere; scan-twice byte-identical test (WP-G). |
| Join semantics wrong (intersection) ⇒ missed vulns | Union-at-join is an explicit acceptance test (sanitized-in-one-branch still flagged). |
| Recursion / large SCC blowup | Bounded fixpoint iteration cap + access-path depth cap + summary memoization; perf smoke. |
| DSL must grow (kwarg args, by-side-effect flow) | Centralized grammar constants; extend DSL not engine (P4); record in dsl-reference. |
| IR/matcher file-name divergence between agents | §3 resolves it — ir.py + engine/matcher.py are binding. |
| tls-verify needs a non-taint "presence sink" | Treat as stretch; ship only if engine adds presence-sink, else defer honestly. |
| Over-tainting from depth-cap collapse ⇒ FPs | Accepted, P5-safe; documented; tune caps if noisy. |
Executed as one Workflow per phase; I drive merges from the main loop.
- Phase 0: merge scaffold PR #1 →
main(CI-green baseline). - Per phase: a Workflow fans out one implementation agent per WP in that phase,
each in an isolated git worktree (
isolation: worktree) on itsfeat/<wp>branch. Each agent: reads this PLAN + its task table + the design archive, implements every task, runs the local gate (ruff/ruff format --check/mypy src/pytest), and commits. A second workflow stage runs an adversarial reviewer per WP (principles + acceptance criteria) before the PR is opened. - I push each branch, open its PR, wait for required CI checks to go green, and
auto-merge (admin) in dependency order; then the next phase branches off the
updated
main. - After Phase 6: final full-suite verification on
main, then STOP (no publish) and report.docs/release-readiness.mdis the human checklist for an eventual release.
Estimated fan-out: ~8 implementation agents (+ reviewers) across phases 1–6, plus the already-spent 9 design agents. Large components (engine, testing) may split into two stacked PRs if a single PR would be unwieldy.
- WP-A…H merged to
mainvia auto-merged, CI-green PRs. -
scanipy scanworks end-to-end; 6 core detectors flag TP / clear TN (P5). - Intra-file interprocedural taint with spliced witnesses; deterministic output (P3).
- Engine has zero per-CWE logic (P4, grep-verified); no network on scan path (P1).
- Tests green on 3.10–3.13; coverage ≥ 90%; golden snapshots; P5 matrix.
- README/docs de-stubbed and honest (P7);
__version__ = 0.2.0; CHANGELOG updated. - Release-readiness checklist complete — no publish performed.