2026-09-04 13:57:33 +02:00
# Design Document
## Overview
**Purpose.** This feature makes the three-stage scan *mean* three jobs: pattern inventory **proposes** sinks; a flow overlay **adjudicates** those proposals on real files; the sandbox **proves** only what was promoted. Operators scanning a working tree they are writing get honest `unadjudicated` instead of fake cleanliness.
**Users.** Developers get `quick` as cheap inventory on WIP code. AppSec gets `standard` dispositions (promote / demote / unadjudicated / coverage). DevOps gets `deep` budget spent on promoted reachable sinks.
**Impact.** Today `quick_scan_findings` is the de facto decision. After this spec, inventory is necessary but not sufficient. MAP writes overlay artifacts. REGRESS candidate selection reads promotions, not demotions.
### Goals
2026-10-12 18:16:41 +02:00
- Map every current scan module onto propose, adjudicate, budget, or prove (rank/embed is not a verdict).
2026-09-04 13:57:33 +02:00
- Treat messy unlabeled trees as the default; labeled corpora as calibration.
- Fail closed: cannot parse or cannot follow flow → `unadjudicated` , not demote.
2026-10-12 18:16:41 +02:00
- Prioritize the semantic engine now: tree-sitter IR for in-scope languages; Joern optional CPG sidecar; stdlib Python parse only as last resort.
2026-09-04 13:57:33 +02:00
- Tunable embeddings + existing ranker order **prove** candidates.
- SAST pair slice scores the overlay; smoke gate stays inventory-only.
### Non-Goals
- Replacing pattern rules or `LANGUAGE_MANIFESTS` .
- LLM-as-adjudicator or LLM-as-parser.
- Requiring Joern or an embedding API for `quick` .
- Using retrieval similarity to drop promotions.
- Auto-authoring source/sink/sanitizer facts from `ousast improve` .
- Buffer-size / memory-safety semantics (Juliet CWE-121 goodG2B stays a known leak until a later spec).
## Boundary Commitments
### This Spec Owns
- Overlay dispositions and flow-fact records.
- Source / sink / sanitizer **facts as data** (not CWE severity).
- Tree-sitter probe/runner (CLI-first, like Docker) and last-resort Python stdlib parse.
- Optional Joern probe/runner (CLI sidecar).
- Intra-file taint on the tree-sitter IR.
- Prove-budget: heuristic rank + OpenRouter embeddings over the mechanism log.
- Calibration scoring of overlay on the sast pair slice; unlabeled-scan honesty.
### Out of Boundary
- Pattern text, rule `enabled|shadow|disabled` , CWE TSV, project score formula (`three-stage-scan` / ruleset / policy).
- Complexity-map identity keys, Docker sandbox flags, hunter suspicion contract (`three-stage-scan` ).
- Pair catalog schema except scoring **which** findings count (overlay vs inventory).
- External analyzer execution (SARIF ingest remains a proposal input).
- MCP exposing `deep` .
### Allowed Dependencies
- Preprocess, mapping, rank (`heuristic_rank` 0.5/0.2/0.3), `quick_scan_findings` , complexity map, `ousast index` / json-local vectorstore, tool hunter, sandbox runner, pair eval, reports.
- Host binaries when present: `tree-sitter` , `joern` / `joern-parse` (probed, never required for inventory).
- Optional embedding model from existing `[embeddings]` config.
2026-10-12 18:16:41 +02:00
- Clearwing as an **oracle** for tree-sitter callgraph + taint-pattern tables + mechanism retrieval, not a dependency.
2026-09-04 13:57:33 +02:00
### Revalidation Triggers
2026-10-12 18:16:41 +02:00
- Adding a fourth disposition that implies "safe."
2026-09-04 13:57:33 +02:00
- Demoting when flow is incomplete.
- Stamping overlay above `static_corroboration` .
- Selecting sandbox candidates from demoted or unadjudicated sets.
- Demoting because retrieval score is low.
- Folding overlay Youden into `python -m openultrasast.gate` .
- Making tree-sitter or Joern a hard dependency of `quick` .
## Architecture
### Existing Architecture Analysis
Concrete mapping of **what exists today** onto the three roles:
| Role | Stage / mode | Module today | What it actually does | After this spec |
|------|----------------|--------------|------------------------|-----------------|
| Propose | STATIC / `quick` | `findings.quick_scan_findings` | Pattern rules decide the report | **Proposal list only** |
| Propose | STATIC | `mapping.ingest_sarif` | Operator SARIF | Still proposals |
| Propose | STATIC | `mapping.analyze_entry_points` | Routes / CLI | **Sources** for overlay |
| Budget | STATIC | `rank.heuristic_rank` 0.5/0.2/0.3 | File budget | Same formula; later **prove order** |
| Budget | `ousast index` | `vectorstore` json-local | Hunter retrieval | **Prove retrieval** on promotions |
| Adjudicate? | MAP | `complexity.map` | Hotspot band | Attention only |
| Adjudicate | MAP | **new** tree-sitter IR + taint | — | **Primary semantic engine** |
| Adjudicate | MAP | **new** Joern CLI sidecar | — | Optional CPG facts; never required |
| Adjudicate fallback | MAP | stdlib `ast` | Routes only today | Python last resort if tree-sitter missing |
| Suspicion | MAP | `tool_hunter` | LLM + grep | May query overlay; never adjudicate |
| Prove | REGRESS / `deep` | `regress` + sandbox | Recipes on ranked findings | Recipes on **promoted then budgeted** findings |
| Calibrate | CI | `gate.py` / `pairs` | Cheat-sheet vs Youden | Inventory vs overlay, unchanged split |
2026-10-12 18:16:41 +02:00
**Selected pattern.** Propose → adjudicate → **budget** → prove. Clearwing's tree-sitter callgraph + taint tables + mechanism memory are the oracle, not a library import. Embeddings never demote.
2026-09-04 13:57:33 +02:00
```mermaid
flowchart TB
subgraph propose["PROPOSE inventory"]
PRE[preprocess]
EP[entry points as sources]
RX[pattern inventory]
SARIF[optional SARIF]
end
subgraph adjudicate["ADJUDICATE overlay"]
TS[tree-sitter IR]
JOERN[optional Joern CPG]
FALLBACK[Python stdlib parse last resort]
FACTS[source sink sanitizer facts]
TAINT[intra-file taint]
DISP[promote demote unadjudicated coverage]
end
subgraph budget["BUDGET not a verdict"]
RANK[heuristic and optional model ranker]
VS[json-local embeddings]
end
subgraph prove["PROVE sandbox"]
CAND[promoted reachable then budgeted]
BOX[existing Docker runner]
VERD[triggerable or not]
end
PRE --> EP
PRE --> RX
RX --> DISP
SARIF --> DISP
EP --> TAINT
TS --> TAINT
JOERN --> TAINT
FALLBACK --> TAINT
FACTS --> TAINT
TAINT --> DISP
DISP --> RANK
DISP --> VS
RANK --> CAND
VS --> CAND
CAND --> BOX
BOX --> VERD
```
### Technology Stack
| Layer | Choice | Role |
|-------|--------|------|
| CLI / modes | existing `quick` `standard` `deep` | Role gating |
| Propose | pattern rules + SARIF | Inventory |
| Semantic IR | `tree-sitter` CLI or API extra; probe like Docker | Primary adjudicator parse |
| Semantic CPG | Joern CLI when present | Optional inter-file facts |
| Fallback parse | stdlib `ast` | Python only, last resort |
| Facts | TOML next to the ruleset | Sources sinks sanitizers |
| Budget | existing ranker + json-local vectorstore | Prove order, tunable |
| Prove | existing Docker CLI sandbox | Unchanged runner |
`quick` stays zero-dep. `standard` /`deep` degrade to unadjudicated when parsers are missing.
## File Structure Plan
```
src/openultrasast/semantic/
__init__.py
facts.py # source sink sanitizer facts
engines.py # probe tree-sitter, joern, ast fallback
ir.py # FileIR from the first engine that works
taint.py # worklist; incomplete -> Unadjudicated
overlay.py # dispositions
prove_budget.py # rank + optional retrieval over promotions
src/openultrasast/ruleset/semantic/
python.toml javascript.toml c.toml java.toml
```
### Modified Files
2026-10-12 18:16:41 +02:00
- `stages.py` / `cli.py` , overlay in MAP; prove-budget before regress.
- `findings.py` , inventory unchanged.
- `regress/candidate.py` , `promote` only, then budget order.
- `rank.py` / `index.py` / `vectorstore.py` , reuse; do not invent a second store.
- `reports.py` , disposition columns.
- `pairs.py` , sast slice uses overlay.
- `mcp.py` , still no `deep` .
2026-09-04 13:57:33 +02:00
## System Flows
```mermaid
stateDiagram-v2
[*] --> Proposed
Proposed --> Unadjudicated: parse fail or flow incomplete or language unsupported
Proposed --> Promoted: source reaches sink without sanitizer
Proposed --> Demoted: constant or sanitizer dominates
Proposed --> Coverage: flow to uninventoried sink
Promoted --> Proven: sandbox triggerable
Promoted --> Unproven: sandbox skip or unavailable
Unadjudicated --> [*]
Demoted --> [*]
Coverage --> Promoted: same flow rule
```
**Key decisions**
- Demote only with a positive reason (constant or sanitizer on that path).
- Unadjudicated is the default for messy real files.
- Coverage findings enter the same state machine as proposals.
### Unlabeled working-tree flow
No expected.toml. Success is: every inventory item has a disposition; unadjudicated reasons are explicit; `deep` does not execute demoted sinks; `--fail-on worth-fixing` ignores unadjudicated.
## Requirements Traceability
| Requirement | Summary | Components | Flows |
|-------------|---------|------------|-------|
| 1.1– 1.5 | Role split by mode | Stage plan, overlay, prove budget, regress | propose → adjudicate → budget → prove |
| 8.1– 8.6 | Semantic engines | engines.py, ir.py, taint.py | tree-sitter then Joern then ast |
| 9.1– 9.5 | Prove budget | prove_budget.py, rank, vectorstore | retrieval never demotes |
| 2.1– 2.5 | Messy trees, unadjudicated | IR fail, taint incomplete | unlabeled scan |
| 3.1– 3.5 | Promote demote coverage | taint, overlay | state diagram |
| 4.1– 4.5 | Calibration vs product | gate.py, pairs.py | sast overlay score |
| 5.1– 5.5 | Evidence honesty | overlay evidence, hunter | ladder |
| 6.1– 6.5 | Prove promoted only | candidate filter, sandbox | deep |
| 7.1– 7.5 | Artifacts and fail-on | reports, overlay.json | operator |
## Components and Interfaces
| Component | Domain | Intent | Req Coverage | Dependencies | Contracts |
|-----------|--------|--------|--------------|--------------|-----------|
| InventoryProposer | Propose | Existing pattern + SARIF list | 1.1 | findings, mapping | State |
| FactStore | Adjudicate | Sources sinks sanitizers as data | 3, 5.5 | ruleset/semantic | State |
| FileIR | Adjudicate | Parse one file or fail | 2.1 | ast | Service |
| TaintEngine | Adjudicate | Intra-file paths or incomplete | 2.2, 3 | FileIR, FactStore | Service |
| Overlay | Adjudicate | Dispositions on proposals | 1.2, 3, 5 | TaintEngine | State |
| ProveFilter | Prove | Candidates from promote | 1.3, 6 | Overlay, regress | Service |
| OverlayPairEval | Calibrate | SAST slice uses dispositions | 4 | Overlay, pairs | Batch |
### Adjudicate layer
#### OverlayRecord
| Field | Detail |
|-------|--------|
| Intent | One disposition per proposal or coverage item |
| Requirements | 3.5, 7.1 |
**State contract**
```
proposal_id: str
path: str
line: int | None
disposition: promote | demote | unadjudicated | coverage
reason: str
cwe: str
sources: list[str]
sinks: list[str]
sanitizers: list[str]
evidence_level: static_corroboration | suspicion
```
**Invariants**
- `demote` requires a recorded constant or sanitizer.
- `unadjudicated` requires a reason in {`parse_failed` , `language_unsupported` , `flow_incomplete` , `facts_unavailable` }.
- `promote` and `coverage` do not exceed `static_corroboration` .
#### TaintEngine service
```
analyze_file(path, text, language, facts) -> FileFlow | Unadjudicated
adjudicate(proposals, flows, facts) -> list[OverlayRecord]
```
- Engine order: tree-sitter grammar → Joern file export if probed and useful → Python stdlib parse → unadjudicated.
- Joern may add paths; it must not demote a tree-sitter promote, and a Joern miss must not demote.
- Postconditions: every inventory proposal appears exactly once; extras are `coverage` only.
### Prove layer
#### ProveFilter
Inbound: overlay + reachability from mapping. Outbound: existing `regress` candidates.
- Drop `demote` .
- Do not sandbox `unadjudicated` as a negative proof.
- Keep `promote` that are reachable or inferred-file-surface.
- Order survivors with heuristic rank, then **mechanism recall** : hard filter (language, CWE, tags) then OpenRouter dense cosine on the mechanism text. Low similarity never drops a promotion.
- Write mechanisms only after prove/triggerable. JSONL log is authoritative; json-local `mechanisms` namespace is a rebuildable embedding cache via `OpenRouterEmbeddingClient` .
## Data Models
**Facts (data, versioned with the ruleset)**
- `source` : identifier (e.g. request parameter, header, stdin, argv)
- `sink` : call shape + CWE (aligned to existing rule ids when possible)
- `sanitizer` : call shape that stops taint (parameterized execute, `printf` with literal format, `safe_load` , list-form subprocess)
**FileFlow**
- assignments and call arguments in one function
- one-hop name copies (`bar = param` , `sql = f"...{bar}"` )
- no types, no inter-file, no may-alias heap in v1
**Mechanism (Clearwing reference architecture)**
Identity is the *pattern* , not a file slice:
| Field | Role |
|-------|------|
| id | stable uuid |
| summary | one sentence, no path/line |
| cwe, language | filters |
| tags, keywords | hard filters, not a ranker |
| what_made_it_exploitable | why a near-miss became a bug |
| source_finding_id, source_repo | provenance only |
2026-10-12 18:16:41 +02:00
Persistence: append-only JSONL (portable). Recall is **not** Clearwing's TF-IDF ladder.
2026-09-04 13:57:33 +02:00
2026-10-12 18:16:41 +02:00
1. **Filter** , language, CWE, tags (exact overlap). This is a hard prefilter, not a ranker.
2. **Rank** , OpenRouter `/embeddings` (`OpenRouterEmbeddingClient` , existing `[embeddings].model` ) over `summary + what_made_it_exploitable + tags` . Cosine against the promotion's overlay text. Cache vectors in json-local `mechanisms` namespace, keyed by mechanism id + model id.
3. **Degrade** , no key / no model / embed failure → heuristic ranker only, degradation recorded. Never bag-of-words cosine.
2026-09-04 13:57:33 +02:00
Query for a promotion: embed the overlay rationale (sources, sink, CWE). Result is a **boost to prove order** , never a demote.
Repo-code chunks (`ousast index` 80-line slices) stay for hunter/verifier retrieval. They are **not** the prove-budget store. Textbook fixtures must not fill the mechanism log unless they were actually proven in the sandbox.
## Error Handling
| Condition | Disposition | Operator sees |
|-----------|-------------|---------------|
| Syntax error / parse fail | unadjudicated parse_failed | proposal kept, marked unresolved |
| No tree-sitter grammar and not Python | unadjudicated language_unsupported | same |
| Joern missing | ignore sidecar | standard still overlays via tree-sitter |
| Embedding model missing | heuristic prove order | deep still runs |
| Callee in another file | unadjudicated flow_incomplete | same |
| Fact file missing | unadjudicated facts_unavailable; `quick` still emits inventory | degradation in manifest |
| Sandbox missing | promoted remains unproven | `sandbox_unavailable` |
## Testing Strategy
- Unit: parse fail → unadjudicated; constant `exec("ls")` → demote; `eval(request.args)` → promote; `sql = f"…{x}"; execute(sql)` → coverage or promote.
- Pair: `--slice sast` uses overlay; `juliet-c-cwe134-printf` stays PASS; `owasp-python-deser` stays PASS; Juliet `goodG2B` exec/strcpy must **demote or unadjudicated** , not inventory-leak counted as pair fail for overlay.
2026-10-12 18:16:41 +02:00
- Unlabeled: tmp tree with a syntax-error file next to a clean `eval(request.args)` . Error file unadjudicated, clean file promoted; no expected.toml required.
2026-09-04 13:57:33 +02:00
- Gate: `python -m openultrasast.gate` still inventory-only.
- Deep: candidate list excludes demoted.
## Security
- Overlay does not execute target code.
- Facts cannot override CWE severity.
- Hunter still cannot stamp corroboration.
## Performance
- Overlay is per-file, MAP stage, same file budget as rank.
- Skip files already unreadable at preprocess.
## Migration
- Default `standard` runs overlay when facts load; if facts missing, degrade as Requirement 7.5.
- Existing `quick` CI unchanged.
2026-10-12 18:16:41 +02:00
- Pair gate local 100% remains inventory-or-overlay-equivalent on fixture counterparts (those files are still "nice"; unlabeled tests are additional).