Reachability & Sink Detection — Product Strategy (Decision Doc)
Status: RATIFIED 2026-06-10 — D1–D10 ratified by the operator, with D3 modified (all-language plan, see §10 ratification note and §11). Date: 2026-06-10. Author: Product/security-architecture strategy pass (codebase-verified). Audience: Operator (decision-maker), Scanner/Concelier/Signals/Policy guilds. Scope: What “reachability” should mean for Stella Ops, how sinks should be detected and proven, what to build in which order, and the explicit decisions required. No code in this pass.
Every load-bearing claim about current behavior below was verified against src/ on 2026-06-10; file paths are cited inline. Competitive-landscape and ecosystem-data-rate statements come from training knowledge (offline pass — no web fetches) and are marked [knowledge].
1. Executive summary — the call
Stella Ops should not position reachability as “we have a sink rule list” (every SAST tool has one) and should not chase whole-program taint analysis (CodeQL/Semgrep own it; it is non-deterministic in practice across 8+ languages and unaffordable to maintain). The defensible, on-strategy position is:
“This CVE is reachable in your image, and here is the cryptographically signed, offline-replayable call path that proves it — or the signed coverage statement that explains why we say it is not.”
Concretely:
- Make the vulnerable symbol the primary sink. Reachability questions are keyed by (CVE, purl, image digest), and the sink set is the CVE’s affected functions — not a generic “dangerous API” list. The data model for this already exists end-to-end (Concelier
AffectedSymbolingest projection → ScannerICveSymbolMappingService→ReachabilityAnalyzerexplicit-sinks BFS →PathWitnessDSSE signing); it is plumbing-complete and population-starved (§3.3, §6.1). - Demote the generic sink catalogs to what they really are: capability annotations.
CmdExec/UnsafeDeser/Ssrfmarks on a path tell the operator what an attacker gains at the end of a reachable path (“this reachable CVE path terminates in process execution”). They grade severity and feed Policy’s evidence-weighted scoring; they must never be the headline “reachable” claim. They also become data packs, not C# code (§7.4). - Sell the honesty lattice, not a boolean. Four public verdicts —
reachable (proven),likely reachable,not observed,unknown— each with a mandatory machine-checkable evidence object, and a hard product rule: no verdict ever claims more than its evidence layer can prove, and “unreachable” always carries a graph-coverage qualifier. This is the only stance that survives an auditor and the only one consistent with Stella’s attestation posture. - The OCI referrer carries the pruned, vuln-keyed subgraph + witnesses, not the whole graph. Full graphs stay in CAS; the attached evidence is the minimal entry→sink slice (bounded by
maxPaths × maxDepth, kilobytes not megabytes). This simultaneously solves auditability and the referrer size problem (§8). - Sequence: fix the four structural defects first (cheap, verified, §3.5), prove one language end-to-end (Go — best symbol data + only SSA-grade extractor), then scale by data fan-out, then build the patch-diff surface factory as the long-term moat.
What makes this defensible: SaaS reachability vendors (Endor, Snyk) cannot follow Stella into air-gap, cannot make their analysis deterministically replayable by the customer, and do not emit signed per-path evidence bound to an image digest. Rule-list scanners (Trivy-class) have no path proofs at all. Stella’s wedge is reachability as attested evidence — a property of the evidence pipeline, which is already Stella’s core competence, not of the analyzer, where Stella will never out-analyze CodeQL.
2. What reachability must mean for Stella users (jobs-to-be-done, ranked)
Stella is a release control plane: its user is deciding “may this image go to prod?” under audit obligations (NIS2/CRA/DORA), often air-gapped. Rank of jobs:
| Rank | Job | Why this rank | Product implication |
|---|---|---|---|
| 1 | Auditable evidence for a release decision — “we shipped despite CVE-X because it is not reachable; prove it to the auditor in 2 years” | This is the job nobody else can do offline + signed; it monetizes Stella’s existing attestation spine (DSSE, referrers, EvidenceLocker, deterministic replay). A reachability verdict that gates a release is itself a release-decision artifact and must meet the same evidentiary bar as the rest of the platform. | Every verdict ships a signed witness or a signed coverage statement. Replayable: same inputs → same verdict hash. VEX statements are drafted from verdicts, with operator countersign. |
| 2 | Prioritization — “of 400 CVE findings, which 12 are actually exploitable in my deployment?” | This is the daily-pain job and the demo moment. But ranking can tolerate likely; it does not need proof for every line of the list. | The lattice verdict + capability-sink annotation + gates feed Policy scoring/EWS; UI sorts by evidence tier first. |
| 3 | Noise reduction — auto-downgrade/suppress not-reachable findings | Highest economic value but highest risk: a wrong “not reachable” is a shipped vulnerability with Stella’s signature on it. Must be earned by #1’s evidence machinery, never by rule-list heuristics. | Suppression requires not observed + coverage above policy threshold + signed suppression witness (SuppressionWitness already exists in src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/); policy-gated, operator-visible. |
All three, in that order. The order matters because #1 is the moat that makes #2 and #3 trustworthy; competitors do #2 first and then struggle to be believed on #3. (Decision D1.)
3. Ground truth: what exists today (verified)
This section is the honest inventory the strategy stands on. The architecture is unusually far along — the gap is not missing design, it is four structural defects + unpopulated data paths + dormant default wiring.
3.1 Call-graph extractors (src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/)
Two tiers exist, and the distinction drives the whole strategy:
| Tier | Languages | Method | Precision (verified) |
|---|---|---|---|
| Toolchain | Go (GoCallGraphExtractor → external SSA tool), Java (JavaCallGraphExtractor → bytecode/ASM), Node (NodeCallGraphExtractor → Babel tool), .NET tier-A (DotNetCallGraphExtractor → Roslyn semantic, needs restore), Binary (BinaryCallGraphExtractor → x86/arm64 disassembly, PLT/IAT, DWARF) | Real resolution | Go SSA resolves calls properly; Java resolves bytecode call targets and marks sink call-targets as synthetic nodes (verified lines 103–147 — graph-construction logic only: the underlying class-file parser had a position-loss bug that made every REAL .class parse fail until LANG-JAVA fixed it 2026-06-11, see §11 Java row). Binary extracts direct calls from disassembly. |
Standalone source-AST (the build-time set wired in ReachabilityCallGraphGenerator: dotnet, javascript, python, rust) | DotNetLightweightCallGraphExtractor (Roslyn parse only, no semantic model), JavaScriptCallGraphExtractor, PythonCallGraphExtractor, RustCallGraphExtractor (regex/AST sweeps) | Lexical name resolution | No type resolution, no virtual/interface dispatch, no overload resolution, no cross-assembly knowledge. The .NET lightweight resolver matches invocation text against same-project method names; unresolved calls become synthetic “external” nodes whose symbol is the literal receiver text (e.g. _logger.LogDebug); ambiguity is handled by deterministic lowest-ordinal pick at confidence 0.5, externals at 0.4 (verified DotNetLightweightCallGraphExtractor.cs lines 853–977). Header honestly documents the gaps (generics, source-gen, cross-assembly virtual dispatch, fluent chains). |
Strategic read: the standalone extractors are adequate for first-party application code (the app’s own entry→call structure, where most code is non-virtual local calls) and inadequate for deep dependency-graph traversal (which is exactly where vulnerable symbols live). Entrypoint detection, by contrast, is genuinely strong and broad (Flask/FastAPI/Django/Celery/Click, Spring/JAX-RS/Micronaut/Servlet/gRPC, minimal APIs/hosted services, Module Federation — see src/Scanner/AGENTS.md Sprint 018/020 coverage tables with negative-test discipline). Conclusion: the entry side of the graph is trustworthy; the sink side must be anchored by symbol matching (purl-scoped), not by chasing call chains through dependency internals with a lexical resolver. Where dependency-internal traversal is needed (trigger→sink inside a package), the per-package internal graphs from VulnSurfaces (CecilInternalGraphBuilder, JavaInternalGraphBuilder, …) are the right tool — computed once per package version offline, not re-derived per scan.
3.2 Sink machinery — wired, but structurally broken in the two languages that matter most
Per-language sink matchers exist for ALL languages (Python/JS/Java/Go/Rust/Ruby/PHP/Bun/Deno) with substantial pattern tables, and they ARE invoked by extractors. The “sinks=0” symptom has precise, fixable causes:
- .NET — qualified-name mismatch.
SinkRegistrypatterns are fully qualified (System.Diagnostics.Process.Start), but the lightweight extractor’s external symbols are lexical receiver text (Process.Start).SinkRegistry.MatchSinkdoessymbol.Contains(pattern)— a shorter symbol can never contain a longer pattern, so .NET sinks ≈ 0 unless code calls fully-qualified. Verified:SinkRegistry.csline 142 vsDotNetLightweightCallGraphExtractor.cslines 889–894 (BuildExternalNode→MatchSink("dotnet", resolved.TargetSymbol)). - Python — no call extraction at all.
PythonCallGraphExtractor.ExtractCallsFromFunctionreturns[](“Simplified: would need proper AST parsing”, line 477–481), so the Python graph has zero edges; and the sink matcher is applied to function definitions in first-party code (line 94) rather than call targets — first-party apps do not defineos.system, so sinks are structurally zero. A zero-edge graph also always fails the build-time stub-guard (nodes > 1 && edges > 0), so the Python build-time reachability referrer can never attach. - Triplicate enums.
SinkCategoryis defined inStellaOps.Scanner.Contracts/SinkCategory.cs(17 values, the canonical one) ANDStellaOps.Scanner.Reachability/Explanation/PathExplanationModels.csline 212 (14 different values);EntrypointTypeexists three times (Contracts/CallGraphEnums.cs,Reachability/Stack/ReachabilityStack.csline 162,Reachability/Explanation/PathExplanationModels.csline 175) with different member sets. Witness/explanation payloads therefore cannot round-trip categories losslessly across layers. - JS/Rust/Java/Go work where imports resolve. JS synthesizes sink nodes from import-alias matches (verified
JavaScriptCallGraphExtractor.cslines 440–535 —require/ESM import aliases →js:{pkg}/sink.{module.method}nodes); Rust matches use-decl module paths; Java marks bytecode call targets; Go matches SSA node packages. These are real but only as good as the import-alias table (JS member-calls on non-imported receivers are skipped — honest under-approximation). — EXTENDED 2026-06-11 (LANG-JS): JS now also synthesizesjs:{pkg}/external.{module}.{method}targets for import-verified calls that are NOT catalog sinks (vulnerability-sink matching needs the external call surface in the graph, §7.2 note); non-imported receivers remain honest skips. — EXTENDED 2026-06-11 (LANG-RUST): Rust call chains are use-decl-expanded before sink matching and target synthesis (the matcher sees full module paths instead of receiver-text suffix luck), first-party-rooted paths resolve to declared nodes, and extern targets carry the module path as theirPackageanchor (§11 Rust row).
Strategic read: the sink taxonomy and plumbing are sound; the defects are local and cheap. But even fixed, a capability-sink catalog is a commodity. It earns its keep as path annotation and scoring input, not as the product claim. (Decision D2.)
3.3 The CVE→symbol feasibility gate — VERIFIED OPEN, with a caveat
The question “does Stella’s advisory model carry affected symbols, or only version ranges?” gates the whole vulnerability-driven strategy. Verified answer:
- The canonical Concelier advisory model (
StellaOps.Concelier.Models/Advisory.cs,AffectedPackage/AffectedVersionRange) carries version ranges only — no symbols. Correct as-is; do not bloat the canonical model. - A separate, fact-only signals projection exists and is wired into ingestion:
AffectedSymbol(+AffectedSymbolProvenance,AffectedSymbolSet) inStellaOps.Concelier.Core/Signals/AffectedSymbol.cs, per CONCELIER-SIG-26-001 — symbol name, type (Function/Method/Class/Module/Package/Endpoint), purl, module/class, file/line, version range, full provenance anchors.PostgresAdvisoryObservationStore.UpsertAsyncrunsIAffectedSymbolExtractor.ExtractAsyncover the raw observation JSON on every advisory upsert and persists to a durable Postgres projection (PostgresAffectedSymbolStore), with a fail-closedUnsupportedAffectedSymbolStoredefault. Query API (IAffectedSymbolProvider: by advisory, by purl, batch) is complete. - The caveat (defect #4):
OsvAffectedSymbolExtractoronly reads symbol arrays sitting directly underecosystem_specific/database_specific(field namessymbols|functions|vulnerable_functions|affected_functions|methods, verifiedIAffectedSymbolProvider.cslines 604–649). The two richest real-world shapes are nested and therefore currently missed: Go vulndb’secosystem_specific.imports[].symbols[knowledge: present on the large majority of Go vulndb entries — this is the single best symbol corpus in the industry] and RustSec’secosystem_specific.affects.functions. The OSV connector preservesecosystem_specificverbatim (OsvVulnerabilityDto.csline 88), so the data reaches the extractor and is dropped there. One method fixes the biggest data source in the ecosystem. — FIXED 2026-06-10 (see §3.5 #4): both nested shapes are now descended with per-shapeExtractionShapeprovenance tags; previously-ingested observations needed the §Phase-2 re-upsert backfill before their projections populate — backfill LANDED 2026-06-11 (PostgresAffectedSymbolBackfillService+signals:backfill-affected-symbolsjob, see §3.5 #4). - A second, independent CVE→symbol source is ~70% built:
StellaOps.Scanner.VulnSurfaces— the patch-diff pipeline (download vulnerable+fixed package versions, fingerprint methods per ecosystem: Cecil IL/.NET, ASM/Java, Babel/npm, AST/PyPI;MethodDiffEngine;TriggerMethodExtractorcomputing public-API triggers over per-package internal call graphs; Postgres-backedVulnSurfaceServicereturningsurface | package-symbols | heuristicsources with calibrated confidence 0.8/0.4/0.2). This is the Snyk-style “vulnerable function derivation” engine, in-tree. — BRIDGED INTO THE MAPPING READ PATH 2026-06-12 (sprintSPRINT_20260612_002MAPPING-BRIDGE, see the §7.1 Phase 4(a) note):VulnSurfaceCveSymbolMappingServiceexposes computed surfaces throughICveSymbolMappingService(curated > upstream projection > patch-diff merge order; the feed-time factory trigger that computes surfaces on ingest is still owed). - Third source, designed for air-gap: offline bundles (
FileAdvisoryBundleStore, format documented indocs/modules/reach-graph/guides/cve-symbol-mapping.md§5). - Population gap: the Scanner-side evidence job resolves sinks via
ICveSymbolMappingService→PostgresCveSymbolMappingRepository(Reachability/ServiceCollectionExtensions.cslines 40–43), but nothing currently bridges Concelier’sAffectedSymbolprojection into that mapping store; and the evidence pipeline’s default DI is dormant (NullCallGraphSnapshotProvider,NullEvidenceStorageService,NullBinaryPatchVerifier,NullRuntimeReachabilityCollector— lines 52–76 of the same file). — PARTIALLY WIRED 2026-06-10 (see §7.1 Phase 0): (a) the symbol bridge now exists —AffectedSymbolCveSymbolMappingService(Scanner.Reachability/Services) queriesIAffectedSymbolProviderand merges the projection into theICveSymbolMappingServiceread path (explicit store rows win on canonical-id collisions; Function/Method/Class granularity only; write path/offline-bundle import unchanged); activated in DI whenever the host registers anIAffectedSymbolProvider— the Worker registers an HTTP adapter (ConcelierHttpAffectedSymbolProvider→/v1/signals/symbols/*) whenReachability.AdvisoryBaseUrlis configured. (b)ICallGraphSnapshotProvideris now real —WorkerCallGraphSnapshotProvideris fed byReachabilityInputStageExecutorregistering each extracted snapshot keyed by image digest (forceRecompute replays the persistedcallgraph-snapshot.json). Still dormant:NullEvidenceStorageService,NullBinaryPatchVerifier(Ghidra),NullRuntimeReachabilityCollector(eBPF) — the latter two are deliberate opt-ins (§5-E/F).
Verdict on the gate: vulnerability-driven sinks are feasible and mostly built — blocked by (a) one nested-shape extractor fix, (b) one Concelier→Scanner bridge, © ecosystem data coverage, which is structural and must be answered by the three-source ladder (§6.1), not denied.
3.4 The layered reachability stack, witnesses, and pruning — strong bones
ReachabilityStack3-layer model (Reachability/Stack/ReachabilityStack.cs): L1 static call graph, L2 binary/loader resolution (“does the dynamic loader actually link the vulnerable symbol” — full ELF/PE model: RPATH/RUNPATH/LD_PRELOAD/symbol versioning/delay-load/SxS inLayer2/ILayer2Analyzer.cs), L3 runtime gating (feature flags/env/config/platform).ReachabilityStackEvaluatorimplements a conservative verdict truth table (Exploitable / LikelyExploitable / PossiblyExploitable / Unreachable / Unknown) — notably it refuses to trust a low-confidence gate as a blocker (lines 132–139), exactly the right fail-direction.- Reality check:
ILayer1/2/3Analyzerinterfaces have no implementations (verified by search). What runs instead:ReachabilityEvidenceJobExecutorbuilds L1 from the real BFSReachabilityAnalyzer(deterministic, explicit-sink support, witness-ordered paths —CallGraph/Analysis/ReachabilityAnalyzer.cs), L2 from an optional GhidraIBinaryPatchVerifier(patch-presence check, NOT the loader-resolution model — two different L2 semantics coexist), L3 from an optional eBPF runtime collector — both defaulting to honestUnknown. So: stack model real, L1 real, L2/L3 optional-and-dormant, the loader-resolution L2 is a design with no engine yet (though the raw capability exists inBinaryCallGraphExtractor’s PLT/IAT/dynamic-load analysis). - Witness/evidence machinery is the most complete part: DSSE-signable
PathWitnesswith canonicalpath_hash, node hashes, gates, evidence URIs, static/runtime observation types and claim-ids linking runtime confirmation to static claims;SuppressionWitness+ signer; slice extraction with DSSE signing, verdict computation, CAS storage, replay (Slices/);SubgraphExtractor+ PoE design (SUBGRAPH_EXTRACTION.md: bounded BFS, deterministic ordering, guard propagation, build-id binding); gate detection with multipliers (Gates/); drift attestation (ReachabilityDrift); Signals-side scoring lattice with bucket weights and unknowns-pressure penalty (docs/modules/reach-graph/guides/lattice.md). Hash-contract boundary (verified 2026-07-19): the active PW-SCN-003 witness/referrer recipe is internally self-consistent, but it is not the uncomposedStellaOps.Reachability.Core.PathHashRecipe/PathFingerprintcontract. PW-SCN-003 emitspath:sha256:over colon-joined node digests and returns sorted/de-duplicated node hashes; the Core recipe emitssha256:over directed>joins and selects entry/exit top-K nodes.RichGraphBuilderdoes not populateRichGraphNode.NodeHash, slice DTOs have no hash fields, the subgraph extractor does not project its optional node hash, and the production WebService SARIF mapper supplies none of the SARIF library’s optional hash inputs. Do not describe these as one canonical cross-surface recipe until a versioned migration reconciles them. - Build-time referrer path (
Sbom.BuildTime/ReachabilityCallGraphGenerator.cs, invoked bystella sbom attach --reachabilityviaSbomAttachCommand/BuildTimeOciAttachService): runs standalone extractors in-process, enforces a stub-guard (refuse 1-node/0-edge graphs), and serializes a compact-v2 wire form (edges by integer index, nulls omitted, gzip — “20–40x smaller”, built because a ~189k-edge graph 502’d a gateway). Today it attaches the whole graph and “no consumer reads the referrer back” (its own comment).
3.5 The four structural defects (Phase 0 targets — all verified, all cheap)
| # | Defect | Where | Fix shape |
|---|---|---|---|
| 1 | .NET sink patterns can never match lexical symbols | Contracts/SinkRegistry.cs line 142 + lightweight extractor external symbols | Suffix-aware/alias-aware matching keyed on Type.Method tail; or resolve via using-directives at parse time — FIXED 2026-06-10: SinkRegistry.MatchSinkLexical adds a conservative segment-suffix rule (lexical symbol must be the exact dot-segment tail of the pattern, ≥2 segments = Type.Method minimum; equal/longer symbols still require the full pattern, so Acme.Workflow.Process.Start does NOT match); the lightweight extractor uses it for external nodes only (first-party declarations keep strict MatchSink, so a first-party Process.Start look-alike is never flagged), and using-alias receivers (SD.Process.Start) are expanded to their qualified form at parse time (pure syntax; also improves in-project resolution). using static deliberately not expanded (would be a guess). Pinned by SinkRegistryLexicalMatchTests (qualified + lexical + alias + 10 negatives) and lexical/qualified/alias/negative extractor fixtures in DotNetLightweightCallGraphExtractorTests |
| 2 | Python extractor emits zero edges; sink match targets definitions | Extraction/Python/PythonCallGraphExtractor.cs lines 363/406/448, 477–481, 94 | Implement call extraction against the import-alias table (the JS extractor is the working in-repo template) — FIXED 2026-06-10: two-pass lexical call extraction mirroring the JS template — per-file import-alias table (import X [as Y], from M import a [as b], parenthesized and relative froms) + project-wide first-party function index; in-module and cross-module direct/member calls become real edges (graph now clears the build-time stub-guard), and dangerous imported call TARGETS (e.g. os.system via import os/import os as o/from subprocess import run as launch) synthesize py:{pkg}/sink.{module}.{func} nodes — sink matching now applies to call targets (the definition-side match at l.94 is retained for the library-self-scan case, same shape as JS). Non-imported receivers, getattr reflection, and import * remain honest skips (no fabricated reachability). Pinned by PythonCallGraphExtractorTests (in/cross-module + member-call edges incl. stub-guard clearance, plain + aliased import sink nodes with CmdExec category and caller→sink edges, negative no-edge/no-sink fixture) |
| 3 | Triplicate SinkCategory/EntrypointType enums with divergent members | Contracts vs Explanation/PathExplanationModels.cs vs Stack/ReachabilityStack.cs | Converge on StellaOps.Scanner.Contracts; type-alias or map at the witness boundary — FIXED 2026-06-10: duplicates removed, Reachability copies are now using-aliases of the Contracts enums; member sets reconciled append-only (FileAccess/NetworkClient/Other added to SinkCategory; GraphQlResolver/PublicApi/Main/Timer/Constructor/StaticInitializer/TestMethod added to EntrypointType; compact-v2 integer wire values pinned by test); canonical wire-name helper SinkCategoryWire added and the lossy PostgresReachabilityDriftResultRepository fallback (_ => CmdExec) replaced; lossless Scanner→witness→back round-trip proven by SinkCategoryWitnessRoundTripTests |
| 4 | Symbol extractor misses Go vulndb imports[].symbols and RustSec affects.functions | Concelier.Core/Signals/IAffectedSymbolProvider.cs lines 589–649 | Descend the two known nested shapes (provenance-tagged per shape); add fixture tests from real OSV entries; backfill via re-upsert — FIXED 2026-06-10: OsvAffectedSymbolExtractor now descends Go vulndb imports[].{path, symbols[]} (import path → Module; the documented Type.Method form becomes a Method symbol with receiver ClassName, bare names stay Function — CanonicalId round-trips losslessly either way) and RustSec affects.functions (fully-qualified Rust path split at the last :: into Module/Symbol, also lossless under CanonicalId); AffectedSymbolProvenance gained an optional ExtractionShape field and every extracted symbol — nested AND flat — is now tagged with its JSON shape path (ecosystem_specific.imports[].symbols, ecosystem_specific.affects.functions, {container}.{field} for flat arrays). Non-object ecosystem_specific/database_specific values are now guarded instead of throwing. Pinned by OsvAffectedSymbolExtractorTests (fixtures from the real GO-2021-0113, GO-2022-0969, RUSTSEC-2020-0071 entry shapes + empty/malformed negatives + same-input determinism). Backfill DONE 2026-06-11 (sprint 20260610_002 BACKFILL): PostgresAffectedSymbolBackfillService (src/Concelier/__Libraries/StellaOps.Concelier.Persistence/Postgres/Repositories/) is the idempotent, offline re-upsert sweep — per tenant it keyset-pages vuln.advisory_observations (ordinal observation_id order, deterministic), rehydrates each observation from the stored observation_json via the SAME deserializer live lookups use, re-runs IAffectedSymbolExtractor.ExtractAsync with exactly the UpsertAsync inputs/provenance (no upstream fetch), and calls ReplaceObservationSymbolsAsync ONLY when the re-extraction differs from the stored projection (multiset compare over every persisted field except ExtractedAt), so a repeated sweep leaves existing rows byte-identical (ids and timestamps included); per-observation fault isolation (a malformed stored payload is counted+skipped, never aborts the sweep; infra failures abort). Operator surface: the signals:backfill-affected-symbols job (AffectedSymbolBackfillJob, registered cron-less in JobRegistrationExtensions — trigger via the jobs API with optional tenant/pageSize parameters). Pinned by PostgresAffectedSymbolBackfillServiceTests (Testcontainers Postgres: pre-fix-shaped observation with EMPTY projection → nested-shape symbols populated; second sweep idempotent with raw rows byte-identical; tenant filter + page-size-1 keyset paging + already-correct projection untouched; malformed-payload skip) |
4. The two sink semantics — name them and assign roles
The codebase currently conflates two meanings of “sink.” The strategy splits them explicitly:
| Capability sinks | Vulnerability sinks | |
|---|---|---|
| Definition | A call into an API with dangerous capability (CmdExec, UnsafeDeser, Ssrf, …) | The specific functions an advisory says are vulnerable (JndiLookup.lookup, lodash.template) |
| Source | Curated catalogs (today: SinkRegistry + per-language matchers) | Advisory symbol data, patch diffs, curated bundles |
| Question answered | “What could an attacker do if they control this path?” | “Is CVE-X exploitable in this image?” |
| False-positive cost | Low (annotation, scoring weight) | Catastrophic if wrong in either direction (gates releases, signs VEX) |
| Stella role (this strategy) | Path annotation + blast-radius grading + EWS input + drift signal. Never a “vulnerable” claim by itself. | The primary reachability product. Keys every verdict, witness, VEX draft, and policy gate. |
This split resolves the strategic confusion cleanly: the existing matchers/catalogs are kept (fixed per §3.5) but repositioned; a reachable CVE path whose terminal frames also carry CmdExec capability is presented as “reachable AND ends in process execution” — a severity multiplier no competitor expresses as signed evidence. Capability-sink reachability alone (no CVE) surfaces as an attack-surface inventory view (and powers ReachabilityDrift’s “newly reachable dangerous capability” alerts), clearly labeled as informational, never as findings.
5. Approaches considered (trade-offs under Stella’s constraints)
Constraints recap: offline/air-gap (no SaaS calls, no model APIs at runtime), determinism (same inputs → same verdict bytes; TimeProvider injection, ordinal ordering everywhere), attestability (DSSE/referrer-grade evidence), multi-language, image-AND-source inputs, on-prem compute budgets.
| Approach | Precision / Recall | FP cost | Offline | Deterministic | Verdict |
|---|---|---|---|---|---|
| A. Rule-based capability sink catalogs (exists) | Low precision as a vuln signal (flags Path.Combine, logging.info, JSON.parse…); fine as annotation | High if presented as findings; near-zero as annotation | Yes | Yes | Keep, demote to annotation + scoring; externalize as signed data packs (§7.4) |
| B. Vulnerability-driven sinks (CVE→symbol; plumbing exists) | High precision where symbol data exists; recall bounded by data coverage | Low — claims are scoped to a real CVE and a real path | Yes (symbols ride Concelier feeds / offline bundles) | Yes | Adopt as primary. Close the data gap with the three-source ladder (§6.1) |
| C. Static taint/dataflow (source→sanitizer→sink) | Highest theoretical precision for injection classes | Very high engineering + per-language maintenance; solver-style analyses are hard to keep bit-deterministic and bounded; duplicates CodeQL/Semgrep [knowledge] | Yes | Hard | Reject as core. The existing lightweight ersatz — GuardDetector + Gates/ multipliers + CallEdgeExplanationType.TaintGate — already captures the cheap 80% (path conditionality) without a solver. Operators who want taint depth run CodeQL and can feed results in as an external evidence source (future, D8) |
| D. Entrypoint→sink graph reachability (exists: BFS, explicit sinks, deterministic path selection) | Precision = extractor precision; honest over/under-approximation controls via edge confidence | Managed by confidence on edges | Yes | Yes (verified design) | Keep as the L1 engine. Invest in extractor precision only where it changes verdicts (§7.2) |
| E. Binary/loader layer (L2; model exists, engine partial) | Decisive negative evidence (symbol not linked/not present ⇒ unreachable with high confidence); decisive presence evidence for native | Low — it’s a linker fact, not a heuristic | Yes | Yes | Build as the native-stack lane and as the highest-quality “absence” prover (§7, Phase 4). Unify the two L2 semantics (loader-resolution vs Ghidra patch-presence) under one layer with two evidence kinds |
| F. Runtime confirmation (eBPF collectors exist, default-off) | Converts likely → proven (observed) and strengthens not observed over time; never proves absence alone | Low (only upgrades/never downgrades static claims) | Yes (on-cluster) | Recording is inherently non-deterministic; witnesses of recordings are deterministic artifacts | Opt-in lane, after the static spine. Primitive generation is modeled (ObservationType, claim IDs, RuntimeWitnessGenerator); production static/runtime matching and the Static → Confirmed transition are not composed yet. |
| G. Hybrid layered confidence (stack + lattice exist) | n/a — the integration layer | n/a | Yes | Yes | Adopt: one verdict vocabulary across Scanner stack verdicts, Signals buckets, Policy gates (§7.3) |
| H. LLM-assisted sink/path triage | n/a | Violates determinism + offline at runtime | No (runtime) | No | Reject at verdict time. Permissible only as offline catalog authoring aid whose output is reviewed, signed data (never in the scan path) |
6. Competitive frame and differentiation [knowledge section]
How the field frames reachability, from training knowledge (no fetches; treat names/feature details as directional):
- Endor Labs: call-graph reachability over dependencies (“function-level reachability”), strong multi-language program analysis, annotated vuln DB with affected functions. SaaS-first; analysis is a cloud service; evidence is a UI/report, not a customer-verifiable signed artifact.
- Snyk: “reachable vulnerabilities” via call graphs + a proprietary CVE→function dataset derived largely from fix-commit/patch analysis (the approach
VulnSurfacesmirrors). SaaS; limited language set for reachability; no offline posture. - Semgrep / CodeQL: rule/query-based taint and dataflow — the capability sink world. Excellent at pattern depth in source; not vuln-keyed reachability over images, not signed evidence, CodeQL licensing hostile to embedding [knowledge].
- Socket: supply-chain behavior signals (capability detection in packages — “this package spawns processes”) — closer to Stella’s capability annotation than to reachability proofs.
- OpenSSF ecosystem: OSV
ecosystem_specific.imports[].symbols+govulncheckis the open exemplar of symbol-level reachability (Go-only); VEX (OpenVEX/CycloneDX VEX) is the standard Stella’s verdicts should emit into —VexStatusDetermineralready maps verdicts to VEX statuses with justification categories. - Oligo/Sysdig-class runtime tools: eBPF “loaded/executed in production” signals — Stella’s L3/runtime lane equivalent, but without a static spine or signed replay.
Stella’s differentiation — lean into all four, they compound:
- Offline-first: the entire loop (advisory symbols → graph → verdict → witness → policy gate) runs inside the customer boundary; symbol data ships in Concelier feeds and offline kits. No competitor’s reachability works disconnected. [knowledge]
- Deterministic + replayable: verdicts are pure functions of CAS-addressed inputs (
graph_hash, symbol-set hash, policy digest);POST /api/slices/replayalready exists as a contract. “Re-run it yourself and get the same bytes” is an auditor-grade claim no probabilistic SaaS makes. - Signed, image-bound evidence: DSSE witnesses attached as OCI referrers to the image digest, flowing into EvidenceLocker and release decisions. “Reachable — here is the cryptographically-attested call path” / “suppressed — here is the signed coverage statement.”
- Release-gate integration: reachability is not a report; it is an input to the policy-gated promotion Stella already owns end-to-end (the 409-blocking deploy gate is live). Reachability changes deploy decisions, with evidence persisted per decision.
The honest weakness to own: Stella will not match Endor/CodeQL analyzer depth per-language for years. The strategy therefore anchors precision in data (symbols, patch diffs, loader facts) rather than analysis cleverness, and makes honesty itself (lattice + coverage statements) the differentiator a regulated buyer values more than recall.
7. Recommended strategy
7.0 North star
For each (image digest, CVE, purl): a deterministic verdict from the lattice below, with a mandatory evidence object, computed offline, signed, attached, and consumed by Policy.
| Public verdict | Minimum evidence required (machine-checked before signing) | Maps from |
|---|---|---|
reachable:proven | ≥1 PathWitness whose path exists in the referenced graph_hash, entry node is a classified entrypoint, terminal node symbol-matches the CVE symbol set (match rule + symbol provenance recorded); runtime-confirmed witnesses upgrade strength | Stack Exploitable/LikelyExploitable with L1 path |
reachable:likely | Package-API-level path (entry → vulnerable package public API) when function-level symbols are unavailable; witness carries `surface=package-symbols | heuristic` source + confidence |
not-observed | Signed coverage statement: graph hash, entrypoint count, % nodes resolved, edge-confidence distribution, symbol-match attempt log, analysis limits (depth/paths), known blind spots (reflection/dynamic edges encountered) | Stack Unreachable / tier Unreachable — renamed publicly: the analyzer observed no path, which is not a proof of absence unless L2 adds linker facts |
unknown | Reason code (no symbols for CVE; no graph for language; graph below stub-guard; …) | Stack Unknown / tier Present |
Hard rules: (1) never emit reachable:proven without a verifiable witness; (2) never emit plain “unreachable” — not-observed + coverage, upgradeable to not-present only by L2 linker/symbol-table facts (the one true absence proof); (3) every verdict object embeds the input digests so replay is closed under content addressing.
IMPLEMENTED 2026-06-10 (D4 canonical contract — sprint 20260610_002 LATTICE, partial):
PublicReachabilityVerdict(src/Scanner/__Libraries/StellaOps.Scanner.Contracts/PublicReachabilityVerdict.cs) carries exactly the wire namesreachable:proven/reachable:likely/not-observed/unknown+ reservednot-present. The hard rules are machine-checked structurally, not test-only: the wire helper’s static initializer refuses any member whose wire name containsunreachable/safe(every use of the type would throw),TryParserejects the forbidden vocabulary and member-name spellings, andnot-presentis non-emittable (IsEmittable=false;EnsureEmittableand verdict-object construction throw) until an L2 engine exists. The per-(image digest, CVE, purl) verdict object (PublicReachabilityVerdictObject.Create) enforces the minimum-evidence column before the object can exist (hence before signing):proven⇒ ≥1PathWitnessReferencebound to the inputgraph_hash;likely⇒ witness + symbol source tier (surface|package-symbols|heuristic|curated) + confidence ∈ [0,1];not-observed⇒ReachabilityCoverageStatement(graph hash, entrypoint count, % nodes resolved, edge-confidence distribution, symbol-match attempt log, depth/path limits, known blind spots) over the same graph hash, and no contradictory path witnesses;unknown⇒ reason code (PublicReachabilityVerdictReasons). Rule 3 is enforced viaReachabilityInputDigests; canonical bytes are deterministic (collections ordinal-normalized at construction;ToCanonicalJsonBytes). Mapping judgment call (the “Maps from” column made total): stackPossiblyExploitablealso maps toreachable:provenbecause the evaluator truth table (ReachabilityStackEvaluator.DeriveVerdict) only yields any reachable verdict whenLayer1.IsReachable— an L1 path exists for all three; the rule-1 witness gate still applies, andPublicReachabilityVerdictMapper.FromStackVerdict(verdict, hasVerifiableWitness:false)downgrades a pathless would-beproventounknown(witness_unavailable), never to a weaker/stronger reachability claim. Pinned byPublicReachabilityVerdictLatticeTests/PublicReachabilityVerdictObjectTests/ReachabilityCoverageStatementTests(Contracts.Tests, 46) andPublicReachabilityVerdictMapperTests(Reachability.Tests, 18, exhaustive over both internal vocabularies). Signals rebase landed 2026-06-11 (LATTICE 3a), the CLI rebase landed 2026-06-11 (LATTICE 3b —ReachabilityVerdictDisplayfold), and the Console FE rebase landed 2026-06-11 (LATTICE 3c —normalizeFindingReachabilityStateboundary fold, §7.3): all three D9 surfaces now speak the lattice.
7.1 Phasing (each phase ships value alone)
Phase 0 — Fix the floor (small, surgical; mostly src/Scanner, one Concelier file). The four defects of §3.5 + wire the dormant defaults: a real ICallGraphSnapshotProvider backed by the Worker’s existing snapshot output, and the Concelier→Scanner symbol bridge (ICveSymbolMappingService implementation that queries/syncs the AffectedSymbol projection — or replaces the unpopulated Postgres mapping table as the read path). Exit criterion: a Go or .NET fixture scan produces a non-zero sink count and one end-to-end ReachabilityStack with a real L1 verdict from CVE-derived sinks. — WIRING LANDED 2026-06-10: WorkerCallGraphSnapshotProvider (registered by AddReachabilityEvidence, fed by ReachabilityInputStageExecutor per image digest) replaces the null snapshot provider, and AffectedSymbolCveSymbolMappingService bridges the AffectedSymbol projection into the mapping-service read path (merge semantics: curated store rows > projection facts; deterministic confidence-DESC/name-ASC ordering; tenant via ReachabilitySymbolBridgeOptions, default default). Worker hosts get the projection over HTTP (ConcelierHttpAffectedSymbolProvider, gated on Reachability.AdvisoryBaseUrl; degrades to empty-set → honest Unknown on outage). Pinned by AffectedSymbolCveSymbolMappingServiceTests (populated projection → CVE sinks, purl-version-insensitive match, module-granularity exclusion, curated-wins merge, outage fallback, determinism) and WorkerCallGraphSnapshotProviderTests (digest keying, persisted-snapshot replay on forceRecompute, corrupt/missing-file fallback). EXIT CRITERION MET (in-process) 2026-06-10: Phase0CveSinkFloorEndToEndTests (Scanner.Reachability.Tests/Evidence) is the network-free deterministic integration proof of the floor — raw Go-vulndb-shaped OSV JSON → real OsvAffectedSymbolExtractor (defect-#4 nested imports[].symbols shape) → AffectedSymbol projection → the bridge → real ReachabilityEvidenceJobExecutor (real BFS ReachabilityAnalyzer with CVE-derived explicit sinks over a 5-node Go fixture graph with SinkIds EMPTY, real WorkerCallGraphSnapshotProvider, real ReachabilityStackEvaluator) → non-zero sink count (2 canonical ids) + end-to-end ReachabilityStack verdict PossiblyExploitable(L1 yes / L2 unknown / L3 unknown truth-table row) with the exact entry→mid→sink path; a second CVE symbol present in the graph but called only from a non-entrypoint stays pathless (BFS is entrypoint-rooted, selective); plus a real PathWitnessBuilder PathWitness whose path_hash is independently recomputed in the test from the witness path + purl and whose path is edge-closure-verified against the snapshot; plus byte-identical ResultDigest/stack determinism across two fresh runs and the honest-Unknown inverse for a symbol-less CVE. What remains owed for Phase 1 is the live variant only: the same loop fired by a real image scan on a running stack.
Phase 1 — One language, the whole truth (Go). Go is the only lane where every layer is simultaneously strongest: SSA-grade extractor (toolchain tier), the industry’s best symbol corpus (Go vulndb imports[].symbols [knowledge]), static binaries for future L2. Ship: symbol intake (fixed shape) → mapping bridge → explicit-sink BFS → PathWitness DSSE → pruned-subgraph referrer (§8) → Signals fact → Policy gate consumes verdict tier → Console “why is this reachable” path rendering (PathRenderer/Explanation exist). This is the demo and the template. Exit criterion: a known-reachable fixture (e.g., vulnerable handler calling an affected vulndb symbol) gates a release with a verifiable witness, and the inverse fixture produces not-observed + coverage statement that replays byte-identically.
Phase 2 — Symbol-intake hardening + offline kit (Concelier-led). RustSec nested shape; GHSA/NVD remain range-only (accept — ladder rung 3 covers them); backfill job re-extracting symbols from stored observations (raw JSON is already persisted, so this is a re-upsert sweep) — LANDED 2026-06-11 (sprint 20260610_002 BACKFILL: PostgresAffectedSymbolBackfillService + the operator-triggered signals:backfill-affected-symbols job, see §3.5 #4 for the full contract); export AffectedSymbolSets into the offline kit bundle format (cve-symbol-mapping.md §5) — LANDED 2026-06-12 (sprint SPRINT_20260612_012 AG1: AffectedSymbolOfflineBundleExporter exports the per-tenant projection into bundle schema v2 — deterministic CanonJson canonical bytes, no timestamps, pinned sha256 digest, optional DSSE signing on the catalog-pack/FileKeyProvider pattern, per-source license-allowlist gate defaulting to osv/ghsa/nvd; the symmetric import is BundleAffectedSymbolProvider, an IAffectedSymbolProvider over the bundle file, so a disconnected host activates the SAME AffectedSymbolCveSymbolMappingService read path a connected host uses. In-process proof at the provider seam: AffectedSymbolOfflineBundleTests (Concelier.Core.Tests/Signals, 7 tests — export/round-trip/byte-faithful re-import/allowlist/DSSE-tamper). UPSTREAM advisory symbols only — derived patch-diff surfaces stay counsel-gated, §7.5 row 4. The disconnected-verdict forcing-function proof LANDED 2026-06-12 (AGV, in-process): AirgapOfflineBundleReachabilityEndToEndTests (Scanner.Reachability.Tests/Evidence, built-exe run Total: 6, Failed: 0; Evidence namespace Total: 90, Failed: 0) proves an air-gapped host with ONLY the kit bundle closes the whole loop — frozen OSV fixtures → AG1 export → DSSE-signed bundle file verified offline fail-closed against a local trusted-keys dir → BundleAffectedSymbolProvider as the sole symbol source (NO Concelier projection, NO HTTP) → the REAL AffectedSymbolCveSymbolMappingService bridge + REAL ReachabilityEvidenceJobExecutor over a fixture Go graph → reachable:proven with a PW-SCN-003 witness whose path hash is independently recomputed in the test; the disconnected configuration’s persisted verdict objects are BYTE-IDENTICAL to the connected (live projection) configuration’s — bundles fully stand in for online feeds; honest inverses pinned (CVE absent from the bundle ⇒ unknown/no_symbols_for_cve, zero witnesses, nothing fabricated; bundled-but-unreachable symbol ⇒ not-observed with the mandatory coverage statement); two fully fresh runs replay byte-identically (pinned bundle digest sha256:7e088a20…054d0f + verdict bytes). Still owed: the LIVE kit-import variant (a real disconnected stack importing a kit and gating a release) rides the standing §11 live full-loop remainders, and the curated-precedence case rides the AG2 loader (sprint SPRINT_20260612_012); derived patch-diff surfaces remain EXCLUDED from kits — counsel-gated per §7.5 row 4 / AG3 BLOCKED); a curated Top-N bundle (the ~200–500 CVEs that dominate container scan results [knowledge] — KEV-weighted, hand-verified symbols) as the deterministic backstop for ecosystems with no upstream symbols. Curation is feed-time work, shippable + signable — exactly Concelier’s existing operating model. FORMAT + LOADER + curated-wins precedence LANDED 2026-06-12/13 (sprint SPRINT_20260612_012 AG2): schema stellaops.curated-affected-symbols/v1 (cve-symbol-mapping.md §5.4) with mandatory per-item curator provenance (curator/method/ISO curatedAt) and mandatory per-entry confidence; CuratedAffectedSymbolBundleValidator fail-closes the whole bundle on wrong schema / blank cveId / missing provenance / non-ISO date / unknown symbolType / out-of-range confidence / duplicate canonical id; CuratedAffectedSymbolProvider loads + materializes curated facts (provenance source curated); LadderAffectedSymbolProvider overlays curated over an upstream provider with curated-wins on canonical-id collision (closing the §6.1 ladder); optional offline DSSE (distinct payload type, fail-closed). In-process proof: CuratedAffectedSymbolBundleTests (Concelier.Core.Tests/Signals, built-exe run Total: 12, Failed: 0 — example-load, all validation rejections, malformed-file fail-closed, curated-beats-upstream-on-collision, deterministic ordinal merge, signed round-trip + tamper/missing-envelope fail-closed, pinned canonical digest sha256:270d3f71…58d4aa5). The actual curation of real KEV-weighted CVEs is deliberately NOT done here — it stays OPEN feed-time human work; the shipped example uses clearly-synthetic CVE-2099-* ids. The deliverable is that a curator file ships/validates/loads/wins precedence, not that any real CVE is covered.
Phase 3 — Scale by data fan-out (per-language agents). With the Go template proven, per-language work becomes data + one normalizer (§7.2): .NET (dogfood — Stella scans itself; lightweight extractor + suffix matching + package-symbols fallback), Java (bytecode extractor already marks sink targets; Maven surfaces next), JS/TS (import-alias machinery exists), Python (after defect #2). Symbol-format normalizers reconcile advisory symbol naming with extractor node symbols per ecosystem (the CanonicalId ↔ node-symbol match rule — each language’s rule is a documented, testable contract recorded in the witness).
Phase 4 — The moat: patch-diff surface factory + binary/loader L2. (a) Productionize VulnSurfaces as a feed-time factory: a Concelier-adjacent job (network allowed where feeds are allowed; air-gapped sites consume the exported surfaces in offline kits) that, on advisory ingest with a fix version, downloads vuln+fixed, diffs methods, extracts triggers, stores + signs the surface. This converts Stella from a consumer of scarce symbol data into a producer — the data moat Snyk built, but self-hostable and exportable [knowledge]. (b) Implement the loader-resolution L2 over the existing ELF/PE models + BinaryCallGraphExtractor capability for native images, unifying with the Ghidra patch-presence verifier as two evidence kinds within one layer; L2 facts upgrade not-observed → not-present. (a) and (b) are independent; (b) may be pulled earlier for native-heavy customers — see D6.
Phase 4(a) MAPPING-BRIDGE LANDED 2026-06-12 (sprint
SPRINT_20260612_002): computed surfaces are now a live read source forICveSymbolMappingServiceviaVulnSurfaceCveSymbolMappingService(Scanner.Reachability/Services) — a read-side decorator OUTERMOST in the mapping chain (store → Concelier projection bridge → surface bridge; inner wins on canonical-id collision, so the ladder is curated > upstream symbols > patch-diff, and a surface always beats a range-only advisory). Surface-tier sinks project asMappingSource.PatchAnalysis(verdict-composer labelsurface, function-level ⇒ proven-capable) with confidence capped at 0.8; thepackage-symbolstier carries the establishedstella://vuln-surfaces/package-symbols/evidence-URI convention (⇒reachable:likelydowngrade); the emptyheuristictier projects nothing. MethodKeys fold to the{module}::{Type}.{method}CanonicalId convention viaMethodKeyCanonicalIdFolder(VulnSurfaces/MethodKeys; nuget fold contract-tested through the realDotNetSymbolNormalizer; maven/npm/pypi folds remain best-effort from emitted MethodKeys, with LANE-REG-TESTS pinning the registered lanes’ deterministic sink keys).IVulnSurfaceService.HasSurfaceAsync(CVE-only) keeps the Worker’sHasMappingAsyncevidence-job gate open for surface-only CVEs. Namespace-twin resolved by routing: the placeholderStellaOps.Scanner.Reachability.IVulnSurfaceService(SubgraphExtractor PoE seam, Worker-nulled) is annotated as NOT the surface source; surfaces reach scans exclusively through the mapping read path. Chosen over the write-through shape so derived rows never enter thecve_symbol_mappingsstore the offline-bundle flows read (legal boundary: internal use only, kit export counsel-gated). Still owed: the feed-time factory trigger (FEED-TIME-TRIGGER) and the host registration of the VulnSurfaces read path (the decorator joins the DI chain only when a host registersStellaOps.Scanner.VulnSurfaces.Services.IVulnSurfaceService).
Phase 4(a) FEED-TIME-TRIGGER LANDED 2026-06-12 (sprint
SPRINT_20260612_002): the factory is wired end-to-end as ingest-hook → queue table → Scanner-side executor. Seam:PostgresAdvisoryObservationStore.UpsertAsync(the same hook that populates the affected-symbol projection) extracts (cve, ecosystem, package, vulnVersion, fixedVersion) tuples from OSVaffected[]entries carrying afixedrange event (VulnSurfaceRequestExtractor, Concelier.Persistence) and enqueues them intovuln.vuln_surface_requests(Concelier migration 007; unique-keyed, so re-ingest dedupes at the queue). The contract seam is the TABLE — no Concelier↔Scanner csproj coupling. Scanner side (StellaOps.Scanner.VulnSurfaces/Feed/):VulnSurfaceFeedWorkerclaims rows (FOR UPDATE SKIP LOCKED),VulnSurfaceFeedProcessorrunsIVulnSurfaceBuilder(the patch-diff engine), persists the surface tenant-global (Guid.Empty, matching the read path), records a deterministic canonical digest (VulnSurfaceDsse.CanonicalPayload— ordinal sink ordering, no timestamps; recompute ⇒ same digest) invuln_surfaces.attestation_digest, and DSSE-signs the canonical payload with a local ECDSA P-256 PEM key (the catalog-pack FileKeyProvider pattern, §7.4 — fully offline, envelope rides side-by-side in the configured attestation directory). Opt-in, default OFF — flagVulnSurfaces:FeedTrigger(Scanner Worker config): with the flag offAddVulnSurfaceFeedTriggerregisters nothing (pinned by test). Fail-open: download failures persistfailed/package_not_foundrows (newstatus/errorcolumns, Scanner.Storage migration 008) — never a fabricated surface, never a blocked ingest; failed rows do not block a later retry. Every computation logs the registry endpoint used (per-ecosystem ToS audit trail, sprint Decisions & Risks). Enabling the trigger also registers the realIVulnSurfaceService, which activates the MAPPING-BRIDGE decorator — closing the host-registration gap noted above. Test posture (web policy §2.8): the network download stays a production-only path; the suite runs the REAL Cecil fingerprinter + diff engine over frozen Mono.Cecil-synthesized vuln/fixed pairs with a faked downloader. Still owed (recorded, not ticked): the LIVE feed-time forcing-function proof on a running stack (real Concelier ingest → queue → worker → surface → verdict), alongside the §11 live full-loop rows; kit export of computed surfaces stays counsel-gated (KIT-EXPORT BLOCKED).
Feed-trigger opt-in reconciliation 2026-06-12: the same
VulnSurfaces:FeedTrigger:Enabledflag now gates Concelier request emission (VulnSurfaceRequestOptions) as well as Scanner consumption (VulnSurfaceFeedOptions). With the flag off, advisory ingest does not mutatevuln.vuln_surface_requests; with it on, Concelier writes the dedupe-keyed queue rows and Scanner consumes them only when its feed worker is enabled.
Phase 4(a) LANE-REG-TESTS LANDED 2026-06-12 (sprint
SPRINT_20260612_002):AddVulnSurfacesnow registers the built Maven/npm/PyPI downloaders, fingerprinters, and internal graph builders alongside NuGet/Cecil. Frozen fixtures pin the previously dark lanes without network access: Maven parses a synthesized real.classfile throughJavaBytecodeFingerprinter; npm and PyPI pin positive extraction plus honest-miss negatives for regex-only syntax gaps; each lane has a vuln-vs-fixed fixture pair proving deterministic expected sinks across two fresh builds. Confidence is now fidelity-aware: bytecode/Cecil lanes retain multiplier1.0; regex lanes (npm/PyPI) carry multiplier0.65, so lexical surfaces cannot outrank bytecode/Cecil surfaces before the downstream curated-row cap is applied. LANE-GAPS landed 2026-06-12:gem/rubygemsandcomposer/packagistnow have downloader and fingerprinter aliases, Ruby/PHP MethodKey folding through the realRubySymbolNormalizer/PhpSymbolNormalizer, and frozen registry extraction plus vuln/fixed diff fixtures. Full-chain VERIFY fan-out for these newly registered lanes remains owed. LANE-AST-UPGRADE landed 2026-06-12: the npm fingerprinter is no longer regex —JavaScriptMethodFingerprinternow parses a real Esprima AST (Esprima 3.0.5, BSD-3-Clause, already a Scanner production dependency viaAnalyzers.Lang.Node; offline cache, no new download) covering ESM/CJS exports, single-param arrows, class-field arrows, and nested-paren defaults the regexes missed; its fidelity multiplier rises to0.85(AST tier — above0.65lexical, below1.0bytecode; unparseable files are skipped, never guessed at). The Java class-file parser is additionally de-risked against a REALjavac -g --release 21frozen fixture (JavaBytecodeRealClassFileTests: Long/Double double-slot constants, MethodHandle/MethodType/InvokeDynamic tags, exception tables, Code sub-attributes, synthetic-lambda exclusion — the ByteReader-precedent class-of-bug is now pinned; no desync found). PyPI stays at the0.65regex tier: a real Python AST parse is BLOCKED offline — no license-compatible Python parser exists in the local NuGet cache and downloading is forbidden (web policy 2.8 / air-gap posture); see the sprint’s Decisions log.
Phase 4(a) LANE-PARSER-HANDROLL landed 2026-06-13 (sprint
SPRINT_20260612_002, operator-directed): the0.65regex tier is retired from production lanes.PythonAstFingerprinter,RubyMethodFingerprinter, andPhpMethodFingerprinterare now hand-rolled tokenizer-parsers (the air-gap-correct path when no license-compatible parser exists offline — boundaries parsed by hand, zero new dependencies): Python masks all string/comment forms and parses def/class scopes by indentation with logical-line joining; Ruby masks heredocs/%-literals/regex-literals and balances def/end blocks with modifier-keyword discipline (class << self, endless defs included); PHP gains heredoc/nowdoc masking, balanced-paren parameters, and trait/interface/enum members. Fidelity ladder now: regex0.65(retired floor) < tokenizer-parser0.8(pypi/gem/composer) < AST0.85(npm/Esprima) < bytecode1.0(nuget/maven). Parser-tier hardening tests pin the previously-desyncing constructs (defs inside literals, heredoc keywords, nested-paren defaults) as positives while every honest-miss negative (lambda assignment,define_method, variable-bound closures) survives. Proof via built exes: full VulnSurfaces suite 121/121, Phase-4a chain fan-out re-proven over the new parsers 10/10.
Phase 4(a) VERIFY — starvation closure proven in-process 2026-06-12 (sprint
SPRINT_20260612_002):Phase4aSurfaceFactoryEndToEndTests(Scanner.Reachability.Tests/Normalization) pins the full chain on frozen fixtures with every component REAL: a range-only nuget CVE with NO surface yields an honestunknown(no_symbols_for_cve, zero witnesses); the SAME app + SAME CVE after the feed-time factory computes a surface (REALVulnSurfaceFeedProcessor→CecilMethodFingerprinter→MethodDiffEngineover Mono.Cecil-synthesized vuln/fixed pairs; the downloader is the only fake — web policy §2.8) resolves function-levelsurface-tier mappings through the MAPPING-BRIDGE, binds via the REALDotNetSymbolNormalizer(receiver-text tail rule), and the REAL evidence-job executor emitsreachable:provenwith a verifiable PW-SCN-003 witness — the computed surface is the single flipped input. Honest inverses pinned: a FAILED computation fabricates nothing (stillunknown); a computed surface whose sink is unreachable yieldsnot-observedwith coverage, never proven. Two fully fresh runs are byte-identical (surface digest + persisted verdict bytes). Phase-4a lane coverage today: nuget = full chain proven; maven/npm/pypi = registered and frozen-fixture hardened, with per-lane full-chain proof still left to the active VERIFY task; gem/rubygems and composer/packagist = downloader/fingerprinter/fold fixtures landed but still owe full-chain VERIFY fan-out; Go/Rust deliberately none (symbols ride upstream). Standing remainders: the LIVE feed-time proof (real Concelier ingest on a running stack, flag ON) is recorded alongside the §11 live full-loop rows, not ticked; KIT-EXPORT of derived surfaces into offline kits stays counsel-gated (§7.5 row 4; same derived-data-redistribution class asdocs/legal/decisions/trivy-db-builder-counsel-thread-airgap-export.md) — the “air-gapped sites consume the exported surfaces in offline kits” sentence in Phase 4(a) above is therefore NOT yet operative.
Approved-subset KIT-EXPORT loader/proof landed 2026-06-13 (sprint
SPRINT_20260612_013SURF-EXPORT-POLICY/SURF-EXPORT-FORMAT/SURF-EXPORT-LOADER/SURF-EXPORT-VERIFY): derived patch-diff surface export now has an explicit legal gate and separatestellaops.vuln-surface-export/v1format underStellaOps.Scanner.VulnSurfaces.Export. Only PyPI, RubyGems, NuGet, and Packagist rows can pass, and only when no package content/literals are present, package-license classification is OSS/free/permissive, registry endpoint + ToS snapshot + User-Agent/contact + rate-limit/Retry-After + audit-log controls are present, and the row carries the deterministic computation digest.npmandmavenrows are excluded by policy/loader with auditable reason codes until written registry permission or a commercial arrangement is recorded. Canonical bytes are timestamp-free and byte-stable; signed rows use DSSE over the canonical row payload and tamper verification fails closed. The loader verifies trusted DSSE signatures, canonical bundle bytes, row payload digests, and policy/provenance before exposing rows as a read-onlyIVulnSurfaceService; it does not write derived rows into the curated/upstream symbol store, and the read path still preserves curated > upstream advisory symbols > derived surface precedence. In-process disconnected proof:VulnSurfaceExportLoaderTests(10/10) plusVulnSurfaceCveSymbolMappingServiceTests.ApprovedExportBundleLoader_FeedsMappingReadPathAndVerdictInputDeterministicallyprove approved signed bundle -> loaded surface facts ->surfacemapping attribution -> deterministicreachable:provenverdict bytes, with npm/Maven/generic non-approved/tampered/unsigned/no-audit/no-provenance negatives fail-closed. The all-sixKIT-EXPORTrow in sprintSPRINT_20260612_002remains blocked because npm and Maven Central are still not approved.
npm + Maven Central kit export ABANDONED 2026-06-14 (operator decision) — supported set is the counsel-approved 4. The original
KIT-EXPORTframing (“all six ecosystems”) is closed by declaring npm and Maven Central explicitly out of scope for offline-kit surface export. Counsel left only one path for these two — written npm/GitHub confirmation that Stella-computed surfaces fall outside npm’s security-data redistribution clause, and a Sonatype written permission / commercial arrangement for Maven Central’s commercial-scanner-use restriction — and the operator has decided not to pursue that registry permission. The shipped, supported export scope is therefore exactly the four counsel-approved ecosystems PyPI, RubyGems, NuGet, Packagist (done inSPRINT_20260612_013); the exporter fail-closes npm/Maven (and every other unapproved ecosystem and every unknown/proprietary/custom/restricted package license) by construction — producer gateVulnSurfaceExportPolicy.Evaluate(Export/VulnSurfaceExportPolicy.cs:95-127) and loader gate (VulnSurfaceExportLoaderTests.cs:139-142, rejects signed-but-blocked rows). This is a permanent product decision, not a pending block; the per-ecosystem WHY and the single re-enable path (record written permission underdocs/legal/decisions/, then add toApprovedEcosystems) are documented indocs/modules/reach-graph/guides/cve-symbol-mapping.md§5.3 “Unsupported ecosystems: npm + Maven Central”. Internal-only use (mapping bridge / scan-time resolution) of npm/Maven surfaces is unaffected.
Phase 5 — Runtime lane + drift (opt-in). eBPF observation upgrading static claims via existing claim-id linking; ReachabilityDrift re-keyed on vulnerability sinks (“CVE-X became reachable between scan A and B because guard G was removed” — DriftCause machinery exists).
7.2 Generic vs per-language (so agents fan out on data, not logic)
Generic core (one implementation, language-blind): graph schema (CallGraphSnapshot/richgraph-v1), BFS + path selection + determinism, stack evaluator + lattice mapping, witness building/signing/verification, subgraph extraction + referrer serialization, coverage-statement computation, symbol-store bridge + match framework, gate multiplier model, Signals scoring, Policy/VEX emission.
Per-language (the fan-out surface):
- Extractor (exists ×8 — improve only when a fixture shows verdict-changing imprecision);
- Entrypoint catalog → data (patterns like
@app.route,MapGetlargely declarative); - Capability-sink catalog → data packs (§7.4);
- Symbol normalizer (advisory symbol ↔ graph symbol match rule) — the only genuinely per-language logic, small and contract-tested;
- Golden fixtures: one known-reachable CVE, one known-not-observed, one negative (dynamic dispatch must NOT produce a witness) — the existing Sprint-018/020 negative-test discipline generalized.
IMPLEMENTED 2026-06-11 (items 4+5, Go reference lane — sprint 20260610_002 LANG-GO): the normalizer contract is now code:
ISymbolNormalizer/SymbolNormalizerRegistry(src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Analysis/ISymbolNormalizer.cs— language-blind interface; rule id + version are part of the contract and ride witnesses) withGoSymbolNormalizer(Extraction/Go/GoSymbolNormalizer.cs, rulestellaops.go.symbol-normalizer@1.0.0) as the reference implementation the other lanes mirror.ReachabilityEvidenceJobExecutorapplies the registry before BFS soExplicitSinksreceives real node ids; an all-unresolved symbol set short-circuits to honestunknown/symbol_unresolved(never a guessed match).WitnessSink.match_rule(append-only wire field: rule id/version + canonical id + symbol source) records the binding per §7.0;ReachabilityCoverageStatementFactorycomposes the §7.0 coverage statement from the real snapshot + normalization attempt log. See §11 Go row for the match rule and golden-trio evidence. .NET joined 2026-06-11 (LANG-DOTNET):DotNetSymbolNormalizer(Extraction/DotNet/DotNetSymbolNormalizer.cs, rulestellaops.dotnet.symbol-normalizer@1.0.0) is the second registered rule — the hardest of the eight because .NET node ids are content hashes and lightweight external symbols are lexical receiver text; the rule therefore binds on nodeSymbolwith a strict first-party/external split (source-anchored nodes never tail-match). See §11 .NET row for the rule items and golden-trio evidence. Java joined 2026-06-11 (LANG-JAVA):JavaSymbolNormalizer(Extraction/Java/JavaSymbolNormalizer.cs, rulestellaops.java.symbol-normalizer@1.0.0) is the third registered rule — the most precise lexical lane of the eight because bytecode call targets are always fully qualified (constant-pool class + method + descriptor), so the rule needs no receiver-text tail: fully-qualified ordinal equality on node symbols (descriptor-insensitive, binds all overloads) plus a descriptor-precise node-id form pinning one overload; its golden trio was also the live forcing-function that exposed and fixed a parser-killing bug (JavaBytecodeAnalyzer.ByteReaderpassed by value — real class files never parsed; see §11 Java row). JavaScript/TypeScript joined 2026-06-11 (LANG-JS, Bun/Deno ride the lane):JsSymbolNormalizer(Extraction/JavaScript/JsSymbolNormalizer.cs, rulestellaops.js.symbol-normalizer@1.0.0) is the fourth registered rule — package-anchored: it never inspects node-id text (a first-party file namedlodash.jsorsink.tsis not confusable with a synthesized target); a node binds only when itsSymbol == {Package}.{name}for a bare npm specifierPackage— true exactly for import-verified synthesized call targets. The lane also gained the verdict-changing extractor piece (the Go-precedent): the JS extractor now synthesizesjs:{app}/external.{module}.{method}targets for ALL import-alias-verified calls, not only capability-catalog sinks — without it no advisory symbol outside the catalog could ever appear in the graph. See §11 JS row for the rule items and golden-trio evidence. Python joined 2026-06-11 (LANG-PYTHON):PythonSymbolNormalizer(Extraction/Python/PythonSymbolNormalizer.cs, rulestellaops.python.symbol-normalizer@1.0.0) is the fifth registered rule — package-anchored like JS but with NO class erasure: Python member calls preserve the dotted chain in the resolved import path, so Method canonicals refold the class INTO the module ({module}::{Class}.{method}→{module}.{Class}::{method}), and hyphenated PyPI distribution-name modules fold-→_(PEP 503; names differing beyond that, e.g. Pillow→PIL, honestly do not bind). The lane also gained the same verdict-changing extractor piece: the Python extractor now synthesizespy:{app}/external.{module}.{func}targets for import-verified non-catalog calls;getattr/__import__reflection,import *, non-imported receivers, and first-party modules remain honest skips. See §11 Python row for the rule items and golden-trio evidence. Rust joined 2026-06-11 (LANG-RUST):RustSymbolNormalizer(Extraction/Rust/RustSymbolNormalizer.cs, rulestellaops.rust.symbol-normalizer@1.0.0) is the sixth registered rule — package-anchored like JS/Python, binding only nodes whoseSymbol == {Package}::{name}for a valid Rust module-pathPackage(true exactly for the extractor’s extern-verified call targets and crate-root self-scan free functions); RustSec CanonicalIds reconstruct the publishedcrate::path::funclosslessly, so the rule needs no receiver-text guessing — Method dots refold to::, generics strip, and Cargo hyphen names fold to the extern-crate spelling on the node side. The lane’s verdict-changing extractor piece is the use-decl reconciliation the language note names: per-fileuse-alias tables expand call-chain roots before target synthesis (plus first-party declared resolution, bare-call edges, and sanitized sweeps), so a crate-localmod timeshadowing the advisory crate is resolved first-party exactly as rustc would — structurally unbindable. See §11 Rust row for the rule items and golden-trio evidence. Ruby joined 2026-06-11 (LANG-RUBY):RubySymbolNormalizer(Extraction/Ruby/RubySymbolNormalizer.cs, rulestellaops.ruby.symbol-normalizer@1.0.0) is the seventh registered rule — package-anchored like JS/Python/Rust, binding only nodes whoseSymbol == {Package}.{ConstChain}.{method}for a valid require-feature-pathPackage(true exactly for the extractor’s require-verified synthesized targets); the canonical module is the gem’s REQUIRE FEATURE PATH (erb::ERB.new,net/http::Net::HTTP.get), the rubysec#instance-method spelling and dot-spelled constant chains fold deterministically, and the flatglobal::form is matched whole. This lane was extractor-FIRST (§11: Ruby could not execute any part of the loop): the extractor gained the full two-pass sweep (per-file require tables, first-party class/top-level resolution where a shadowingclass ERBWINS over the require — the Rust precedent — catalog-sink + external target synthesis on call TARGETS, Sinatra route-block bodies, offset-preserving comment/string sanitization, reflection deny-list forsend/const_get/method_missingshapes), plus DI registration and Worker scan-side routing (Gemfile/Gemfile.lock/config.ru). See §11 Ruby row for the rule items and golden-trio evidence. PHP joined 2026-06-11 (LANG-PHP — the eighth and final lane):PhpSymbolNormalizer(Extraction/Php/PhpSymbolNormalizer.cs, rulestellaops.php.symbol-normalizer@1.0.0) — package-anchored like JS/Python/Rust/Ruby, binding only nodes whoseSymbol == {Package}.{Class}.{method}/{Package}.{function}for a valid PSR-4 StudlyCaps namespace-pathPackage(true exactly for the extractor’s use-verified synthesized targets; first-party symbols keep PHP’s{Ns}\{Class}::{method}spelling and never contain a dot, so the parse is structurally exclusive); the canonical module is the class’s PSR-4 NAMESPACE, never the Packagist package name, the natural advisory FQN spelling (GuzzleHttp\Psr7\Message::parseMessage) folds deterministically into the Method form, and the flatglobal::form is matched whole. This lane was extractor-FIRST like Ruby (§11: PHP could not execute any part of the loop): the extractor gained the full two-pass sweep (namespace +use/use functiontables, first-party-wins resolution where a shadowingnamespace GuzzleHttp\Psr7; class Messagebeats the use import, catalog-sink + external target synthesis on call TARGETS, core-class/core-function sinks kept structurally non-bindable, comment/string/heredoc sanitization with PHP 8#[attributes preserved, and a definition-side sink match narrowed to class-scoped rules so a first-partyfunction exec()is never flagged as PHP’s coreexec), plus DI registration and Worker scan-side routing (composer.json/composer.lock). See §11 PHP row for the rule items and golden-trio evidence.
7.3 One verdict vocabulary (convergence requirement)
Today three vocabularies coexist: stack verdicts (Exploitable/…), ReachabilityConfidenceTier (Confirmed/Likely/Present/Unreachable/Unknown), Signals buckets (entrypoint/direct/runtime/unknown/unreachable + weights). Adopt the §7.0 lattice as the public contract; keep stack verdicts as the internal evaluator output mapped 1:1; rebase Signals buckets to consume the verdict + evidence tier rather than recomputing semantics (lattice v1 design in docs/modules/reach-graph/guides/lattice.md §9 is the convergence vehicle). One vocabulary in Console, CLI, VEX, policy expressions.
PARTIALLY IMPLEMENTED 2026-06-10 (D9 — sprint 20260610_002 LATTICE): the single public contract now exists (
PublicReachabilityVerdict+ verdict object + coverage statement inStellaOps.Scanner.Contracts, see §7.0 note) and both internal vocabularies map onto it totally and deterministically viaPublicReachabilityVerdictMapper(src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Stack/PublicReachabilityVerdictMapper.cs): stackExploitable/LikelyExploitable/PossiblyExploitable→reachable:proven(witness-gated),Unreachable→not-observed,Unknown→unknown; tierConfirmed→proven,Likely→likely,Present→unknown,Unreachable→not-observed,Unknown→unknown— exhaustively truth-table-pinned, and no internal verdict can surface asnot-presentor as a forbiddenunreachable/safestring (guard test over every projection). Stack verdicts and tiers remain internal-only per this section.CONVERGENCE AUDIT 2026-06-11 (D9 — sprint 20260610_002 CONVERGE-VERIFY): repo-wide verification of who speaks what. Lattice-only (mapping layers only): Scanner stack/tier (the mapper above); the policy gate on BOTH layers —
ReachabilityVerdictGateconsumes only the verdict object (its decision DTO exposes onlyVerdictWireName), and Policy.Engine’s promote-time gate normalizes pack/finding tokens through one function (FindingReachabilityStates.Normalize,src/Policy/StellaOps.Policy.Engine/Services/IFindingsLookup.csl.85–115 — lattice wire names accepted, legacynot_reachable/unreachablefold tonot_observed) withIsReachableProofproven-only (l.72); VEX (IVexStatusDeterminer.DetermineStatus(PublicReachabilityVerdict)); and the OCI referrer (ReachabilityEvidenceSetPayloadembeds canonical verdict objects; the verifier re-runs the §7.0 gates on parse). The full loop is pinned per language by theGoldenReachable_FullLoop_ReferrerVerifiesAndPolicyGateBlocks_ByteIdenticallyfacts (proven verdict → pruned referrer verified from bytes alone → gate BLOCK under the real shipped pack, byte-identical on replay — see §11). Found at audit time still recomputing their own semantics — all three since REBASED the same day (see the re-audit note below): (a) Signals bucket scoring —ReachabilityScoringService.ComputeScores(src/Signals/StellaOps.Signals/Services/ReachabilityScoringService.csl.656–699) derives v0 buckets from its own BFS andReachabilityLattice.FromV0Bucket(src/Signals/StellaOps.Signals/Lattice/ReachabilityLattice.csl.145–163) derives the internal v1 state;ReachabilityStateDocument.Bucket/LatticeStateride the Signals API. The true rebase (Signals consuming Scanner verdict objects instead of recomputing) was blocked on the scan-side verdict-object feed — FEED LANDED 2026-06-11 (sprint 20260610_002 VERDICT-FEED):ReachabilityEvidenceJobExecutornow composes aPublicReachabilityVerdictObjectper (image digest, CVE, purl) for EVERY completed evidence job (proven via analyzer-builtPathWitnessreferences carrying PW-SCN-003 path hashes;reachable:likelywhen every binding mapping is package-API tier;not-observedwith the real coverage statement overISymbolNormalizer.KnownBlindSpots; honestunknownwith reason codes on every early-out) and persists it throughIPublicReachabilityVerdictStore(src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Verdicts/— Postgresreachability_public_verdictsauto-migrated by Scanner.Storage migration 005, canonical bytes stored verbatim as TEXT so byte-determinism/signature replay survive storage, reads re-validated throughReachabilityEvidenceJson.ParseVerdictObject; in-memory twin for non-storage hosts), queryable over HTTP atGET /reachability/verdicts/{imageDigest}[/{vulnId}?purl=…](Scanner.WebService, canonical bytes verbatim). SIGNALS REBASED 2026-06-11 (sprint 20260610_002 LATTICE criterion 3a): Signals scoring now CONSUMES the feed —ReachabilityRecomputeRequest.PublicVerdictscarries the canonical verdict-object JSON per target (exactly the bytes the feed serves); parsing goes through the re-validatingReachabilityEvidenceJson.ParseVerdictObject, so a tampered/evidence-less verdict fails its own §7.0 gate before scoring sees it. For verdict-fed targets, NO Signals-side BFS runs:ReachabilityScoringService.BuildVerdictDrivenStatemaps the verdict 1:1 (stateBucket= the lattice wire name;ReachabilityLattice.FromPublicVerdictfolds proven/likely⇒statically-reachable, not-observed⇒statically-unreachable, unknown⇒bottom, then joins Signals’ runtime-evidence lane — not-observed + runtime hit ⇒ Contested, never silently safe;reachable:likelyscores with the verdict’s own gated confidence), and the consumed verdict’s content address + witness ids rideReachabilityEvidenceDocument.PublicVerdictinto the fact digest. The legacy v0-bucket recompute (ComputeScores/FromV0Bucket, both annotated fallback-only in code) runs ONLY for targets with no verdict object. Pinned byReachabilityScoringVerdictConsumptionTests(Signals.Tests, 12 — incl. consumption-beats-BFS both directions and byte-determinism);lattice.mddeclares the buckets internal-only. (b) CLI — REBASED 2026-06-11 (sprint 20260610_002 LATTICE criterion 3b): the CLI witness/verdict surface now renders ONLY the public wire names through one display fold (src/Cli/StellaOps.Cli/Commands/ReachabilityVerdictDisplay.cs: canonical names pass through; legacy tier/stack tokens fold per the ratified mapper truth table —unreachable/safe/not_reachable/not_observed⇒not-observed,present⇒unknown; missing/unrecognized tokens are honestunknown; every output routes throughToWireName(), so the §7.0 naming guard structurally bans a public “unreachable”/“safe”).reachability witness-ops list/show(ReachabilityCommandGroup.cs) speak lattice (VERDICT column,--verdictfilter restricted to the four emittable wire names with--tieras legacy alias, the “Reachable: Yes/No” absence-claim column dropped); the livestella witness list/showhandlers (CommandHandlers.Witness.cs) no longer fabricate a tier (previously: hardcodedconfirmedon show, the--tierfilter echoed into every list row) — the witness APIs carry no per-witness verdict yet, so the CLI renders honestunknown, exposes JSON keyverdict(lattice) instead ofconfidenceTier, and warns that a--verdictfilter cannot be applied. Pinned byReachabilityVerdictDisplayTests(Cli.Tests, 38: fold truth table, forbidden-term guard, both surfaces, honest-unknown). © Console FE — REBASED 2026-06-11 (sprint 20260610_002 LATTICE criterion 3c): the Console-boundary fold isnormalizeFindingReachabilityState(src/Web/StellaOps.Web/src/app/core/api/triage-evidence.models.ts, the Policy-Normalize/CLI-Foldprecedent applied at the FE seam):NormalizedFindingReachabilityStateIS the lattice display vocabulary (reachable:proven/reachable:likely/not-observed/unknown, + reservednot-presentpass-through for claim-faithful display, + transientcomputingwhich is a job-progress state, not a verdict) — the forbidden public terms are structurally unrepresentable in the normalized union; canonical wire names pass through, legacy tokens fold per the ratified truth table (legacyreachable/booleantrue⇒reachable:provenmirroring Policy l.112,potentially_reachable⇒reachable:likely,not_reachable/unreachable/safe/booleanfalse⇒not-observed, unrecognized ⇒ honestunknown).reachability-chip.component.tsrenders the six display states (not-observed tooltip carries the §7.0 coverage qualifier, never “safe”; hop count renders on both witness-bearing tiers),security-findings.client.tsexposes lattice-onlyFindingDto.reachabilityState(legacy nullable boolean kept derived: proven/likely ⇒ true per the CLI--reachable-onlyfold, not-observed/not-present ⇒ false, unknown/computing ⇒ null), and the findings page facets/saved-views/labels/reasons + finding-row/list/detail/drawer + vulnerability-detail-page all consume the fold (legacy URL facet params fold inbound). Pinned byreachability-chip.component.spec.ts(34: lattice rendering, legacy-fold truth table, forbidden-term guard) + the client/page/row/detail/drawer/list specs (113 across 6 files, vitest). Out-of-scope adjacent FE surfaces recorded in the sprint Decisions & Risks (CLI (b)(v) precedent).RE-AUDIT 2026-06-11 (D9 closure verification — sprint 20260610_002 CONVERGE-VERIFY re-run, post-rebase): with VERDICT-FEED + LATTICE 3a/3b/3c landed, all six surfaces were re-verified against
src/. File:line evidence: Scanner stack/tier —PublicReachabilityVerdictMapper.FromStackVerdict/FromConfidenceTier(src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Stack/PublicReachabilityVerdictMapper.csl.40/55/69, total + witness-gated). Scan-side feed —ReachabilityEvidenceJobExecutorpersists a verdict object per completed job throughIPublicReachabilityVerdictStore(…/Jobs/ReachabilityEvidenceJobExecutor.csl.260, 595–606, 624; null store ⇒ verdict still rides the job result, l.77–79), served as canonical bytes verbatim atGET /reachability/verdicts/{imageDigest}[/{vulnId}](src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReachabilityEvidenceEndpoints.csl.71/77). Signals CONSUMES —ReachabilityRecomputeRequest.PublicVerdicts(src/Signals/StellaOps.Signals/Models/ReachabilityRecomputeRequest.csl.30) →ParsePublicVerdicts/BuildVerdictDrivenState(…/Services/ReachabilityScoringService.csl.106/116/699/730; l.845 documents that verdict-fed targets never reach the legacy path),ReachabilityLattice.FromPublicVerdict(…/Lattice/ReachabilityLattice.csl.154) withFromV0Bucketexplicitly “LEGACY FALLBACK ONLY” (l.178–183). Policy —FindingReachabilityStates.Normalizesingle fold accepts lattice wire names, legacy tokens collapse tonot_observed(src/Policy/StellaOps.Policy.Engine/Services/IFindingsLookup.csl.101–118),IsReachableProofproven-only (l.72–76),ReachabilityVerdictGateswitches on the lattice enum only (…/Reachability/Vex/ReachabilityVerdictGate.csl.202–235). VEX —IVexStatusDeterminer.DetermineStatus(PublicReachabilityVerdict)(…/Vex/IVexStatusDeterminer.csl.34/45;VexStatusDeterminer.csl.40;VexStatementDraftgates onNotObserved, l.180). CLI —ReachabilityVerdictDisplayroutes every output throughToWireName()so the §7.0 naming guard is structural (src/Cli/StellaOps.Cli/Commands/ReachabilityVerdictDisplay.csl.36–59). Console FE —NormalizedFindingReachabilityState/normalizeFindingReachabilityStatelattice union + fold (src/Web/StellaOps.Web/src/app/core/api/triage-evidence.models.tsl.55/79–117/184–200), consumed by the chip (…/shared/components/reachability-chip.component.tsl.30/36/195) and the lattice-typedFindingDto.reachabilityState(…/core/api/security-findings.client.tsl.47/353–363). Re-run counts (fresh builds, built exes + vitest, 2026-06-11): full-loop*GoldenReachable_FullLoop_*Total 8, Failed 0; the 8Lang*GoldenTrioEndToEndTestsclasses Total 49, Failed 0;PublicReachabilityVerdictFeedTests+PostgresPublicReachabilityVerdictStoreContractTestsTotal 11, Failed 0;PublicReachabilityVerdictMapperTestsTotal 18, Failed 0; SignalsReachabilityScoringVerdictConsumptionTestsTotal 12, Failed 0; CLIReachabilityVerdictDisplayTestsTotal 38, Failed 0; FEreachability-chip.component.spec.ts34/34 + 6 adjacent findings-chain specs 113/113. D9 verdict: one public vocabulary remains — every audited surface speaksPublicReachabilityVerdictthrough mapping/fold layers only. The single remaining reachability deliverable is NOT a vocabulary gap: the LIVE-stack forcing-function proof (a real image scan on a running Scanner+Concelier producing a CVE-derivedreachable:proventhat blocks a release) is still owed and tracked separately (§7.1 Phase 0 closing note, §11 per-lane “live full-loop” remainders).
7.4 Sink catalogs become signed data packs
Move capability-sink (and entrypoint) catalogs out of C# into versioned, DSSE-signed JSON packs loaded at startup (same trust-root pattern as scanner plugin manifests / policy packs; default packs in-repo, operator packs via devops/plugins-style drop folder). Effects: operators extend catalogs in air-gap without rebuilds (their internal frameworks’ sinks/entrypoints); catalog version + digest lands in every witness (today a registry edit silently changes verdicts with no provenance — an attestation hole); agents/curators fan out on data; SinkRegistry/matchers become the loader + match engine. CWE mapping and severity weights ride the pack.
IMPLEMENTED 2026-06-11 (D7 — sprint 20260610_002 CATALOG-PACKS): the sink catalogs are data packs; the engines are code. Format
stellaops.reachability-catalog-pack.v1(src/Scanner/__Libraries/StellaOps.Scanner.Contracts/CatalogPacks/):registrySinks(fully-qualified patterns forSinkRegistry),matcherSinks((module, symbol, category) tuples for the per-language matchers — each matcher keeps its language-specific match semantics in code and reads its table from the pack),entrypoints(declarative rules, e.g. Pythondecorator-prefix); CWE + severity weights ride the entries; array order is first-match-wins and preserved. Default packs are embedded resources extracted content-equal from the prior C# tables (SinkRegistry.BuildRegistry+ the 7 live matcher tables + the Python decorator table) and pinned by byte-parity + behavior-parity tests against a frozen verbatim copy (DefaultCatalogPackParityTests/LegacyCatalogTables; the full pre-existing extractor suites re-ran green over pack data — same fixtures, same sinks). Loader (ReachabilityCatalogPackLoader→ process-wideReachabilityCatalogRuntime, installed at Worker startup viaReachability:CatalogPackDirectory/CatalogTrustedKeysDirectory): defaults + operator packs from the drop folder (devops/plugins/scanner/catalog-packs/), DSSE-verified offline against local PEM key files (ECDSA P-256 over the DSSE v1 PAE; payload must byte-equal the pack file; no cloud KMS), fail-closed on unsigned/tampered/untrusted packs; deterministic occurrence-indexed override-in-place merge (operator overrides keep first-match positions; PHP’s legitimate duplicate identities survive). Witness pinning: everyPathWitnessnow carriesevidence.catalog_packs(name/version/sha256 digest/source per pack, from the SAME snapshot the engines matched against) —PathWitnessCatalogPackStampTestsproves presence and that a pack change changes the witness id. The attestation hole is closed for everything the packs cover. Remainder CLOSED 2026-06-11 (D7 completion pass, packs v1.1.0): every remaining DECLARATIVE table is now pack data, generated byte-parity from frozen verbatim copies of the C# tables (LegacyCatalogTables, zero hand transcription — the parity test’s bootstrap emission regenerates the committed JSON): Go entrypoints (kindsframework-methodexact /package-prefixStartsWith), Java (method-annotation/controller-class-annotation), Rust (attributeexact-then-::-suffix, Ordinal /free-function-name), Ruby (controller-action-name/controller-parent-class), PHP (controller-action-name/annotation-prefix/controller-parent-class), plus the Bun/Deno sink tables (language keysbun/deno; Module empty, Symbol = substring pattern; engines keep exact-then-Contains semantics in pack order — the extractor classes themselves remain unregistered/unrouted per §11, so a future match-semantics rework is a pack version bump, not a frozen shape). The declarative-vs-heuristic split, per language (heuristics stay in the classifier engines): Python — decorators in pack; Django view-class suffixes (View/ViewSet/APIView),Servicersuffix, lambda/main/CLI name conventions in code. Go — framework-method + package-prefix tables in pack;main/inithandling and receiver-suffix conventions (Server/Handler/Controller) in code. Java — annotation tables in pack;mainsignature, servlet inheritance (doGet…), special-method names in code. Rust — attribute + free-function-name tables in pack; AST-derived launch/Axum-router/Rocket-fairing detection, FFI exports, clap-command detection in code. Ruby — action-name + parent-class tables in pack;Controller/Job/Worker/CLI/Commandclass-suffix and rake-module conventions in code (the deadRailsCallbackstable was removed, not migrated). PHP — action-name + annotation-prefix + parent-class tables in pack;Controller/Job/Listener/Command/Schedulesuffix-and-substring conventions in code. JavaScript has NO live declarative entrypoint table — its in-codeModulePatterns/NestJsControllerDecoratorstables were dead (never consumed) and were deleted rather than migrated: pack data must reflect live behavior (pinned byJavaScriptEntrypointRules_AreDeliberatelyAbsent). Parity:DefaultCatalogPackParityTests(36 incl. loader) byte-pins both packs against the frozen tables and sequence-pins all 9 matcher languages + 6 entrypoint languages; the full pre-existing extractor/entrypoint suites re-ran green over pack data (CallGraph.Tests Total 466, Failed 0 pre-existing; Total 483, Failed 0 with the newBunDenoSinkMatcherPackEngineTestsof the completion pass — Deno’s first behavior pin, since no Deno suite existed).
7.5 Top risks
| Risk | Severity | Mitigation |
|---|---|---|
Symbol coverage disappoints outside Go/Rust → most verdicts reachable:likely/unknown → product feels hollow | High | Three-source ladder (§6.1/Phase 2/4); UI is explicit about evidence tier (a likely with package-API path still beats every rule-list competitor); patch-diff factory is the structural answer |
| Lexical extractor under/over-approximation produces a wrong gating verdict | High | Verdict-tier policy: only proven (verifiable witness) may block; not-observed may downgrade only with coverage ≥ policy threshold — IMPLEMENTED 2026-06-11 (ReachabilityVerdictGate + live PolicyRuntimeEvaluator proof gate, see §10 D5 note); negative-fixture discipline; edge-confidence already biases under-approximation (right direction) |
| A signed “not-observed” precedes an exploit (the existential reputational risk) | High | Naming (not-observed, never “safe”); mandatory coverage statement; L2 facts required for not-present; operator countersign on VEX not_affected (D5) — IMPLEMENTED 2026-06-11 (VexStatementDraft drafted-pending-countersign state machine, see §10 D5 note); suppression witnesses revocable + re-evaluated on every graph/symbol refresh (drift machinery) |
| Patch-diff factory legal/licensing (redistributing derived data from downloaded packages in offline kits) | Medium | Derived facts (method names/hashes) not package content; counsel review before kit export (pattern: existing trivy-db counsel thread); per-ecosystem ToS check at feed time |
| Graph scale on monorepos/large images (189k-edge incident already observed) | Medium | Pruned-subgraph referrer (§8); compact-v2+gzip exists; per-language caps + SUBGRAPH_EXTRACTION.md §8 size/depth tables; Valkey cache (ValkeyCallGraphCacheService exists) |
| Scope creep toward taint analysis | Medium | D8 records the explicit rejection; gates/guards remain the bounded substitute |
| Convergence debt (3 verdict vocabularies, 2 L2 semantics, 3 enums) hardens further | Medium | Phase 0/§7.3 normalize before new surface area is added |
8. Evidence sizing: what gets attached (the referrer answer)
Layered storage, all content-addressed and cross-linked by digest:
- Full call graph → CAS only (richgraph-v1 / snapshot; cached, queryable, replay input). Never the referrer payload.
- OCI referrer per image → the vuln-keyed evidence set: for each (CVE, purl) evaluated — verdict object + pruned entry→sink subgraph (bounded:
maxPaths≤ 5,maxDepth≤ 10–15 perSUBGRAPH_EXTRACTION.md; nodes ≈ paths × depth ⇒ kilobytes, gzip-compact-v2 encoded) + witness DSSE envelopes + coverage statement + input digests (graph_hash, symbol-set hash, catalog-pack digests, policy digest). Bounded by findings count, not graph size — the scaling answer. - Today’s whole-graph compact referrer remains a transitional/debug artifact; deprecate as default once (2) lands (its own comment notes no consumer reads it back). — DONE 2026-06-11: no longer the default; opt-in via
BuildTimeOciAttachRequest.AttachWholeGraphReferreronly (see the implementation note below).
This makes the referrer simultaneously small, sufficient for offline third-party verification (subgraph + witness + digests verify without the full graph), and meaningful (every byte attached is evidence for a decision).
IMPLEMENTED 2026-06-11 (D10 — sprint 20260610_002 REFERRER): the vuln-keyed evidence set is now the wire contract AND the attach default. (2)
ReachabilityEvidenceSetPayload+ReachabilityEvidenceReferrerBuilder+ReachabilityEvidenceReferrerSerializer(src/Scanner/__Libraries/StellaOps.Scanner.Contracts/ReachabilityEvidenceReferrer*.cs): per (CVE, purl) — the canonicalPublicReachabilityVerdictObject(the coverage statement rides inside it fornot-observed, per §7.0) + the pruned entry→sink subgraph in compact-v2 conventions with node ids retained (the slice must verify standalone; bounds clamped to maxPaths ≤ 5 / maxDepth ≤ 15 perSUBGRAPH_EXTRACTION.md) + the PathWitness DSSE envelopes + input digests (set-levelReachabilityInputDigestsand per-finding inside each verdict), gzip canonical JSON (artifactTypeapplication/vnd.stellaops.reachability.evidence-set.v1+json, payload formatreachability-evidence-setv1). The builder is fail-closed: fabricated paths (non-edges), witnesses referencing pruned-away paths, path-hash mismatches (PW-SCN-003 recipe recomputed), graph/image binding mismatches, and reachable claims whose evidence cannot fit the bounds all THROW before anything can attach. Third-party verification is real:ReachabilityEvidenceReferrerVerifier.Verify(bytes)works from the payload alone — parsing re-runs the §7.0 evidence gates (verdict parse goes throughCreate), witness paths are edge-checked against the included subgraph, and path hashes are recomputed. Size is pinned by test as bounded by findings, not graph: a 189k-edge fixture and its 378k-edge double produce byte-identical decompressed payloads modulo the graph hash, kilobytes total (ReachabilityEvidenceReferrerTests, Contracts.Tests). (1)/(3)BuildTimeOciAttachServicenow attaches the evidence set BY DEFAULT for generated graphs (zero findings at build time — the set still bindsgraph_hash, the CAS content address of the full graph, plus bounds to the image digest; the scan-side evidence pipeline populates findings); the whole-graph compact-v2 referrer is DEPRECATED/TRANSITIONAL behindBuildTimeOciAttachRequest.AttachWholeGraphReferrer(deliberately not exposed as a CLI flag) and remains the canonical wire form for CAS storage of full graphs. Annotations:org.stellaops.reachability.graph_hash+org.stellaops.reachability.findingsride the manifest. Pinned byPrunedEvidenceReferrerDefaultTests(Sbom.BuildTime.Tests): default ⇒ evidence set with no graph node/edge tables; whole graph only behind the flag; byte-determinism.
9. What we explicitly do NOT build
- Whole-program taint/dataflow engine — see §5-C/D8.
- LLM-in-the-verdict-path — violates determinism/offline; offline catalog-authoring aid only, output human-reviewed + signed.
- Generic capability-sink findings (“you call eval somewhere”) — annotation and drift signal only.
- Cloud-callback reachability service — contradicts the product’s constitution.
- Per-language bespoke verdict pipelines — one core, data fan-out (§7.2).
10. Decisions required from the operator
| # | Decision | Recommendation |
|---|---|---|
| D1 | Ratify job ranking: auditable evidence > prioritization > noise reduction (drives every later trade-off) | Yes — evidence-first |
| D2 | Ratify the two-sink split: vulnerability sinks = product; capability sinks = annotations/scoring/drift only (no standalone findings) | Yes |
| D3 | First-language order | Go first (end-to-end truth fastest: best data + SSA extractor), .NET second (dogfood: Stella attests its own images) |
| D4 | Public verdict vocabulary + the naming rule (not-observed w/ mandatory coverage statement; not-present reserved for L2 facts; no public “unreachable/safe”) | Adopt §7.0 lattice |
| D5 | Policy/VEX defaults: only reachable:proven blocks; not-observed + coverage ≥ threshold may downgrade; VEX not_affected auto-drafted but requires operator countersign (cf. ADR-025 operator-signed decisions) | Adopt; thresholds in policy packs |
| D6 | Patch-diff surface factory (Phase 4a): commit now as the data moat, run feed-side, export to offline kits? Pull binary/loader L2 (4b) earlier for native-heavy estates? | Commit 4a after Phase 1 proves the loop; 4b on customer signal. Open counsel thread on derived-data redistribution before kit export |
| D7 | Sink/entrypoint catalogs as DSSE-signed data packs (operator-extensible, digest-pinned in witnesses) | Yes — closes an attestation hole and enables agent fan-out |
| D8 | Confirm rejections (§9): no taint engine, no LLM at verdict time, no SaaS lane | Confirm |
| D9 | Vocabulary convergence mandate: Scanner stack / Signals buckets / Policy gates / Console adopt the single lattice (lattice-v1 design as vehicle); fold the duplicate-enum cleanup into Phase 0 | Yes |
| D10 | Referrer policy: pruned vuln-keyed evidence set as the attached artifact; full graph CAS-only; deprecate whole-graph referrer default | Yes |
RATIFIED 2026-06-10 by the operator: D1–D10, as recommended — with one modification to D3. D3 (modified): NOT “Go first, .NET second”. The operator directs a plan for every supported language (Go, .NET, Java, JavaScript/TypeScript, Python, Rust, Ruby, PHP — Bun and Deno ride the JS lane). Rationale: §7.2 already establishes that the per-language surface is data + one small normalizer, so the data-fan-out applies to all lanes, not a two-language pilot. Go remains the template lane (best symbol corpus + SSA extractor) and lands first within the all-language sprint, but every language gets its own normalizer + golden-fixture task in the same plan. See §11 for the verified per-language coverage matrix and
docs/implplan/SPRINT_20260610_002_Scanner_reachability_lattice_referrer_per_language.mdfor the implementation tracker (LATTICE D4/D9, REFERRER D10, POLICY-VEX D5, CATALOG-PACKS D7, LANG-× 8, CONVERGE-VERIFY).
IMPLEMENTED 2026-06-11 (D5 — sprint 20260610_002 POLICY-VEX): the policy/VEX defaults are code, on both layers. (a) Verdict-tier gate —
ReachabilityVerdictGate(src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Vex/ReachabilityVerdictGate.cs): pure, deterministic function of (PublicReachabilityVerdictObject, pack thresholds).reachable:proven→Block(the witness is machine-checked before the verdict object can exist, §7.0 rule 1);reachable:likely/unknown→ never block,FeedsScoring=true;not-observed→DowngradeDraftediff its mandatory coverage statement clears the pack threshold (percent-nodes-resolved + entrypoint floor), else the downgrade is refused (not_observed_coverage_below_threshold). The never-block semantics are hard rules of the evaluator, not configuration. Thresholds have NO code defaults:ReachabilityGateThresholdsrequires pack provenance (name/version/sha256) and is produced by the fail-closedReachabilityGatePolicyPackLoaderfrom the default packdevops/policies/reachability-verdict-gate.policy.yaml(shipped: ≥ 80% nodes resolved, ≥ 1 entrypoint); a missing pack/settings node throws — no constant fallback. The same defaults are live on the promote-time gate:PolicyRuntimeEvaluator(src/Policy/StellaOps.Policy.Engine/Services/) now treats ONLY proven reachability as blocking proof (FindingReachability.IsReachableProof;potentially_reachable/reachable:likelyno longer block),FindingReachabilityStates.Normalizeaccepts the lattice wire names (legacynot_reachable/unreachabletokens fold intonot_observedper D4), and gate expressions can constrainreachability.evidence_tieralongsidereachability.state. (b) Drafted-pending-countersign VEX —VexStatementDraft(…/Vex/VexStatementDraft.cs): a VEXnot_affectedstatement is auto-DRAFTED from anot-observedverdict (only above threshold — a below-threshold draft is unrepresentable) and follows the ADR-025 state machinePendingCountersign → Countersigned → Published(+ terminalRejected);PublishToAsyncrefuses BEFORE touching the channel unless anOperatorCountersign(operatorsub, enrolled keyId,operator-decision@v1envelope digest) is attached, so an uncountersigned draft can never reach a publication channel. Draft ids are deterministic (content hash of verdict digest + product + pack digest, no clock). © Verdict→VEX mapping —IVexStatusDeterminer.DetermineStatus(PublicReachabilityVerdict)+GetJustificationCategory:proven→affected;likely→under_investigation(judgment call: a data-starvation tier must not claimaffected);not-observed→not_affected+vulnerable_code_not_reachable(draft-only per (b));unknown→under_investigation; reservednot-presentthrows (will map tovulnerable_code_not_presentwhen L2 exists). Pinned byReachabilityVerdictGateTests/ReachabilityGatePolicyPackLoaderTests/VexStatementDraftTests/VexStatusDeterminerLatticeTests(Scanner.Reachability.Tests, 42) and the lattice/evidence-tier additions toPolicyRuntimeEvaluatorTests/PostgresFindingsLookupReachabilityTests/CveDenylistExtractorTests(Policy.Engine.Tests). Still owed (tracked in the sprint): durable draft persistence + countersign endpoints (rides the ADR-025 WS5 surfaces). The other half of this remainder — the scan-side wiring that feeds verdict objects per language — LANDED 2026-06-11 (VERDICT-FEED): the evidence job executor composes + persists the per-(image, CVE, purl) verdict object on every completed job (language-blind composer over the per-language normalizer outputs; see the §7.3 feed note), so gate consumers now have one durable, re-validating source of verdict objects instead of constructing their own.
11. Per-language coverage plan (D3 as ratified — all lanes)
Verified against src/ on 2026-06-10. Two cross-cutting facts frame every row:
- The symbol normalizer (§7.2 item 4) exists for NO language.
ReachabilityAnalyzermatchesExplicitSinksby exact node-id equality (CallGraph/Analysis/ReachabilityAnalyzer.cslines 111–119:targetSinks.Where(origins.ContainsKey)), while advisory symbols arrive asCanonicalId({module}::{Class}.{symbol}/{module}::{symbol},Concelier.Core/Signals/AffectedSymbol.cslines 128–140) and graph node ids are per-language shapes (go:{package}.{func}perGoSymbolIdBuilder.cs;rust:{package}/{symbol}; .NET lightweight node ids are hashes ofdotnet:lightweight:{symbol}(arity)—DotNetLightweightCallGraphExtractor.cslines 376–377, so .NET must match on nodeSymbol, notNodeId). The Phase 0 floor proof passes only because the fixture hand-crafts node ids equal to theCanonicalIds (Phase0CveSinkFloorEndToEndTests.cslines 75–77, 516–517). The normalizer — a documented, contract-tested CanonicalId→node-id resolution step applied before BFS, with the match rule recorded in the witness — is the per-language deliverable owed everywhere. — GO LANDED 2026-06-11 (sprint 20260610_002 LANG-GO): the contract (ISymbolNormalizer/SymbolNormalizerRegistry) + the Go reference rule (GoSymbolNormalizer,stellaops.go.symbol-normalizer@1.0.0) are wired into the evidence executor pre-BFS, recorded inWitnessSink.match_ruleand coverage-statement attempt logs. .NET LANDED 2026-06-11 (LANG-DOTNET):DotNetSymbolNormalizer(stellaops.dotnet.symbol-normalizer@1.0.0) joins the default registry — it binds on nodeSymbol(node ids are hashes in both .NET tiers), with the tail rule restricted to unresolved-surface nodes so first-party look-alikes never bind (see the .NET row). Java LANDED 2026-06-11 (LANG-JAVA):JavaSymbolNormalizer(stellaops.java.symbol-normalizer@1.0.0) — fully-qualified ordinal equality on{class}.{method}node symbols + descriptor-precise node-id pinning; simple-name/global-module canonicals refuse to bind (see the Java row). JS/TS LANDED 2026-06-11 (LANG-JS, Bun/Deno ride the lane):JsSymbolNormalizer(stellaops.js.symbol-normalizer@1.0.0) — package-anchored binding to import-verified synthesized targets only; first-party look-alike files structurally cannot bind (see the JS row). Python LANDED 2026-06-11 (LANG-PYTHON):PythonSymbolNormalizer(stellaops.python.symbol-normalizer@1.0.0) — package-anchored binding to import-verified synthesized targets (sink + external forms); dotted-symbol fold instead of class erasure; PyPI-→_fold (see the Python row). Rust LANDED 2026-06-11 (LANG-RUST):RustSymbolNormalizer(stellaops.rust.symbol-normalizer@1.0.0) — package-anchored binding to extern-verified, use-decl-expanded call targets; Method-dot refold, generics strip, Cargo hyphen fold (see the Rust row). Ruby LANDED 2026-06-11 (LANG-RUBY):RubySymbolNormalizer(stellaops.ruby.symbol-normalizer@1.0.0) — package-anchored binding to require-verified synthesized targets (canonical module = the gem’s require feature path);#-spelling and dot-chain folds; flatglobal::matched whole (see the Ruby row). PHP LANDED 2026-06-11 (LANG-PHP):PhpSymbolNormalizer(stellaops.php.symbol-normalizer@1.0.0) — package-anchored binding to use-verified synthesized targets (canonical module = the class’s PSR-4 namespace, never the Packagist package name); natural-FQN/()/->/all-::folds; flatglobal::matched whole (see the PHP row). All 8 lanes now carry a registered rule (see §7.2 implementation note and the rows below). - Routing is narrower than the extractor inventory. Worker scan-side language detection routes only dotnet, javascript, python, go, java (
ReachabilityInputStageExecutor.DetectLanguageCandidates, lines 637–712) — rust joined 2026-06-11 (LANG-RUST:Cargo.toml/Cargo.lockcandidate, routing-tested), ruby joined 2026-06-11 (LANG-RUBY:Gemfile/Gemfile.lock/config.rucandidate, routing-tested; the extractor is also now DI-registered), and Bun/Deno manifests ride the javascript lane since LANG-JS, php joined 2026-06-11 (LANG-PHP:composer.json/composer.lockcandidate, routing-tested; the extractor is also now DI-registered); the build-time referrer generator routes only dotnet, javascript, python, rust (ReachabilityCallGraphGenerator.CreateExtractor, lines 195–208); DI registers DotNet/Java/Node/JavaScript/Php/Python/Ruby/Rust/Go/Binary (CallGraphServiceCollectionExtensions.cs). The Bun and Deno extractor classes remain unregistered/unrouted — by decision their manifests ride the JS lane (§9, no bespoke pipelines).
| Language | Extractor tier (§3.1) | Entrypoint catalog | Capability-sink catalog | Symbol normalizer (§7.2 #4) | Advisory symbol data: source + coverage | Golden fixtures | Concrete remaining gaps |
|---|---|---|---|---|---|---|---|
| Go | Toolchain — GoCallGraphExtractor → external SSA tool, with static source fallback (Sprint 020 shapes). Fallback now extracts real call edges (LANG-GO 2026-06-11): two-pass lexical design (Python/JS template) — in-package direct calls, project-local selector calls via go.mod module path, dependency/stdlib selector calls synthesized as go:external/{path}.{func} target nodes (sink-matched on the call TARGET); honest skips: non-imported receivers, dotted chains, dot-imports, method values, reflection, go member/closure spawns (pinned by GoCallGraphExtractorCallEdgeTests incl. no-edge negative + string/comment sanitization). Worker-routed (go.mod/go.sum); NOT in build-time set | Strong — GoEntrypointClassifier + Sprint 020 table w/ negative discipline (HandleFunc/router verbs, init(), main, goroutine pseudo-nodes) | GoSinkMatcher (SSA node packages) + SinkRegistry go entries — works where imports resolve (§3.2 #4); post-LANG-GO also applied to fallback call targets | DONE 2026-06-11 — GoSymbolNormalizer (stellaops.go.symbol-normalizer@1.0.0): canonical {module}::{symbol} / {module}::{Receiver}.{method} resolved against go:{module}.{sym} / go:external/{module}.{sym} node ids + identity form, terminal-/vN version normalization on BOTH sides, pointer-receiver folding; ordinal-exact only — first-party look-alike packages (go:ssh.NewServerConn) and short-name self-scan nodes deliberately do NOT match (unknown, never guessed). Contract-tested (GoSymbolNormalizerTests: function/method/version-suffix/external/identity + 5 negatives + determinism); recorded in WitnessSink.match_rule | Best in industry — Go vulndb imports[].symbols via OSV; nested-shape extractor FIXED 2026-06-10 (§3.5 #4). No VulnSurfaces lane (not needed) | Toy svc-04-text-template-go (tier-labeled); Phase 0 floor proof (synthetic graph, identity-keyed); golden trio via the REAL extractor DONE 2026-06-11 — LangGoGoldenTrioEndToEndTests: reachable ⇒ reachable:proven + independently-recomputed path_hash witness; not-observed ⇒ not-observed + coverage statement replaying byte-identically across two fresh runs; negative (interface dispatch + reflection + method value + look-alike pkg) ⇒ no witness, unknown/symbol_unresolved | SSA tool presence in worker images; add go to build-time set or document why not; live full-loop proof (CONVERGE-VERIFY) |
| .NET | Both — toolchain DotNetCallGraphExtractor (Roslyn semantic, opt-in DotNetExtractor=msbuild + restore); standalone DotNetLightweightCallGraphExtractor is the scan-path default (S075-002) and the build-time extractor | Strong — Sprint 018 table w/ negatives (minimal API Map*, [Http*], hosted services, gRPC, static Main) | SinkRegistry dotnet entries; lexical-match defect FIXED 2026-06-10 (§3.5 #1, MatchSinkLexical + using-alias expansion) | DONE 2026-06-11 — DotNetSymbolNormalizer (stellaops.dotnet.symbol-normalizer@1.0.0, Extraction/DotNet/): node ids are HASHES in both tiers, so the rule binds on node Symbol — (1) identity for canonical-keyed curated graphs; (2) fully-qualified {ns}.{Type}.{method} ordinal equality on ANY node (library self-scan, tier-A invoked externals, alias-expanded lightweight externals); (3) Type.Method dot-boundary tails (MatchSinkLexical floor) ONLY on unresolved-surface nodes (File empty) — first-party look-alikes (Acme.Process.Start, namespace-less Process.Start) and variable receivers (serializer.Deserialize) NEVER bind; Function-shaped canonicals ({ns}::{symbol}) never tail-match (no type anchor — bareword ban). Contract-tested (DotNetSymbolNormalizerTests, 20: qualified/lexical-receiver/alias-expanded-via-REAL-extractor/partial-tail/overloads/global-module + 8 negatives + determinism); recorded in WitnessSink.match_rule | Thin — GHSA/NVD range-only; needs the patch-diff factory (VulnSurfaces NuGet downloader + Cecil fingerprinter EXIST, ~70%, §3.3); golden trio honestly rides the curated + package-symbols tiers meanwhile | Toy svc-05-xmlserializer-dotnet; Sprint 018 + Phase 0 lexical/alias extractor fixtures; golden trio via the REAL lightweight extractor DONE 2026-06-11 — LangDotNetGoldenTrioEndToEndTests (6): reachable ⇒ reachable:proven + independently-recomputed path_hash witness (match rule + curated tier recorded); package-symbols tier ⇒ reachable:likely verdict object (tier + calibrated 0.4 confidence, never a fabricated proven); not-observed ⇒ coverage statement declaring the cross-assembly/virtual-dispatch blind spots, replaying byte-identically across two fresh runs; negative (reflection Type.GetType/Activator.CreateInstance + variable receiver + a REACHABLE first-party look-alike XmlSerializer class) ⇒ no witness, unknown/symbol_unresolved | Symbol data via VulnSurfaces (D6 factory) — until then verdicts above likely need curated bundles; live full-loop proof (CONVERGE-VERIFY) |
| Java | Toolchain — JavaCallGraphExtractor (bytecode, sink call-targets as synthetic nodes, §3.1). Parser fixed 2026-06-11 (LANG-JAVA): JavaBytecodeAnalyzer.ByteReader (mutable ref struct) was passed BY VALUE to every parsing helper, losing all position advances — real .class files ALWAYS desynced and parsed to null (the call-target logic was dead code on real bytecode; the pre-existing suite is JavaClassInfo-built and never forced a real parse). All helpers now take ref ByteReader; the golden trio parses real class files end-to-end. Worker-routed (pom.xml/gradle); NOT in build-time set | Strong — Sprint 020 table w/ negatives (Spring/JAX-RS/Micronaut/Servlet/gRPC/EJB/JSR-250) | JavaSinkMatcher on bytecode call targets + registry entries (§3.2 #4) — now actually reachable on real bytecode post-parser-fix | DONE 2026-06-11 — JavaSymbolNormalizer (stellaops.java.symbol-normalizer@1.0.0): canonical {package}::{Class}.{method} / {package.Class}::{method} resolved against java:{class}.{method}{descriptor} node ids by fully-qualified ordinal equality on node SYMBOLS (descriptor-insensitive — binds all overloads, documented) + descriptor-precise node-id equality (a JVM descriptor pins ONE overload; source-form parameter lists are stripped) + inner-class source→binary folding (Outer.Inner.m→Outer$Inner.m) + javadoc # spellings + <init>/<clinit>; simple-name/global-module canonicals NEVER bind (the look-alike trap — com.demo.JndiLookup.lookup stays unmatched) and class-granularity facts do not fan out to methods. Contract-tested (JavaSymbolNormalizerTests, 20: package/FQ-class modules, synthetic sink targets, overloads, descriptors, inner classes, constructors + 7 negatives + determinism); recorded in WitnessSink.match_rule | Thin — GHSA Maven range-only; needs patch-diff factory (Maven downloader + JavaBytecodeFingerprinter EXIST); golden trio honestly rides the curated tier meanwhile | Toy svc-01-log4shell-java; golden trio via the REAL bytecode extractor DONE 2026-06-11 — LangJavaGoldenTrioEndToEndTests (5): real synthesized class files (deterministic in-test class-file writer: constant pool + Code attributes with invoke instructions) → log4shell-shaped reachable (main→LogService.process→JndiLookup.lookup) ⇒ reachable:proven + independently-recomputed path_hash witness (match rule + curated tier recorded); not-observed (dormant importer calls the symbol, no entrypoint reaches it) ⇒ coverage statement replaying byte-identically across two fresh runs; negative (Class.forName/Method.invoke reflection + absent dependency definition + REACHABLE first-party look-alike com.demo.JndiLookup) ⇒ no witness, unknown/symbol_unresolved | Symbol data via VulnSurfaces (D6 factory) — until then verdicts above likely need curated bundles; live full-loop proof (CONVERGE-VERIFY) |
| JS/TS (Bun + Deno ride this lane) | Both — toolchain NodeCallGraphExtractor (Babel tool); standalone JavaScriptCallGraphExtractor (import-alias sink synthesis, lines 440–535). Externals synthesized for ALL import-verified calls (LANG-JS 2026-06-11): member calls on imported aliases and direct calls of named/default imports that are NOT capability-catalog sinks now synthesize js:{app}/external.{module}.{method} targets (Go-lane precedent — the external call surface must exist for CanonicalId matching); honest skips unchanged: non-imported receivers, computed keys, dynamic require(expr), relative-path and protocol-prefixed (node:, https://) specifiers (pinned by JavaScriptCallGraphExtractorExternalCallTests incl. negatives + determinism). Worker- and build-time-routed; Bun/Deno manifests (bun.lockb/bun.lock/deno.json/deno.jsonc) now route into this lane when no package.json exists (routing test), and the extractor reads Deno project metadata from deno.json/deno.jsonc— the unregistered Bun/Deno extractor classes stay unrouted (no bespoke pipelines, §9) | Strong — Sprint 018 table w/ negatives (Express/Fastify, Module Federation); Bun/Deno classifiers exist outside the 018/020 discipline | JsSinkMatcher import-alias table (catalog sinks keep the sink.{…} node shape, IsSink=true; externals are NOT capability claims) | DONE 2026-06-11 — JsSymbolNormalizer (stellaops.js.symbol-normalizer@1.0.0, Extraction/JavaScript/): package-anchored — a node binds only when Symbol == {Package}.{name} for a bare npm specifier Package (true exactly for import-verified synthesized targets; node-id text is never inspected, so file-name look-alikes like lodash.js/sink.ts are structurally unmatchable). Rule items: (1) identity for canonical-keyed curated graphs; (2) {package}::{export} incl. scoped packages + npm package-root variant for deep imports (lodash/template answers for lodash); (3) Method form {module}::{Class}.{method} class-erased to module+member (JS receivers carry no type — documented over-approximation, module verified by import); (4) flat GHSA form global::{module}.{export} matched whole (never class-erased — the global placeholder names no module). Contract-tested (JsSymbolNormalizerTests, 18: ESM/CJS alias forms through the REAL extractor, scoped/deep/flat/self-scan + 7 negatives + determinism); recorded in WitnessSink.match_rule | Thin — GHSA npm range-only (rare flat database_specific fields — the golden trio exercises exactly that shape through the real OSV extractor); needs patch-diff factory (npm downloader + JavaScriptMethodFingerprinter EXIST; fingerprinter upgraded to a real Esprima AST parse at fidelity 0.85, LANE-AST-UPGRADE 2026-06-12) | Toy svc-02-prototype-pollution-node; golden trio via the REAL extractor DONE 2026-06-11 — LangJsGoldenTrioEndToEndTests (5): GHSA-shaped flat OSV JSON (CVE-2021-23337 lodash.template) → real OsvAffectedSymbolExtractor → bridge → real extractor over a real npm project in temp dirs → executor → mapper; reachable (GET /render→renderTemplate→external.lodash.template) ⇒ reachable:proven + independently-recomputed path_hash witness (match rule + osv source recorded); not-observed (symbol present via dormant helper, unreachable) ⇒ coverage statement (JS blind-spot catalog) byte-identical across two fresh runs; negative (non-imported receiver + computed key + dynamic require + REACHABLE first-party look-alike file lodash.js) ⇒ no witness, unknown/symbol_unresolved, nothing stored | Symbol data via VulnSurfaces (D6 factory) — until then verdicts above likely need curated bundles or the rare flat-GHSA entries; Bun/Deno matcher TABLES moved to the packs 2026-06-11 (D7 completion; languages bun/deno, engines keep exact-then-Contains semantics — the extractor classes stay unrouted, and any future match-semantics rework is now a pack version bump); live full-loop proof (CONVERGE-VERIFY) |
| Python | Standalone — PythonCallGraphExtractor; defect #2 FIXED 2026-06-10 (two-pass lexical call extraction + imported-call sink synthesis, §3.5 #2). Externals synthesized for import-verified non-catalog calls (LANG-PYTHON 2026-06-11): member calls on imported aliases and direct calls of from-imported names that are NOT capability-catalog sinks synthesize py:{app}/external.{module}.{func} targets (Go/JS-lane precedent — the external call surface must exist for CanonicalId matching); honest skips unchanged: non-imported receivers, getattr/__import__ reflection, import *, and first-party modules (pinned by PythonCallGraphExtractorExternalCallTests incl. negatives + determinism). Worker- and build-time-routed | Strong — Sprint 020 table w/ negatives (Flask/FastAPI/Django/Celery/APScheduler/Click/Typer/Lambda/gRPC/asyncio.run) | PythonSinkMatcher applied to call targets post-fix + registry entries (catalog sinks keep the sink.{…} node shape, IsSink=true; externals are NOT capability claims) | DONE 2026-06-11 — PythonSymbolNormalizer (stellaops.python.symbol-normalizer@1.0.0, Extraction/Python/): package-anchored — a node binds only when Symbol == {Package}.{name} for a valid dotted Python module path Package (true exactly for import-verified synthesized targets; first-party look-alikes — even a vendored PIL/ImageColor.py shim REACHABLE from an entrypoint — are structurally unmatchable because their Package is the app, and PyPI project names with hyphens fail the module-path check by construction). Rule items: (1) identity for canonical-keyed curated graphs; (2) {module-path}::{func}; (3) dotted-symbol fold {module}::{Class}.{method} → {module}.{Class}::{method} (the class joins the import path — Python member calls preserve dotted chains, so NO class erasure / no JS-style over-approximation is needed); (4) PyPI -→_ fold (PEP 503) for hyphenated distribution-name modules, case never folds; (5) flat global::{dotted.path} matched whole. Distribution names differing beyond the hyphen fold (Pillow→PIL, PyYAML→yaml) deliberately do NOT bind — curated rows must carry the import module path. Contract-tested (PythonSymbolNormalizerTests, 18: module.func + aliased + relative-from THROUGH THE REAL extractor, sink/external/self-scan/hyphen-fold/flat/identity/Method-fold positives + 7 negatives + shape/determinism); recorded in WitnessSink.match_rule | Thin — PYSEC/GHSA mostly range-only (the golden trio exercises the rare STRUCTURED ecosystem_specific.symbols object shape — name+module — through the real OSV extractor); needs patch-diff factory (PyPI downloader + PythonAstFingerprinter EXIST) | Toy svc-03-pickle-deserialization-python; post-fix extractor fixtures w/ negatives; golden trio via the REAL extractor DONE 2026-06-11 — LangPythonGoldenTrioEndToEndTests (5): CVE-2021-23437-shaped OSV JSON (Pillow, PIL.ImageColor::getrgb) → real OsvAffectedSymbolExtractor → bridge → real extractor over a real Python project in temp dirs → executor → mapper; reachable (GET /color→resolve_color→external.PIL.ImageColor.getrgb; build-time stub-guard predicate asserted) ⇒ reachable:proven + independently-recomputed path_hash witness (match rule + osv source recorded); not-observed (dormant helper calls the symbol, no entrypoint reaches it) ⇒ coverage statement (Python blind-spot catalog) byte-identical across two fresh full runs; negative (getattr/__import__ reflection + import * + REACHABLE vendored first-party look-alike PIL/ImageColor.py) ⇒ no witness, unknown/symbol_unresolved, nothing stored | Symbol data via VulnSurfaces (D6 factory) — until then verdicts above likely need curated bundles or the rare structured-OSV entries; live full-loop proof (CONVERGE-VERIFY) |
| Rust | Standalone — RustCallGraphExtractor (cargo-metadata + regex/AST sweep, Tier-B). Two-pass lexical call extraction (LANG-RUST 2026-06-11, Go/Python/JS template): per-file use-decl alias tables expand call-chain roots (use std::process::Command; Command::new → std::process::Command::new), first-party-rooted path calls (crate::/self::/super::, package name, file-derived or mod-declared module roots) resolve to DECLARED nodes, in-module bare calls and use-imported bare calls (use time::at_utc; at_utc()) become real edges, and extern-rooted call targets carry the verified module path in Package (the normalizer anchor); call sweeps run over a sanitized view (strings/comments/raw strings blanked, offsets preserved). Honest skips: non-imported method receivers, glob imports, nested use groups, macro invocations, fn-pointer/function-value dispatch, turbofish-in-chain (pinned by RustCallGraphExtractorCallEdgeTests incl. shadowed-mod time + sanitization negatives). DI-registered, in the build-time set, AND Worker scan-side now routes rust (Cargo.toml/Cargo.lock candidate in DetectLanguageCandidates, LANG-RUST; ReachabilityInputStageLanguageRoutingTests) | RustEntrypointClassifier (main/tokio/actix/rocket/axum/warp) + RustAstEntrypointEnrichmentTests; not under the 018/020 negative-discipline tables | RustSinkMatcher on use-decl module paths + registry entries (§3.2 #4) — post-LANG-RUST the matcher sees use-decl-EXPANDED module paths (exact catalog matches instead of receiver-text suffix luck) | DONE 2026-06-11 — RustSymbolNormalizer (stellaops.rust.symbol-normalizer@1.0.0, Extraction/Rust/): package-anchored (JS/Python precedent) — a node binds only when Symbol == {Package}::{name} for a valid Rust module-path Package and single-identifier name (true exactly for extern-verified synthesized targets and, deliberately, crate-root free functions in a library self-scan). Rule items: (1) identity for canonical-keyed curated graphs; (2) full path {module}::{symbol} (RustSec CanonicalIds reconstruct the published path losslessly); (3) Method-dot refold {module}::{Class}.{method} → {module}::{Class}::{method} (no class erasure — the type is part of the Rust path); (4) generics-stripped (::<…>/<…> removed); (5) Cargo hyphen fold on the NODE side (tracing-core manifest → tracing_core code spelling); (6) flat global::{path} matched whole. First-party shadowing mod time (rustc-consistent resolution), method receivers, scopeless barewords, and deep-module/impl self-scan symbols deliberately do NOT bind. Contract-tested (RustSymbolNormalizerTests, 16: nested modules, refold, generics, hyphen fold, flat, identity, self-scan, use-decl form THROUGH THE REAL extractor + 7 negatives + shape/determinism); recorded in WitnessSink.match_rule | Good — RustSec affects.functions (subset of advisories); nested-shape extractor FIXED 2026-06-10. No crates.io VulnSurfaces lane | Toy svc-07-time-segfault-rust (labels.yaml tier R4, corpus-validated); golden trio via the REAL extractor DONE 2026-06-11 — LangRustGoldenTrioEndToEndTests (5): RUSTSEC-2020-0071-shaped OSV JSON (CVE-2020-26235, affects.functions) → real OsvAffectedSymbolExtractor → bridge → real extractor over a real crate in temp dirs → executor → mapper; reachable (main→handlers::render_clock→time::at_utc) ⇒ reachable:proven + independently-recomputed path_hash witness (match rule + osv source recorded); not-observed (dormant unused_helper calls the symbol, unreachable) ⇒ coverage statement (Rust blind-spot catalog) byte-identical across two fresh full runs; negative (fn-pointer dispatch + function value + REACHABLE first-party shadowing mod time) ⇒ no witness, unknown/symbol_unresolved, nothing stored | Tier-A bitcode pass still a hook; entrypoint classifier not under 018/020 negative tables (its declarative attribute/function-name tables moved to the packs 2026-06-11, D7 completion); live full-loop proof (CONVERGE-VERIFY) |
| Ruby | Standalone — RubyCallGraphExtractor; zero-edge defect FIXED 2026-06-11 (LANG-RUBY, the Python/JS two-pass template): per-file require tables, in-class/sibling and project-global top-level bare-call edges, constant member-call resolution where first-party classes WIN over a same-named required gem (a shadowing class ERB resolves first-party, the Rust shadowed-mod time precedent), require-verified catalog-sink (rb:{app}/sink.{feature}.{Const}.{method}, sink matched on the call TARGET) and external (rb:{app}/external.…, IsSink=false — the surface advisory CanonicalIds bind to) target synthesis, core-constant catalog sinks without require (Marshal.load, bare system(), Sinatra route-block bodies, and offset-preserving comment/string sanitization. Honest skips pinned by negatives: send/__send__/const_get/instance_eval/define_method reflection (deny-list), method_missing, non-constant receivers (locals/ivars/chained ERB.new(x).result), paren-less invocations, ::-form method calls, unverified constants (Rails autoloads), literals/comments (RubyCallGraphExtractorCallEdgeTests, 11). DI-registered + Worker scan-side routed (Gemfile/Gemfile.lock/config.ru); NOT in the build-time set (like Go/Java) | RubyEntrypointClassifier (Rails controllers/actions, Sinatra routes, Sidekiq/ActiveJob, rake, CLI) — drives the golden trio’s controller entrypoints; still not under the 018/020 negative-discipline tables (declarative action-name/parent-class tables moved to the packs 2026-06-11, D7 completion) | RubySinkMatcher over pack data (Kernel.system, Marshal.load, ERB.new, …) — now actually invoked on call TARGETS (definition-side match retained for the library-self-scan case, same shape as JS/Python) | DONE 2026-06-11 — RubySymbolNormalizer (stellaops.ruby.symbol-normalizer@1.0.0, Extraction/Ruby/): package-anchored — a node binds only when Symbol == {Package}.{ConstChain}.{method} for a valid require-feature-path Package (true exactly for require-verified synthesized targets; core-constant sinks carry an uppercase Package and are deliberately NOT advisory-bindable). Rule items: (1) identity for canonical-keyed curated graphs; (2) {feature}::{Const::Chain}.{method} (the canonical module is the gem’s REQUIRE FEATURE PATH, not the gem name when they differ); (3) spelling folds — rubysec # → . and dot-spelled constant chains → :: (class-method vs instance-method distinction not modeled at the lexical tier, documented); (4) narrowed self-scan positive — class methods of feature-path-named projects (scanning the erb gem, ERB#result IS the vulnerable definition); top-level defs do not bind; (5) flat global::{published-string} matched whole, never class-erased. First-party look-alikes (class ERB in svc-app), scopeless barewords, class-less {feature}::{method} canonicals, and case/near-misses deliberately do NOT bind. Contract-tested (RubySymbolNormalizerTests, 19: sink/external/nested/slash-feature/#-fold/refold/flat/identity/self-scan + THROUGH THE REAL extractor + 7 negatives + shape/determinism); recorded in WitnessSink.match_rule | Thin — GHSA/rubysec range-only (structured symbols absent); Gem D6 lane now has the RubyGems downloader, Ruby lexical fingerprinter, alias registration, require-feature MethodKey folding through the real RubySymbolNormalizer, and frozen diff fixtures; golden trio still rides the CURATED tier until full-chain VERIFY fan-out proves computed surfaces through the Ruby evidence-job path; curated rows must carry the require feature path as Module | Toy svc-06-erb-injection-ruby (tier corpus); golden trio via the REAL extractor DONE 2026-06-11 — LangRubyGoldenTrioEndToEndTests (5): curated rows → REAL bridge (EMPTY Concelier projection = the honest range-only posture) → REAL RubyCallGraphExtractor over a real Gemfile project in temp dirs → executor → mapper → verdict objects; reachable (svc-06 shape: GreetingsController#create→render_greeting→sink.erb.ERB.new; stub-guard predicate clears) ⇒ reachable:proven with independently-recomputed path_hash + edge-closure check (match rule + curated tier recorded); not-observed (dormant renderer calls the symbol, no entrypoint reaches it) ⇒ coverage statement (Ruby blind-spot catalog) byte-identical across two fresh full runs; negative (const_get+send dispatch + method_missing + REACHABLE first-party shadowing class ERB whose symbol folds to the EXACT advisory spelling) ⇒ no witness, unknown/symbol_unresolved, nothing stored | Symbol data: Gem D6 patch-diff lane exists in frozen fixtures; full-chain VERIFY fan-out and live full-loop remain before replacing curated-bundle dependency broadly; entrypoint classifier negative tables (pack migration DONE 2026-06-11, D7 completion); build-time-set decision |
| PHP | Standalone — PhpCallGraphExtractor; zero-edge defect FIXED 2026-06-11 (LANG-PHP, the Ruby/Python/JS two-pass template): per-file namespace + use/use function tables, project-global bare-call edges, $this->/self::/parent:: sibling resolution, static-member-call resolution where first-party classes WIN over a same-named use import (a shadowing namespace GuzzleHttp\Psr7; class Message resolves first-party, the Ruby/Rust precedent), use/rooted-verified catalog-sink (php:{app}/sink.{Ns/…}.{Class}.{method}, sink matched on the call TARGET) and external (php:{app}/external.…, IsSink=false — the surface advisory CanonicalIds bind to) target synthesis with Package = {Namespace}, core-class sinks without use (new SimpleXMLElement(, \PDO::query — :: symbol spelling, structurally non-bindable), core global-function sinks (exec(, unserialize( — Package = php), new-expression __construct targets, use-function externals, and offset-preserving comment/string/backtick/heredoc sanitization (PHP 8 #[ attributes survive). Honest skips pinned by negatives: variable functions/methods ($fn(, $obj->$m(, new $cls(), unrooted multi-segment refs in namespaced files, paren-less constructs, literals/comments/heredocs, first-party exec function/method look-alikes (definition-side sink match narrowed to class-scoped catalog rules — userland cannot define PHP’s core exec) (PhpCallGraphExtractorCallEdgeTests, 14). DI-registered + Worker scan-side routed (composer.json/composer.lock); NOT in the build-time set (like Go/Java/Ruby) | PhpEntrypointClassifier (Laravel/Symfony/Slim) — drives the golden trio’s controller entrypoints; still not under the 018/020 negative-discipline tables (declarative action-name/annotation-prefix/parent-class tables moved to the packs 2026-06-11, D7 completion) | PhpSinkMatcher over pack data (exec/unserialize/eval/…) — now actually invoked on call TARGETS; definition-side match narrowed to class-scoped rules (MatchDefinition, library-self-scan case) | DONE 2026-06-11 — PhpSymbolNormalizer (stellaops.php.symbol-normalizer@1.0.0, Extraction/Php/): package-anchored — a node binds only when Symbol == {Package}.{Class}.{method} (class form) or {Package}.{function} (function form) for a valid PSR-4 StudlyCaps namespace-path Package (true exactly for use-verified synthesized targets; first-party symbols are {Ns}\{Class}::{method} — never dotted — so the parse is structurally exclusive). Rule items: (1) identity for canonical-keyed curated graphs; (2) {Namespace}::{Class}.{method} + the natural FQN spelling {Namespace}\{Class}::{method} (curators can paste advisory text verbatim); (3) spelling folds — trailing () strips, -> and all-:: symbol parts fold to the Method form (static vs instance not modeled, documented); (4) function form {Namespace}::{function} for use-function targets; (5) flat global::{FQN-spelling} matched whole, never class-erased; (6) narrowed self-scan positive — first-party namespaced class methods bind iff the namespace’s top segment lowercases to the composer VENDOR segment (scanning guzzlehttp/psr7, the GuzzleHttp\Psr7\Message::parseMessage definition IS the vulnerable code). Composer package names as modules, core sinks, lowercase namespaces, scopeless barewords, and case/near-misses deliberately do NOT bind. Contract-tested (PhpSymbolNormalizerTests, 19: Method/natural/folds/flat/function/identity/self-scan + THROUGH THE REAL extractor + 8 negatives + shape/determinism); recorded in WitnessSink.match_rule | Thin — GHSA Packagist / FriendsOfPHP range-only (structured symbols absent); Composer D6 lane now has the Packagist downloader, PHP lexical fingerprinter, alias registration, PSR-4 MethodKey folding through the real PhpSymbolNormalizer, and frozen diff fixtures; golden trio still rides the CURATED tier until full-chain VERIFY fan-out proves computed surfaces through the PHP evidence-job path; curated rows must carry the PSR-4 namespace as Module | Toy svc-08-psr7-header-parse-php (labels.yaml tier R4, corpus-validated); golden trio via the REAL extractor DONE 2026-06-11 — LangPhpGoldenTrioEndToEndTests (5): curated rows → REAL bridge (EMPTY Concelier projection = the honest range-only posture) → REAL PhpCallGraphExtractor over a real composer project in temp dirs → executor → mapper → verdict objects; reachable (svc-08 shape: GatewayController::store→parse_raw_message→external.GuzzleHttp/Psr7.Message.parseMessage; stub-guard predicate clears) ⇒ reachable:proven with independently-recomputed path_hash + edge-closure check (match rule + curated tier recorded); not-observed (dormant parser calls the symbol, no entrypoint reaches it) ⇒ coverage statement (PHP blind-spot catalog) byte-identical across two fresh full runs; negative (variable-function + call_user_func dispatch + REACHABLE first-party shadowing class Message in the exact advisory namespace) ⇒ no witness, unknown/symbol_unresolved, nothing stored | Symbol data: Composer D6 patch-diff lane exists in frozen fixtures; full-chain VERIFY fan-out and live full-loop remain before replacing curated-bundle dependency broadly; entrypoint classifier negative tables (pack migration DONE 2026-06-11, D7 completion); build-time-set decision |
Honest summary. Go now demonstrates the full in-process loop natively (LANG-GO, 2026-06-11): real vulndb-shaped CanonicalIds resolve against a REAL GoCallGraphExtractor graph through the documented stellaops.go.symbol-normalizer@1.0.0 rule, with the golden trio (proven / not-observed+coverage / dynamic-dispatch negative) green and byte-identical on replay — what remains for Go is the live stack proof (CONVERGE-VERIFY) plus SSA-tool packaging and the build-time-set decision. .NET now demonstrates the same loop through the lightweight extractor (LANG-DOTNET, 2026-06-11) — the dogfood lane: curated/package-symbols-tier CanonicalIds resolve against a REAL DotNetLightweightCallGraphExtractor graph through stellaops.dotnet.symbol-normalizer@1.0.0, golden trio green and byte-identical on replay; its honest limit is DATA, not plumbing — upstream advisories are range-only, so anything above reachable:likely needs curated bundles until the D6 patch-diff factory runs for NuGet. Java now demonstrates the same loop through the real bytecode extractor (LANG-JAVA, 2026-06-11) — and its golden trio was a live forcing-function: it exposed that JavaBytecodeAnalyzer could never parse a real class file (ByteReader passed by value, now fixed — §11 Java row); curated-tier CanonicalIds resolve against a REAL JavaCallGraphExtractor graph through stellaops.java.symbol-normalizer@1.0.0, golden trio green and byte-identical on replay; like .NET its honest limit is DATA (GHSA Maven is range-only), so anything above reachable:likely needs curated bundles until the D6 patch-diff factory runs for Maven. JavaScript/TypeScript now demonstrates the same loop through the real extractor (LANG-JS, 2026-06-11) — flat-GHSA-shaped CanonicalIds resolve against a REAL JavaScriptCallGraphExtractor graph through the package-anchored stellaops.js.symbol-normalizer@1.0.0 rule, golden trio green and byte-identical on replay; the lane’s verdict-changing extractor gap (only catalog sinks were synthesized) is closed by import-verified external-target synthesis, and Bun/Deno manifests route into the lane. Its honest limit remains DATA: GHSA npm symbols are rare flat fields, so most npm verdicts above reachable:likely need curated bundles until the D6 patch-diff factory runs for npm. Python now demonstrates the same loop through the real extractor (LANG-PYTHON, 2026-06-11) — structured-OSV-shaped CanonicalIds resolve against a REAL PythonCallGraphExtractor graph through the package-anchored stellaops.python.symbol-normalizer@1.0.0 rule, golden trio green and byte-identical on replay; the lane’s verdict-changing extractor gap (only catalog sinks were synthesized) is closed by import-verified external-target synthesis. Its honest limit remains DATA: PYSEC/GHSA symbol fields are rare, so most PyPI verdicts above reachable:likely need curated bundles until the D6 patch-diff factory runs for PyPI. Rust now demonstrates the same loop through the real extractor (LANG-RUST, 2026-06-11) — RustSec-shaped CanonicalIds (the §3.5 #4 affects.functions ingestion, end to end) resolve against a REAL RustCallGraphExtractor graph through the package-anchored stellaops.rust.symbol-normalizer@1.0.0 rule, golden trio green and byte-identical on replay; the lane’s verdict-changing extractor gaps (no use-decl expansion, no first-party resolution, no bare-call edges, literal/comment noise) are closed by the two-pass sweep, and Worker scan-side now routes Cargo manifests. Unlike the thin-data lanes its honest limit is COVERAGE, not plumbing: RustSec function lists exist on a subset of advisories, and the Tier-A bitcode pass remains a hook. Ruby now demonstrates the same loop through the real extractor (LANG-RUBY, 2026-06-11) — the lane was extractor-FIRST (it could not execute any part of the loop): the extractor gained the full two-pass sweep (require tables, first-party-wins constant resolution, sink/external target synthesis, route-block bodies, sanitization), DI registration, and Worker routing; curated-tier CanonicalIds (require-feature-path modules) resolve against a REAL RubyCallGraphExtractor graph through the package-anchored stellaops.ruby.symbol-normalizer@1.0.0 rule, golden trio green and byte-identical on replay. Like .NET/Java its honest limit is DATA: GHSA/rubysec are range-only, so anything above reachable:likely needs curated bundles until the gem patch-diff lane gets full-chain VERIFY fan-out (D6 lane fixtures now exist). PHP now demonstrates the same loop through the real extractor (LANG-PHP, 2026-06-11) — the eighth and final lane: it was extractor-FIRST like Ruby (zero-edge extractor, no registration, no routing): the extractor gained the full two-pass sweep (namespace + use/use function tables, first-party-wins resolution, sink/external target synthesis on call TARGETS, heredoc-aware sanitization, class-scoped definition-side matching), DI registration, and Worker routing; curated-tier CanonicalIds (PSR-4 namespace modules) resolve against a REAL PhpCallGraphExtractor graph through the package-anchored stellaops.php.symbol-normalizer@1.0.0 rule, golden trio green and byte-identical on replay. Like .NET/Java/Ruby its honest limit is DATA: GHSA/FriendsOfPHP are range-only, so anything above reachable:likely needs curated bundles until the composer patch-diff lane gets full-chain VERIFY fan-out (D6 lane fixtures now exist). Bun/Deno are folded into the JS lane by decision (this section): same advisory ecosystem (npm), same normalizer — and since LANG-JS their manifests (bun.lockb/bun.lock/deno.json/deno.jsonc) actually route there when no package.json exists.
Final per-language coverage (CONVERGE-VERIFY, 2026-06-11). Every lane now proves the FULL in-process loop in one integration class — advisory symbols → bridge → normalizer → REAL-extractor graph → explicit-sink BFS → lattice verdict → witness → pruned evidence-set referrer (D10, third-party-verified from bytes alone) → policy gate BLOCK under the real shipped pack (D5) — with byte-identical verdict AND referrer payload bytes across two fresh runs (GoldenReachable_FullLoop_ReferrerVerifiesAndPolicyGateBlocks_ByteIdentically per Lang*GoldenTrioEndToEndTests class, all green via built exe: Go 6 / .NET 7 / Java 6 / JS 6 / Python 6 / Rust 6 / Ruby 6 / PHP 6, Failed 0; full Reachability suite Total 863, Failed 0, Skipped 2 pre-existing). Status per lane, with the honest reason for anything short of done:
| Lane | In-process loop (incl. referrer + gate) | Status | Remaining reason |
|---|---|---|---|
| Go | proven | done (in-process) | live full-loop stack proof; SSA-tool packaging (tracked BLOCKED infra: SPRINT_20260612_002 FRONT1-GO-SSA-PACKAGING — GoCallGraphExtractor.cs:67-70 falls back to the genuine lexical tier when the binary is absent); build-time-set decision |
| .NET | proven | done (in-process) | DATA gap CLOSED in-process 2026-06-12: the D6 NuGet patch-diff lane (Phase 4(a) VERIFY note) now carries a range-only CVE to reachable:proven from a computed surface, no curated bundle (Phase4aSurfaceFactoryEndToEndTests); remaining: factory enablement (flag default OFF) + live full-loop |
| Java | proven | done (in-process) | DATA gap CLOSED in-process 2026-06-12 by the D6 Maven patch-diff lane (Phase4aSurfaceFactoryEndToEndTests fan-out); live full-loop |
| JS/TS (+Bun/Deno) | proven | done (in-process) | DATA gap CLOSED in-process 2026-06-12 by the D6 npm patch-diff lane (Phase4aSurfaceFactoryEndToEndTests fan-out); flat-GHSA symbol fields remain rare; live full-loop (Bun/Deno matcher tables in packs since 2026-06-11) |
| Python | proven | done (in-process) | DATA gap CLOSED in-process 2026-06-12 by the D6 PyPI patch-diff lane (Phase4aSurfaceFactoryEndToEndTests fan-out); fingerprinter upgraded 2026-06-13 to a hand-rolled tokenizer-parser (0.8 tier, sprint 20260612_002 LANE-PARSER-HANDROLL — the former BLOCKED item, resolved); live full-loop |
| Rust | proven | done (in-process) | COVERAGE: RustSec affects.functions on a subset of advisories; Tier-A bitcode hook; entrypoint negative tables (pack migration done); live full-loop |
| Ruby | proven | done (in-process) | DATA gap CLOSED in-process 2026-06-12 by the D6 Gem patch-diff lane (Phase4aSurfaceFactoryEndToEndTests fan-out); entrypoint negative tables (pack migration done); build-time-set decision; live full-loop |
| PHP | proven | done (in-process) | DATA gap CLOSED in-process 2026-06-12 by the D6 Composer patch-diff lane (Phase4aSurfaceFactoryEndToEndTests fan-out); entrypoint negative tables (pack migration done); build-time-set decision; live full-loop |
Cross-cutting remainder (not per-language): the D9 vocabulary remainder is CLOSED — the Signals bucket rebase (3a), the CLI lattice DTOs (3b, ReachabilityVerdictDisplay), and the Console FE rebase (3c, normalizeFindingReachabilityState — see the §7.3 audit note) all landed 2026-06-11; the “live full-loop” rows all name the same single gap (the loop fired by a real image scan on a running stack, the §7.1 Phase-0 note’s owed live variant).
Front-1 two-layer coverage matrix (2026-06-12, Phase 4(a) checkpoint — sprint 20260612_002 L4 verification; refreshed by VERIFY fan-out). The CONVERGE-VERIFY table above tracks the in-process reachability loop; this matrix splits each lane into its TWO independent layers so symbol-data starvation is never hidden behind a green extractor: layer 1 = reachability plumbing (call-graph extractor + entrypoint catalog + symbol normalizer, proven by the golden trio), layer 2 = advisory symbol data (native corpus | patch-diff surface | none) and whether the full CVE→symbol→reachable:proven chain has been demonstrated in-process FROM that data source. Fresh layer-1 evidence this date: all 8 Lang*GoldenTrioEndToEndTests classes re-run via the built exe — Total 49, Failed 0 (Go 6 / .NET 7 / Java 6 / JS 6 / Python 6 / Rust 6 / Ruby 6 / PHP 6). Layer-1 ENTRYPOINT-CATALOG status (Front-1 L3 re-verification, 2026-06-12): the declarative entrypoint tables for ALL 6 declarative-table languages (Python + Go/Java/Rust/Ruby/PHP) are signed-pack-backed in stellaops.entrypoints.default.catalog.json (D7 completion commit 610974471d); the classifiers consume the pack via ReachabilityCatalogRuntime.Current.GetEntrypoints(...) and keep genuine structural naming heuristics in-engine; JavaScript deliberately has no declarative table. Byte-parity vs the frozen C# tables (DefaultCatalogPackParityTests, Total 28, Failed 0 via the built StellaOps.Scanner.Contracts.Tests.exe) — the migration the Front-1 brief asked for is already complete and pinned by regression, not owed. Layer-2 fan-out evidence: Phase4aSurfaceFactoryEndToEndTests Total 10, Failed 0 proves NuGet plus Maven/npm/PyPI/RubyGems/Composer through the real VulnSurfaceFeedProcessor path; RubyGems/Packagist advisory spellings canonicalize to gem/composer before persistence and purl readback.
All-8 final re-verification (2026-06-13, full-matrix sign-off — sprint 20260612_002 FULL-MATRIX row). Both layers re-run via freshly built exes and confirmed honest against src/: all 8 Lang*GoldenTrioEndToEndTests + Phase4aSurfaceFactoryEndToEndTests Total 59, Failed 0 (49 trio + 10 fan-out); upgraded-fingerprinter set (LaneRegistrationAndFingerprinterTests + JavaBytecodeRealClassFileTests + EcosystemAliasFoldingTests) Total 35, Failed 0; FULL StellaOps.Scanner.VulnSurfaces.Tests.exe Total 122, Failed 0. Fidelity ladder is source-pinned (ConfidenceMultiplier): bytecode 1.0 (NuGet Cecil / Java) > AST 0.85 (npm Esprima) > hand-rolled tokenizer-parser 0.8 (PyPI/gem/composer) — no lane left at bare regex. The ONLY honest non-proven residuals remain Go-SSA worker-image packaging (docker/infra, BLOCKED GoCallGraphExtractor.cs:67-70) and the per-lane LIVE forcing-function (the explicit owed item below).
| Language | Layer 1 — extractor + entrypoints + normalizer | Layer 2 — symbol-data source | Layer 2 — full in-process chain from that source | Honest status (both layers) |
|---|---|---|---|---|
| Go | proven (trio 6/6 re-run 2026-06-12; SSA-tool worker-image packaging tracked BLOCKED at SPRINT_20260612_002 FRONT1-GO-SSA-PACKAGING — lexical fallback is genuine, GoCallGraphExtractor.cs:67-70) | native corpus — Go vulndb imports[].symbols, ingested by the real OsvAffectedSymbolExtractor (the trio fixture is the real OSV shape, not hand-fed canonicals) | proven — trio carries the vulndb shape end-to-end to reachable:proven; no patch-diff lane needed | proven (in-process); live full-loop owed |
| Rust | proven (trio 6/6; entrypoint classifier outside the 018/020 negative-discipline tables — declarative tables in packs) | native corpus — RustSec affects.functions (present on a SUBSET of advisories) | proven — trio carries RUSTSEC-shaped OSV end-to-end to reachable:proven | proven (in-process); data coverage limited to advisories that publish function lists; live full-loop owed |
| .NET | proven (trio 7/7) | patch-diff surface — nuget D6 lane (Cecil IL fingerprinter, fidelity 1.0); upstream GHSA/NVD are range-only | proven — Phase4aSurfaceFactoryEndToEndTests (5/5, 2026-06-12): computed surface ⇒ function-level surface-tier mappings ⇒ reachable:proven; the contrast is pinned — WITHOUT the surface the same CVE stays unknown/no_symbols_for_cve | proven (in-process); factory flag VulnSurfaces:FeedTrigger default OFF (enablement is a per-deployment decision); live feed-time loop owed |
| Java | proven (trio 6/6) | patch-diff surface — maven D6 lane registered (binary class-file fingerprinter, fidelity 1.0, de-risked against a real frozen javac class — no desync found) | proven — Phase4aSurfaceFactoryEndToEndTests fan-out carries the computed Maven surface through JavaSymbolNormalizer to a proven-capable verdict path | proven (in-process); live full-loop owed |
| JS/TS (+Bun/Deno) | proven (trio 6/6) | patch-diff surface — npm D6 lane registered (real Esprima AST parse, fidelity 0.85); flat-GHSA symbol fields rare | proven — Phase4aSurfaceFactoryEndToEndTests fan-out carries the computed npm surface through JavaScriptSymbolNormalizer to a proven-capable verdict path | proven (in-process); live full-loop owed |
| Python | proven (trio 6/6) | patch-diff surface — pypi D6 lane (hand-rolled tokenizer-parser since 2026-06-13, fidelity 0.8: string/comment-masking tokenizer + indentation-scope def/class parser, multi-line signatures, nested classes; the former regex-only/BLOCKED state is resolved — sprint 20260612_002 LANE-PARSER-HANDROLL) | proven — Phase4aSurfaceFactoryEndToEndTests fan-out carries the computed PyPI surface through PythonSymbolNormalizer to a proven-capable verdict path (re-proven over the new parser, 10/10) | proven (in-process); live full-loop owed |
| Ruby | proven (trio 6/6; entrypoint classifier outside the 018/020 tables) | patch-diff surface — gem D6 lane (hand-rolled tokenizer-parser since 2026-06-13, fidelity 0.8: heredoc/%-literal/regex-literal masking + keyword-balanced def/end blocks, class << self, endless defs — sprint 20260612_002 LANE-PARSER-HANDROLL); GHSA/rubysec range-only | proven — Phase4aSurfaceFactoryEndToEndTests fan-out proves the canonical gem chain through the real RubySymbolNormalizer (re-proven over the new parser, 10/10); EcosystemAliasFoldingTests proves RubyGems advisory spelling persists/readbacks as gem | proven (in-process); live full-loop owed |
| PHP | proven (trio 6/6; entrypoint classifier outside the 018/020 tables) | patch-diff surface — composer D6 lane (hand-rolled tokenizer-parser since 2026-06-13, fidelity 0.8: heredoc/nowdoc masking + balanced-paren params + trait/interface/enum members — sprint 20260612_002 LANE-PARSER-HANDROLL); GHSA/FriendsOfPHP range-only | proven — Phase4aSurfaceFactoryEndToEndTests fan-out proves the canonical composer chain through the real PhpSymbolNormalizer (re-proven over the new parser, 10/10); EcosystemAliasFoldingTests proves Packagist advisory spelling persists/readbacks as composer | proven (in-process); live full-loop owed |
Explicit owed item (out of Front-1 scope): per-lane LIVE forcing-function. Every row above is an in-process capability proof. The live variant — a real advisory ingest with the feed-time factory enabled (VulnSurfaces:FeedTrigger), a real image scan on a running stack, and the policy gate flipping on the computed/native symbol — is owed PER LANE and is recorded here so it cannot be ticked silently (capability-proof ≠ live forcing-function; same class as the §7.1 Phase-0 note’s owed live variant and the CONVERGE-VERIFY “live full-loop” rows).
Appendix A — Verified code findings index
| Finding | Evidence |
|---|---|
| Sink matchers exist for all languages and are invoked | Extraction/{Python,JavaScript,Rust,Java,Go,Bun,Deno,Php,Ruby}/...SinkMatcher.cs; invocation verified in Python (l.94), JS (l.102/121/460/512), Rust (l.282/366), Java (l.103/129), Go (l.183/368), DotNet lightweight (l.483/500) |
| .NET sink match structurally fails (qualified pattern vs lexical symbol) | Contracts/SinkRegistry.cs l.142 (Contains); DotNetLightweightCallGraphExtractor.cs l.889–894 (external symbol = receiver text) |
| Python extractor: zero edges, sink match on definitions | PythonCallGraphExtractor.cs l.477–481 (return []), l.94, Calls = [] at l.406/448 |
| Triplicate enums | Contracts/SinkCategory.cs l.14, Contracts/CallGraphEnums.cs l.41; Reachability/Explanation/PathExplanationModels.cs l.175/212; Reachability/Stack/ReachabilityStack.cs l.162 |
3-layer stack model + conservative evaluator real; ILayer1/2/3Analyzer have no implementations | Stack/ReachabilityStack.cs, Stack/ReachabilityStackEvaluator.cs (truth table l.40–51, low-confidence-gate rule l.132–139); repo-wide search: interfaces only |
| Evidence job: L1 real (BFS, explicit sinks), L2 = optional Ghidra patch-verify, L3 = optional eBPF; honest Unknown fallbacks | Jobs/ReachabilityEvidenceJobExecutor.cs l.79–157, 268–406 |
| Default DI dormant (Null providers) — PARTIALLY WIRED 2026-06-10 (snapshot provider + symbol bridge real; evidence storage/Ghidra/eBPF still null defaults) | Was: Reachability/ServiceCollectionExtensions.cs l.52–76. Now: WorkerCallGraphSnapshotProvider + ICallGraphSnapshotRegistry (Jobs/WorkerCallGraphSnapshotProvider.cs), AffectedSymbolCveSymbolMappingService (Services/), Worker HTTP adapter ConcelierHttpAffectedSymbolProvider; tests WorkerCallGraphSnapshotProviderTests, AffectedSymbolCveSymbolMappingServiceTests; end-to-end floor proof Phase0CveSinkFloorEndToEndTests (§7.1 Phase 0 exit criterion, in-process) |
| Deterministic BFS with explicit sink sets | CallGraph/Analysis/ReachabilityAnalyzer.cs l.50–119 (WIT-007B) |
Concelier AffectedSymbol projection wired at observation upsert; Postgres store; fail-closed default | Concelier.Core/Signals/AffectedSymbol.cs, IAffectedSymbolProvider.cs; PostgresAdvisoryObservationStore.cs l.64–72; UnsupportedAffectedSymbolStore.cs |
| Symbol extractor missed nested Go vulndb / RustSec shapes (FIXED 2026-06-10, §3.5 #4) | Was: IAffectedSymbolProvider.cs l.589–649 (flat field probe, no descent); OSV DTO preserves ecosystem_specific (OsvVulnerabilityDto.cs l.88). Now: ExtractGoImportSymbols/ExtractRustAffectsFunctions in the same file + ExtractionShape provenance tag; tests OsvAffectedSymbolExtractorTests |
| Canonical advisory model = ranges only (correct) | Concelier.Models/Advisory.cs l.157–230 (AffectedPackage, no symbol field) |
| Patch-diff pipeline built out for D6 managed lanes | Scanner.VulnSurfaces/ (downloaders ×6, fingerprinters ×6, MethodDiffEngine, TriggerMethodExtractor, internal graph builders ×4, Postgres repo, VulnSurfaceService confidence ladder l.59–93; Ruby/PHP lexical lanes intentionally do not add internal graph builders yet) |
| Witness/PoE/slices/signing primitives complete; claim confirmation composition partial | Witnesses/PathWitness.cs (path_hash, node_hashes, claim_id, observation_type), WitnessMatcher.cs (not consumed by production code), SuppressionWitness*, Slices/ (DSSE signer, verdict computer, replay), SUBGRAPH_EXTRACTION.md; FunctionMap/Verification/ClaimVerifier.cs verifies node-hash coverage and has no claim-ID field |
| Build-time referrer: whole compact graph, stub-guard, no reader | Sbom.BuildTime/ReachabilityCallGraphGenerator.cs (compact-v2+gzip l.307–394, stub-guard l.480–486); invoked from Cli/Commands/Sbom/SbomAttachCommand.cs via BuildTimeOciAttachService |
| Scoring lattice live in Signals (v0 buckets, v1 design) | docs/modules/reach-graph/guides/lattice.md |
| CVE→symbol source ladder already documented | docs/modules/reach-graph/guides/cve-symbol-mapping.md (surfaces > linksets > offline bundles) |
Appendix B — Related in-repo material
docs/modules/reach-graph/guides/— lattice, cve-symbol-mapping, function-level-evidence, patch-oracles, replay-verification, policy-gate.src/Scanner/AGENTS.md— extractor coverage/limit tables (Sprint 018/020), Smart-Diff, drift, slices (3800-series), witness contracts.docs/modules/scanner/reachability-drift.md,reachability-ground-truth-corpus.md,design/native-reachability-plan.md.- ADR-025 (operator-signed decisions) — pattern for D5 countersigning.
