Status: implemented.
engines/attack_graph/composes Phase 1-5 output into multi-hop attack paths, is wired intocli/main.py(axguard paths, aliasaxguard attack-paths) and soft-wired intoengines/audit.py(after the Phase 5 evidence block; never fails the audit). Artifacts:attack-paths.json+attack-paths.md. This document is the contract the engine satisfies; the sections below match the shipped behaviour.One confidence system, reused. Phase 6 invents no second
CONFIRMED/LIKELY/UNKNOWNscale and no second evidence-weight table. It reuses the Phase 5confidence_level(VERY_HIGH…UNKNOWN) forpath.confidence_level, the legacyconfirmed|likely|unknownvocabulary for per-hop/edge confidence, andengines.dataflow.schema.ensure_no_secret_valuesfor redaction.
Chain individually-reported findings (Phase 3 Judge candidates, Phase 4
adversary-final findings, Phase 1 app-model graph, Phase 2 taint paths) into
multi-hop attack paths: entrypoint → … → sensitive outcome. An attack
path answers "can an attacker actually get from the internet (or a low-priv
identity) to something that matters," not just "is there a vulnerability
somewhere."
Phase 6 is a rollup / composition layer. It does not detect new
vulnerabilities and it does not compute its own notion of confidence — it
consumes the confidence + evidence vocabulary that already exists in
engines/app_model, engines/dataflow, engines/verify, engines/adversary,
and engines/evidence. One confidence system, reused everywhere.
| Artifact | Produced by | What Phase 6 reads from it |
|---|---|---|
application-model.json |
Phase 1 (app_model) |
entrypoints, sinks, assets, ai_components, security_controls, trust_boundaries, graph.nodes / graph.edges |
dataflow.json |
Phase 2 (dataflow) |
taint_paths, controls[] (kind + effectiveness), taint_state |
verification.json |
Phase 3 (verify) |
Judge judgments[] (VERIFIED/LIKELY/UNVERIFIED/FALSE_POSITIVE) + evidence lists |
adversary.json / final-findings.json |
Phase 4 (adversary) |
final finding status (CONFIRMED/LIKELY/UNVERIFIED/FALSE_POSITIVE/REQUIRES_REVIEW) + counter-evidence + control-bypass evidence |
Phase 5 evidence ledger (evidence.json) |
Phase 5 (engines/evidence) |
canonical per-finding confidence rollup + store records (id/type/quality/description/file/line_*/symbol/provenance); paths attach via evidence_refs ids — do not re-copy fields |
Phase 6 must not invent a second CONFIRMED/LIKELY/UNKNOWN scale, a
second evidence-weight table, or a second redaction routine
(ensure_no_secret_values, _redact_snippet already exist in
engines/dataflow/schema.py and must be reused as-is for any snippet a
path evidence record carries).
| Node type | Source | Notes |
|---|---|---|
entrypoint |
app_model | HTTP route, webhook, queue consumer, CLI entry |
finding |
verify/adversary | a specific Judge/adversary-scored vulnerability instance (carries its own status + evidence, never re-scored by Phase 6) |
control |
dataflow controls[] / app_model security_controls |
barrier candidate — auth check, tenant-scoping filter, allowlist, sanitizer |
asset |
app_model assets[] |
sensitive resource: DB table, secret, credential store, PII |
identity |
app_model identities[] (extended) |
user / tenant / role — needed for priv-esc and cross-tenant chains |
ai_component |
app_model ai_components[] |
agent/orchestrator, LLM call, MCP client |
tool |
ai_component sub-type or dataflow SINK_AI_TOOL |
a callable the agent can invoke (file write, shell, HTTP, email, DB) |
external_service |
app_model external_services[] |
third-party API, cloud resource |
trust_boundary |
app_model trust_boundaries[] |
internet↔app, app↔db, agent↔tool, tenant↔tenant |
Every node keeps a refs list pointing back at the source artifact + id
(e.g. {"source": "verification.json", "id": "cand-0007"}) instead of
copying fields — Phase 6 is a graph over existing records, not a fork of
them.
| Edge type | Meaning |
|---|---|
reaches |
trust boundary crossed (internet → entrypoint), from app_model graph |
triggers |
entrypoint/finding → next finding (e.g. SSRF response is fed to a second sink) |
exposes |
finding → asset it directly discloses/modifies |
escalates_to |
identity(role=user) → identity(role=admin) via an authz weakness |
crosses_tenant |
identity(tenant=A) → asset/identity(tenant=B) via a missing tenant-scope check |
invokes |
ai_component → tool |
yields |
tool → asset/external_service affected by the tool call |
blocked_by |
edge annotated with the control node sitting on it, plus that control's effectiveness |
chains_to |
generic fallback link when dataflow/call-graph co-location connects two findings without a more specific edge type applying |
Edges always carry confidence (Phase 1–5 vocabulary: confirmed /
likely / unknown) and an evidence list — never a bare boolean.
A path is an ordered list of edges from one entrypoint (or attacker-
controlled identity) to one asset (or privileged outcome). Status is a
property of the whole chain, computed from its weakest hop plus barrier
state — never averaged, never invented independent of the underlying
findings:
| Status | Meaning | Derivation |
|---|---|---|
CONFIRMED |
Every hop is backed by a CONFIRMED/VERIFIED finding or confirmed-confidence edge, and no un-bypassed effective control sits on the path |
min() across hop statuses == top tier, no active blocked_by |
LIKELY |
At least one hop is only LIKELY/likely, none are FALSE_POSITIVE/INVALID, chain is still plausibly connected |
weakest hop is LIKELY, no blocking control |
UNVERIFIED |
A hop's own status is UNVERIFIED, or the link between two otherwise-real findings is unconfirmed (missing dataflow link, unknown network reachability, ambiguous reachability) |
mirrors EV_MISSING_LINK / reachability unknown |
INVALID |
Any hop's underlying finding is FALSE_POSITIVE, or two findings do not actually share data/control flow (co-located only, no taint/call link) — the chain must not be presented as exploitable |
hard veto, same spirit as adversary's FP_* reason codes |
BLOCKED |
The chain would otherwise be real, but an effective control (effectiveness == confirmed, or likely with no documented bypass) sits on a required edge |
blocked_by edge present and not countered by adversary controls_bypass evidence |
INVALID and BLOCKED are both terminal-safe states but mean different
things to a reader: INVALID says "this chain doesn't exist," BLOCKED
says "this chain exists but a barrier currently stops it" (worth flagging
if the control ever regresses).
Score is a ranking aid for triage order, not a new severity system:
- Weakest-link confidence (dominant factor) — a chain is only as
strong as its worst hop; never let one
CONFIRMEDhop paper over anUNVERIFIEDone. - Hop count — shorter, more direct paths to the same asset outrank longer ones at equal confidence (fewer assumptions).
- Asset impact tier — credentials/secrets/admin-control >
PII/financial data > generic internal data > low-value info. Reuses
asset.kindfrom app_model (secret,credential,database,token, …). - Reachability — public unauthenticated entrypoint > authenticated entrypoint > internal-only/queue-only > requires a prior compromise on this same graph.
- Control effectiveness along the path — mirrors the existing
EFFECT_CONFIRMED/EFFECT_LIKELY/EFFECT_UNKNOWN/EFFECT_INEFFECTIVEweighting inengines/dataflow/controls.py; ineffective/name-only controls (sanitize()that doesn't sanitize) do not reduce score, only genuinely effective ones do. - AI/agent multiplier — a chain that ends in autonomous tool execution (vs. a human reading a report) is flagged with higher priority at equal confidence, because impact can be automated / repeated.
score ∈ [0, 1] is a triage-ordering aid, not a severity or confidence
value. It is a transparent weighted sum with an AI multiplier, and every path
carries a score_factors breakdown so a reader can see exactly why one path
outranks another:
base = 0.50 · confidence(status) # weakest-link status weight (dominant)
+ 0.25 · asset_impact(target) # secret/credential/admin > pii > db > info
+ 0.15 · reachability(entry) # public > authenticated > internal/queue > unknown
+ 0.10 · directness # 1.0 for a 1-hop path, gently decaying, floor 0.30
score = min(1.0, base · ai_multiplier) # ai_multiplier = 1.15 iff the chain ends in a tool
confidence(status)weights (SCORE_STATUS_WEIGHT):CONFIRMED 1.0,LIKELY 0.65,UNVERIFIED 0.35,BLOCKED 0.15,INVALID 0.0— so a blocked/invalid chain never outranks a live one.asset_impact(ASSET_IMPACT_TIER) andreachability(REACHABILITY_TIER) reuseasset.kindand entrypoint reachability from Phase 1.- Only genuinely effective controls change the picture: an effective
barrier flips the status to
BLOCKED(which then dominates the score via the confidence weight); a name-only / ineffective control leaves the score untouched, exactly as the design requires.
Ranking within the output uses (status tier, score, fewer hops, id), and each
(entry, target) group tags its shortest-credible and highest-impact
framing in path.selection.
- Shortest path: BFS over
graphedges from every public/reachable entrypoint or attacker-controllable identity to everyasset/privileged node, cost = 1 per hop, tie-broken by weakest-link confidence (prefer the higher-confidence of two equal-length paths). - Highest-impact path: separate ranking by
(status tier, asset impact tier, aggregate confidence)— a longerCONFIRMEDpath to a credential store should outrank a 1-hopUNVERIFIEDpath to a low-value asset. Both the shortest and the highest-impact path per (entrypoint, asset) pair are worth reporting; they are often the same path but not always. - De-duplication: paths sharing the same ordered set of finding ids
collapse to one record with multiple
(entrypoint, asset)framings noted.
A finding node with no outgoing triggers/exposes/invokes edge to
anything else chainable is a dead end: it is still reported by Phase 3/4
as a standalone vulnerability, but Phase 6 must not fabricate a next hop to
make a more dramatic story. Mark it "dead_end": true and leave it out of
the paths[] list (or include it as a zero-hop "path" only if explicitly
requested) — see unknown_reachability.py and the "no relation" half of
false_chain.py for fixture cases that must terminate here.
Distinct edge sequence, still built from the same node/edge/status vocabulary:
entrypoint (chat/doc-ingest) --reaches--> ai_component[agent]
ai_component[agent] --invokes--> tool[privileged: fs/http/shell/email]
tool --yields--> asset (secret, internal API, filesystem)
- The "vulnerability" at the start is prompt injection: untrusted content
(user message, ingested document, tool output) reaches the model's
instruction context — treat this as a
findingnode like any other (source-controlled, sink = agent instruction context). - The critical edge is
invokes: does the agent framework let an injected instruction reach a privileged tool (write/exec/network/ send) without a human-in-the-loop or allowlist check? BLOCKEDapplies when the tool call requires explicit user confirmation or the tool is allowlisted/scoped narrowly (effectiveness=confirmedcontrol on theinvokesedge) — seeai_chain.py.
Controls are graph nodes/edges, not silent modifiers:
- Reuse
dataflow.controls[](kind,effectiveness) and app_modelsecurity_controls[]directly — do not re-derive control detection. - A control sits on an edge between two nodes. If effectiveness is
confirmed, orlikelywith no adversarycontrols_bypassevidence contradicting it, the edge is annotatedblocked_byand downstream reachability through that edge is cut for scoring purposes (path status →BLOCKED). - A named-but-ineffective control (
EFFECT_INEFFECTIVE, orEFFECT_UNKNOWNwith no reject branch — same heuristic already infind_controls_in_region) does not block the path; the chain keeps whatever status its findings otherwise earn. This mirrors the adversary philosophy: "name-onlysanitize()/require_auth-that-doesn't-check is not enough." - If Phase 4 adversary evidence shows a documented bypass of an otherwise- effective control, the control's blocking effect is lifted for that specific path and the path reverts to whatever status its findings otherwise earn.
The Phase 1-5 hunters do not yet detect every pattern the fixtures exercise
(mass-assignment / BOLA are not in the verify hunters, and the adversary
conservatively drops the multi_chain SSRF over a spurious timeout= "control").
To compose those chains honestly, the attack-graph package runs its own
deterministic, evidence-gated structural detectors (scoped entirely to
engines/attack_graph/nodes.py). They emit finding-like candidate_seed
nodes with explicit evidence and origin: "attack_graph_seed":
- A seed is
LIKELYat best — a structural pattern is neverCONFIRMED. - A seed is
UNVERIFIEDwhen reachability is unknown. - A detector that does not match emits nothing; seeds never fabricate a chain
from co-location, and a
FALSE_POSITIVEfinding hop still vetoes a chain (INVALID).
Adversary-scored CONFIRMED/LIKELY/UNVERIFIED findings are always
preferred over seeds when both are available for the same location.
engines/attack_graph/ writes attack-paths.json (machine-readable graph +
paths[] + dead_ends[] + alternate_paths[]) and attack-paths.md (human
summary), following the same write_reports-style convention as other phases.
Sketch:
{
"schema_version": "1.0.0",
"tool": "axguard",
"graph": {"nodes": [...], "edges": [...]},
"paths": [
{
"id": "path-0001",
"status": "CONFIRMED",
"tags": ["ssrf_chain"],
"entry": "entrypoint:...",
"target": "asset:...",
"hops": ["entrypoint:...", "finding:...", "finding:...", "asset:..."],
"confidence": "confirmed",
"score": 0.0,
"controls_encountered": [{"id": "control:...", "effectiveness": "ineffective"}],
"dead_end": false,
"evidence_refs": [{"source": "adversary.json", "id": "..."}]
}
],
"dead_ends": [{"finding": "finding:...", "reason": "no outgoing chainable edge"}]
}- No new confidence/evidence system — strictly consumes Phase 1–5 output.
- No Business Logic / Dynamic Exploit / Auto-Fix / Continuous Learning engines.
- No LLM calls in the default pipeline (same constraint as Phase 3/4). The
LLMAttackPathStubis inert: it can only restate existing graph nodes and itsinvent()raises — it never adds a node, edge, credential, impact, or reachability claim.
engines/attack_graph/
__init__.py ATTACK_GRAPH_VERSION, run_attack_graph, write_attack_graph_report, render_attack_paths_markdown
schema.py node/edge/status vocabulary, scoring constants, empty_attack_graph()
nodes.py node factories + evidence-gated structural detectors (assets/ai/tools/seeds)
edges.py evidence-backed edge factory (confidence + evidence, never a bare bool)
preconditions.py per-hop auth/role/input/network/tenant preconditions
barriers.py control effectiveness — BLOCKED (fails-closed crypto) vs ineffective
chaining.py vulnerability chaining (ssrf→internal, idor/bola, mass-assignment, ai tool)
paths.py bounded enumeration, dedupe by finding-id signature, shortest/highest-impact
scoring.py transparent prioritization (documented factors)
confidence.py weakest-link path status + Phase 5 confidence_level reuse
llm_stub.py inert explain-only stub (invent() raises)
summarize.py summary + attack-paths.md renderer
writers.py attack-paths.json + attack-paths.md
pipeline.py run_attack_graph(target, evidence=None)
Status: implemented. Part 2 is an intelligence layer built over the Part 1 chains — it does not rewrite them. Part 1 still assembles the evidence-backed
graph+paths[]; Part 2 reasons about who the attacker becomes, what state the app moves through, how privilege shifts, how sensitive the reached data is, what one compromise exposes (blast radius), what one fix removes (fix impact), and which paths are really the same bug (equivalence). It adds new result fields and a public queryapi, and adds optional view filters toaxguard paths. Same one confidence system, reused — Part 2 invents no second confidence scale and no second evidence-weight table, and every derived record is anchored to evidence that already exists on a node/edge.
| Concern | Module | Public entry (engines.attack_graph.api) |
|---|---|---|
| Bounded, deterministic graph search | search.py |
(used internally by blast/reachability) |
| AND/OR three-valued precondition logic | logic.py |
— |
| Identity types + transitions | identity.py |
get_path_identities, get_identity_transitions |
| Application states + transitions | state_model.py |
get_path_state, get_state_transitions |
| Privilege transitions (vert/horiz/deputy) | privilege.py |
get_privilege_transitions, get_confused_deputy_paths |
| Sensitive-data categories + impact | sensitivity_data.py |
get_sensitive_assets, analyze_path_sensitivity |
| Search modes | modes.py |
(via run_attack_graph(search_mode=…) / CLI --mode) |
| Blast radius | blast.py |
get_blast_radius |
| Choke points / minimal cut | choke.py |
get_choke_points |
| Fix impact (analysis only) | fix_impact.py |
analyze_fix_impact |
| Plain-language explanation + rejection reasons | explain.py |
explain_path, get_rejected_path_reason |
| Equivalence grouping (shared root cause) | equivalence.py |
get_equivalent_paths |
| Tenant boundaries / AI paths / temporal | api.py |
get_tenant_boundaries, get_ai_attack_paths, get_temporal_dependencies |
Reachability is rarely a flat list; it is an expression such as
(public_network OR authenticated_session) AND attacker_controlled_input.
Part 2 represents such expressions as JSON-serialisable AND / OR / ATOM
trees and evaluates them with Kleene three-valued logic:
AND→FALSEif any operand isFALSE; elseUNKNOWNif any isUNKNOWN; elseTRUE.OR→TRUEif any operand isTRUE; elseUNKNOWNif any isUNKNOWN; elseFALSE.
The hard rule: UNKNOWN is never promoted to TRUE. An attacker-supplied
atom (satisfied_by == "attacker") is TRUE; everything else stays UNKNOWN
until an explicit resolver proves it. This mirrors the whole-project honesty
contract (unknown reachability → UNVERIFIED, never a confident claim).
preconditions.py gains opt-in *_precondition_expr() builders that emit these
trees; the original flat *_preconditions() helpers are unchanged.
Identity types: ANONYMOUS, ATTACKER, AUTHENTICATED_USER, TENANT_USER,
PRIVILEGED_USER, SERVICE, SYSTEM, AI_AGENT, UNKNOWN. A path is also a
sequence of identities the attacker occupies. Transitions are evidence-backed
only: a transition is emitted when a real escalates_to / crosses_tenant /
invokes / yields edge or an authenticated entrypoint justifies it, and it
carries that edge's evidence. The starting identity is derived from the
entrypoint's reachability (public → ATTACKER, authenticated → low-priv
session), never over-claimed.
States: UNAUTHENTICATED, AUTHENTICATED, AUTHORIZED, TENANT_SCOPED,
PRIVILEGED, TOOL_EXECUTION, COMPROMISED, UNKNOWN. State is inferred
lightly and only from code-visible signals already in the graph — an
authenticated entrypoint, a control/guard node on the path, an
escalation/tenant/invoke edge, or a reached asset. With no code-visible guard
the state stays UNKNOWN rather than being invented. Each transition carries
the evidence that justified it.
Every identity transition is classified into a well-known pattern:
vertical— a lower role becomes a higher one (user → admin), typically viaescalates_to(seepriv_esc.py).horizontal— same tier, different scope (tenant-A → tenant-B), viacrosses_tenant(seecross_tenant.py).confused_deputy— a trusted component performs a privileged action on the attacker's behalf: SSRF (the server fetches an internal resource for the attacker — seemulti_chain.py) or an agent thatinvokesa privileged tool from injected instructions (seeai_chain.py).
privilege_graph() rolls these up into a small identity→identity graph;
get_confused_deputy_paths() lists the path ids exhibiting the deputy pattern.
Categories (with impact weight in [0,1]): PUBLIC 0.10, INTERNAL 0.40,
CONFIDENTIAL 0.60, AI_CONTEXT 0.70, PII 0.75, FINANCIAL 0.80,
CREDENTIAL 0.95, SECRET 1.00, UNKNOWN 0.30. The weight is an impact
dimension only — not a confidence, not a severity — and it never upgrades a
path's status. Categorisation reads the existing asset.kind; anything unmapped
falls back to UNKNOWN (deliberately low), so impact is never over-claimed
for data we cannot categorise.
A mode is an explicit, opt-in status filter over live paths:
CONFIRMED_ONLY— onlyCONFIRMED.CONFIRMED_AND_LIKELY—CONFIRMED+LIKELY(the credible set).INCLUDE_UNKNOWN(default) — alsoUNVERIFIED(surface-for-review set).
BLOCKED / INVALID are never live in any mode — a blocked chain has an
effective barrier and an invalid chain does not exist. Modes never mutate the
written attack-paths.json paths[]: they filter a copy for display/querying.
run_attack_graph(target, search_mode=…) records the mode and re-derives the
posture view; the enumerated path set is identical regardless of mode.
A generic, bounded, deterministic BFS/DFS over graph.nodes / graph.edges
(depth-capped by MAX_SEARCH_DEPTH, result-capped by MAX_SEARCH_RESULTS). It
traverses only edges that already exist (never invents a hop), and prunes
(a) edges blocked by an effective control and (b) edges into a
FALSE_POSITIVE / INVALID finding. Neighbours are visited in a stable
(edge type, to id) order so results are reproducible.
get_blast_radius(node_id) returns everything reachable from a compromised node
over evidence-backed, unblocked edges — grouped by type, with the reachable
sensitive assets, the single most-sensitive category reachable, and
representative shortest paths. A non-existent origin returns an empty, honest
result. rank_blast_origins() ranks likely compromise origins by radius impact.
get_choke_points() ranks nodes by path-betweenness (how many distinct
paths they lie on), weighting controls/findings slightly higher because they are
the natural remediation targets. minimal_cut() is a greedy set-cover
heuristic that picks choke points until all live paths are covered — advertised
as a heuristic, not an exact min-cut solver.
analyze_fix_impact(finding_or_control) projects the effect of one remediation
without editing code or re-running hunters:
- Fixing a finding removes every path that traverses it.
- Hardening a control blocks (weakens) every path it gates — those chains
become
BLOCKED; the finding may still stand for an already-authorised actor.
rank_fixes() orders remediable nodes by how many paths a fix removes/weakens.
get_equivalent_paths() groups paths that reduce to the same underlying bug:
two paths are equivalent when they share a finding/candidate-seed node, or when
Part 1 already linked them via shares_root_cause_with. Each group names a
representative (highest-status, shortest) and the shared finding(s) — the single
fix target — so the path count is not inflated by re-framings of one root cause.
explain_path() restates a path in plain language built only from the
evidence already on its nodes/edges (like llm_stub, it can never invent a
node, edge, credential, impact or reachability claim; invented is always
False). rejected_path_reason() / get_rejected_path_reason() render why a
path is not live (BLOCKED / INVALID / UNVERIFIED) from the path's own
status_reasons.
run_attack_graph(...) keeps every existing field and adds:
| Field | Meaning |
|---|---|
search_mode |
the active mode (default INCLUDE_UNKNOWN) — does not filter paths[] |
identity_transitions |
evidence-backed identity moves across all paths |
privilege_transitions |
vertical / horizontal / confused-deputy transitions |
state_transitions |
application-state moves across all paths |
rejected_paths |
BLOCKED / INVALID paths with plain-language reasons |
posture |
rollup: reachable identities/states, max sensitivity, privilege-pattern counts, confused-deputy paths, top choke points, top blast origins, live-vs-rejected counts |
ensure_no_secret_values runs over the whole result (including these fields),
and the private _evidence ref is still stripped by the writer.
Deterministic, read-only helpers over a run_attack_graph(...) result:
get_path_state, get_path_identities, get_privilege_transitions,
get_tenant_boundaries, get_sensitive_assets, get_blast_radius,
get_equivalent_paths, get_choke_points, analyze_fix_impact,
analyze_path_sensitivity, get_rejected_path_reason,
get_temporal_dependencies (thin but real: per-path ordered prerequisites),
get_state_transitions, get_identity_transitions,
get_confused_deputy_paths, get_ai_attack_paths, plus explain_path.
Display-only filters (the written attack-paths.json is always the full,
unfiltered set):
--mode CONFIRMED_ONLY|CONFIRMED_AND_LIKELY|INCLUDE_UNKNOWN--current— only currently-exploitable paths (CONFIRMED/LIKELY)--blocked— only paths blocked by an effective control--unknown— onlyUNVERIFIEDpaths (for review)--critical— only paths reaching high-sensitivity assets (PII and above)--shortest— only the shortest-credible path per(entry, target)--predictive— attack-surface / architecture-drift signals (never current findings)--diff PATH_A PATH_B— compare two savedattack-paths.jsonartifacts--what-if SCENARIO— counterfactual paths markedhypothetical: true/PREDICTIVE
Distinguish clearly: Current Finding ≠ Current Attack Path ≠ Blocked Path ≠ Predictive Risk ≠ Hypothetical Path.
engines/attack_graph/
logic.py AND/OR three-valued precondition logic (UNKNOWN never TRUE)
identity.py identity types + evidence-backed identity transitions
state_model.py application states + light, evidence-backed state transitions
privilege.py vertical / horizontal / confused-deputy privilege transitions
sensitivity_data.py sensitive-data categories + impact weighting
modes.py search modes (status filters over live paths)
search.py bounded, deterministic BFS/DFS with blocked/impossible pruning
blast.py blast radius from a compromised node (evidence-backed edges)
choke.py choke-point / greedy minimal-cut heuristics
fix_impact.py analyze_fix_impact() — analysis only, no code edits
explain.py plain-language explanation + rejected-path reasons
equivalence.py group equivalent paths sharing a root cause
api.py public, deterministic query surface
whatif.py counterfactual scenarios (hypothetical only)
diff.py attack-graph artifact comparison
predictive.py surface / drift signals (not vulns)
sbom.py manifest-only dependency graph (no CVE DB)
temporal.py multi-step temporal chain heuristics
cross_service.py cross-service trust edges from app model
aggregate.py risk rollup (paths / root causes / choke points)
posture.py Entry→…→Impact posture narrative helper
benchmark.py fixture evaluation harness (measured counts only)
- No new confidence/evidence system (unchanged from Part 1).
- No CVE database lookups; SBOM is declared-deps only.
- No automatic source modification or active exploitation.
- No LLM calls;
LLMAttackPathStub.invent()still raises and is never invoked. fix_impactis projection only — it never edits source or re-runs hunters.- HTML report approval UX lives in
engines/report_ux.py(separate from this engine).