@clear-capabilities/agentic-security-scanner 0.168.0 → 0.170.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/bin/agentic-security.js +567 -0
  3. package/dist/2199.index.js +1 -1
  4. package/dist/2675.index.js +174 -0
  5. package/dist/2923.index.js +369 -1
  6. package/dist/4142.index.js +6 -6
  7. package/dist/4700.index.js +151 -0
  8. package/dist/5014.index.js +2 -2
  9. package/dist/5086.index.js +645 -0
  10. package/dist/5144.index.js +2 -2
  11. package/dist/5206.index.js +32 -0
  12. package/dist/642.index.js +197 -0
  13. package/dist/6447.index.js +131 -0
  14. package/dist/6645.index.js +87 -0
  15. package/dist/6730.index.js +2 -2
  16. package/dist/6932.index.js +2 -2
  17. package/dist/7055.index.js +369 -0
  18. package/dist/7232.index.js +24 -0
  19. package/dist/7552.index.js +2 -2
  20. package/dist/7952.index.js +301 -0
  21. package/dist/8714.index.js +2 -2
  22. package/dist/9102.index.js +110 -0
  23. package/dist/9800.index.js +1 -1
  24. package/dist/agentic-security.mjs +2 -2
  25. package/dist/agentic-security.mjs.sha256 +1 -1
  26. package/package.json +3 -3
  27. package/src/artifact/CLAUDE.md +100 -0
  28. package/src/artifact/filesystem.js +100 -0
  29. package/src/artifact/image-archive.js +159 -0
  30. package/src/artifact/inventory.js +69 -0
  31. package/src/artifact/layer-secrets.js +92 -0
  32. package/src/artifact/mismatch.js +72 -0
  33. package/src/artifact/provenance-graph.js +115 -0
  34. package/src/artifact/safe-tar.js +175 -0
  35. package/src/artifact/signature.js +156 -0
  36. package/src/artifact/vex.js +94 -0
  37. package/src/artifact/vuln-correlate.js +17 -0
  38. package/src/lineage/CLAUDE.md +19 -0
  39. package/src/lineage/observation-adapters.js +368 -0
  40. package/src/lineage/runtime-assurance.js +135 -0
  41. package/src/lineage/runtime-observation.js +37 -4
  42. package/src/posture/artifact-registry.js +8 -0
package/CHANGELOG.md CHANGED
@@ -9,6 +9,80 @@
9
9
  > make the history less accurate, not more.
10
10
 
11
11
 
12
+ ## 0.170.0 - source-to-artifact security & provenance: a new `src/artifact/` package, `image`/`provenance` CLI, VEX
13
+
14
+ Binds a finding to the actual **built artifact** (a container image, today) that ships it — closing the gap
15
+ between "this dependency has a CVE" and "this specific image digest actually ships it." A new, isolated
16
+ `scanner/src/artifact/` package reuses the existing SCA pipeline (manifest parsers, OS-package-DB parser,
17
+ OSV/KEV/EPSS correlation) rather than building a parallel one.
18
+
19
+ **OCI image scanning + inventory.** A hand-rolled, hardened tar/gzip reader (`safe-tar.js`) — hard caps on entry
20
+ count/size/archive size, a native decompression-bomb guard, and a path-safety check that flags (never silently
21
+ normalizes) a `..`/absolute tar entry path. Parses a `docker save`-shaped archive or an OCI-layout archive into
22
+ digest/OS/architecture/base-image/layers/entrypoint/build-labels, with OS and application packages extracted
23
+ through the exact same parsers every other SCA finding in this codebase uses. Never executes the image — no
24
+ `docker run`, no daemon required for the archive path.
25
+
26
+ **Layer secret analysis.** A secret added in one layer and deleted by a later overlay whiteout is still sitting
27
+ in that earlier layer's raw tar bytes — detected and reported (value always masked/redacted, the detector is
28
+ injected, same shape `posture/secret-history.js` already uses for the git-history analog).
29
+
30
+ **Signature + attestation verification**, states exactly `verified`/`invalid`/`missing`/`unavailable` — a
31
+ detached-signature check over the image digest, plus REAL DSSE (Pre-Authentication Encoding) verification for
32
+ in-toto/SLSA provenance attestations, so a real cosign-keyed attestation bundle verifies here given the matching
33
+ public key. Never treats missing material as verified.
34
+
35
+ **The provenance graph** (Git Commit → Build → OCI Digest → SBOM Component → Finding → Deployment) and
36
+ **source-vs-artifact mismatch detection** — the PRD's own "Killer Demonstration": a developer fixes a
37
+ vulnerability in source, but production is still running the old image. `SOURCE: FIXED` / `DEPLOYED ARTIFACT:
38
+ STILL VULNERABLE` / `PRODUCTION EXPOSURE: PRESENT`, proven end-to-end against the real built CLI. **VEX**
39
+ statements (affected/not affected/fixed/under investigation) — `not affected` only ever from positive evidence
40
+ (component absent, or confirmed unreachable), never from absence of information.
41
+
42
+ **CLI:** `agentic-security image scan/verify`, `agentic-security provenance trace`, `agentic-security supply vex`
43
+ — the PRD's own literal CLI shape. New slash commands `/image`, `/provenance`; `/supply` gained a `--vex` mode.
44
+
45
+ **Deliberately NOT done, disclosed:** a local Docker daemon image reference and a remote OCI registry reference
46
+ as `image scan` inputs (only local docker-archive/oci-archive tar files); keyless Rekor-transparency-log
47
+ signature verification. See `docs/implementation-status/source-to-artifact-provenance.md`.
48
+
49
+ ## 0.169.0 - runtime-corroborated security twin: 4 new telemetry adapters, honest runtime assurance, a `runtime` CLI
50
+
51
+ Extends the existing Milestone 5 "Runtime-Corroborated Digital Twin" machinery (closed-world `RuntimeObservation`
52
+ contract, a 3-rung static-to-runtime match ladder, a non-mutating overlay) rather than building a second one —
53
+ closing the real gap that machinery had: only one hand-authored JSONL telemetry format could feed it.
54
+
55
+ **Four new telemetry adapters.** `otel-json` (OpenTelemetry JSON trace export, one draft per span), `envoy-access-log`
56
+ (Envoy's default JSON access log, including `response_flags` → an allow/deny security decision), `k8s-audit-log`
57
+ (Kubernetes `audit.k8s.io` JSONL — `objectRef`/`responseStatus.code`, deliberately never `user.username`), and a
58
+ generic JSON-lines `http-access-log`. Each is a pure, zero-fs parser reading only approved, non-payload metadata
59
+ (destination host/port/scheme, service name, protocol/TLS, store table, queue topic) and dropping everything else
60
+ (query strings, headers, usernames, bodies) — matching the existing contract's own reject-not-redact discipline.
61
+ One new, narrowly-scoped attribute key, `network.securityDecision` (a closed `allowed|denied` enum, not a free-form
62
+ identifier), carries the one new fact these formats need that the existing vocabulary didn't have a slot for.
63
+
64
+ **Honest runtime assurance.** A new `runtime-assurance.js` reports `runtimeStatus: complete|partial|unavailable` —
65
+ `unavailable` whenever no observation store was ever consulted, never a silent "no risk." `runtime status`,
66
+ `runtime reconcile` (diagnostic drift detection against the current graph, never auto-rewriting a stored
67
+ observation) and `runtime query` (two real graph-backed queries, plus honest, named disclosure of the three
68
+ PRD-example queries this architecture genuinely cannot answer) round out a new top-level `runtime` CLI dispatcher;
69
+ `runtime ingest` is a thin shim over the already-shipped `dataflow observations import` — zero duplicated logic.
70
+
71
+ **A real, grounded demonstration, not an asserted-away fixture.** A small app (`scanner/test/fixtures/runtime-twin/`)
72
+ proves all 5 of the PRD's named fixture properties in one scenario against the real built CLI: a database write
73
+ that executes and is observed allowed; an outbound call that never executes and is never observed; an unrecognized
74
+ host that IS observed but matches nothing in the static graph (reported `unmatched`, never dropped); a PII flow
75
+ crossing a trust boundary to a third party whose destination can never be runtime-corroborated by any adapter here
76
+ — a genuine, disclosed architectural limit; and a queue publish observed denied by authorization.
77
+
78
+ **What this deliberately does not do, disclosed rather than silently dropped.** No Data Flow Explorer UI/HTTP-server
79
+ wiring (`scanner/src/server/CLAUDE.md`'s own deferred list already named `runtime/` among the endpoints the
80
+ read-only local server does not serve — bolting one on here without that file's own scoping-doc-plus-test-suite
81
+ rigor would be a real regression). No authenticated-identity field (the existing contract omits it as a
82
+ payload-smuggling risk). No route/function-symbol match rungs or the 3 additional PRD-named edge states
83
+ (`RUNTIME_BLOCKED`/`RUNTIME_POLICY_VIOLATION`/`RUNTIME_EXPLOIT_CORRELATED`) — real, separately-scoped future work.
84
+ See `docs/implementation-status/runtime-corroborated-twin.md` for the full itemized status.
85
+
12
86
  ## 0.168.0 - fleet: multi-repository scan orchestration and cross-repository dependency blast radius
13
87
 
14
88
  A real, honestly-scoped slice of the organization-wide security graph recommendation: a new CLI command group,
@@ -5561,6 +5561,512 @@ async function cmdDataflowTwin(args) {
5561
5561
  return 0;
5562
5562
  }
5563
5563
 
5564
+ // ─────────────────────────────────────────────────────────────────────
5565
+ // PRD "Runtime-Corroborated Security Twin" — the `runtime` dispatcher.
5566
+ //
5567
+ // `runtime ingest`/`runtime status`/`runtime reconcile`/`runtime query`
5568
+ // is the literal CLI shape that PRD names. It is NOT a parallel command
5569
+ // surface: `ingest` is a thin shim over the already-shipped
5570
+ // `dataflow observations import` (zero duplicated logic — it reshapes
5571
+ // `args._` and delegates); `status`/`reconcile`/`query` are genuinely new
5572
+ // (the assurance report and the reconciliation/query reports did not
5573
+ // exist before this PRD), but all three are built entirely on the
5574
+ // already-shipped `correlateObservations`/`matchObservationToGraph`/
5575
+ // `loadObservationImports` — no second correlation engine, no second
5576
+ // observation store.
5577
+
5578
+ async function cmdRuntimeIngest(args) {
5579
+ // args._ = ['runtime', 'ingest', <path>?]. cmdDataflowObservationsImport
5580
+ // reads args._[3] as its own path slot (its own dispatch assumes
5581
+ // ['dataflow', 'observations', 'import', <path>?]) — reshape rather
5582
+ // than duplicate its ~150 lines of validation/import logic.
5583
+ const target = args._[2] || '.';
5584
+ const shimmed = { ...args, _: ['dataflow', 'observations', 'import', target] };
5585
+ return cmdDataflowObservationsImport(shimmed);
5586
+ }
5587
+
5588
+ async function _loadRuntimeInputs(targetAbs) {
5589
+ const { loadSignedGraph } = await import('../src/server/graph-loader.js');
5590
+ const loaded = loadSignedGraph(targetAbs);
5591
+ if (!loaded.ok) return { ok: false, message: loaded.message };
5592
+
5593
+ const dir = statePath(targetAbs, 'runtime-observations');
5594
+ let importRecords = null;
5595
+ let observations = null;
5596
+ if (fs.existsSync(dir)) {
5597
+ const store = await import('../src/lineage/observation-store.js');
5598
+ importRecords = store.loadObservationImports(targetAbs);
5599
+ observations = store.loadObservations(targetAbs);
5600
+ }
5601
+ return { ok: true, graph: loaded.graph, importRecords, observations };
5602
+ }
5603
+
5604
+ async function cmdRuntimeStatus(args) {
5605
+ const target = args._[2] || '.';
5606
+ const targetAbs = path.resolve(target);
5607
+ const format = args.flags.format ?? 'json';
5608
+ if (format !== 'json' && format !== 'markdown') {
5609
+ process.stderr.write(`agentic-security runtime status: --format must be one of json|markdown (got ${JSON.stringify(format)}).\n`);
5610
+ return 2;
5611
+ }
5612
+
5613
+ const loaded = await _loadRuntimeInputs(targetAbs);
5614
+ if (!loaded.ok) {
5615
+ process.stderr.write(`agentic-security runtime status: ${loaded.message}\n`);
5616
+ return 1;
5617
+ }
5618
+
5619
+ const environment = typeof args.flags.environment === 'string' ? args.flags.environment : null;
5620
+ const windowStart = typeof args.flags['window-start'] === 'string' ? args.flags['window-start'] : null;
5621
+ const windowEnd = typeof args.flags['window-end'] === 'string' ? args.flags['window-end'] : null;
5622
+
5623
+ const { correlateObservations } = await import('../src/lineage/observation-correlation.js');
5624
+ const correlation = correlateObservations(loaded.graph, loaded.observations, { environment, windowStart, windowEnd });
5625
+
5626
+ const { computeRuntimeAssurance } = await import('../src/lineage/runtime-assurance.js');
5627
+ const assurance = computeRuntimeAssurance({ importRecords: loaded.importRecords, correlation });
5628
+
5629
+ if (format === 'json') {
5630
+ process.stdout.write(JSON.stringify(assurance, null, 2) + '\n');
5631
+ return 0;
5632
+ }
5633
+
5634
+ const lines = [];
5635
+ lines.push('# Runtime Assurance', '');
5636
+ lines.push(`**runtimeStatus:** \`${assurance.runtimeStatus}\``, '');
5637
+ lines.push(`- Telemetry window: ${assurance.telemetryWindow.start ?? '(none)'} .. ${assurance.telemetryWindow.end ?? '(none)'}`);
5638
+ lines.push(`- Last observed: ${assurance.freshness.lastObservedAt ?? '(none)'}${assurance.freshness.stale ? ' (STALE)' : ''}`);
5639
+ lines.push(`- Mapping: ${assurance.mappingPercentage === null ? '(no observations)' : `${assurance.mappingPercentage}%`} (${assurance.unmatchedObservationCount} of ${assurance.totalObservationCount} unmatched)`);
5640
+ lines.push(`- Parse failures: ${assurance.parseFailures} — ${assurance.parseFailuresNote}`);
5641
+ lines.push('', '## Limitations', '');
5642
+ for (const l of assurance.limitations) lines.push(`- ${l}`);
5643
+ lines.push('');
5644
+ process.stdout.write(lines.join('\n'));
5645
+ return 0;
5646
+ }
5647
+
5648
+ async function cmdRuntimeReconcile(args) {
5649
+ const target = args._[2] || '.';
5650
+ const targetAbs = path.resolve(target);
5651
+
5652
+ const loaded = await _loadRuntimeInputs(targetAbs);
5653
+ if (!loaded.ok) {
5654
+ process.stderr.write(`agentic-security runtime reconcile: ${loaded.message}\n`);
5655
+ return 1;
5656
+ }
5657
+ if (loaded.observations === null) {
5658
+ process.stdout.write(JSON.stringify({ checked: 0, drifted: [], note: 'No runtime observation store exists for this scan — nothing to reconcile.' }, null, 2) + '\n');
5659
+ return 0;
5660
+ }
5661
+
5662
+ // Deliberately diagnostic-only: `matchObservationToGraph` is re-run here
5663
+ // purely to COMPARE against each record's own already-stored match — it
5664
+ // never rewrites the stored record. `observation-correlation.js`'s own
5665
+ // header explains why `correlateObservations` itself must never re-run
5666
+ // the ladder (a stored observation must stay honest about what it was
5667
+ // actually checked against at import time); `reconcile` exists
5668
+ // precisely to surface drift for an operator to decide on, not to
5669
+ // silently apply it.
5670
+ const { matchObservationToGraph } = await import('../src/lineage/observation-correlation.js');
5671
+ const drifted = [];
5672
+ for (const obs of loaded.observations) {
5673
+ const fresh = matchObservationToGraph(loaded.graph, obs);
5674
+ const same = fresh.matchMethod === obs.matchMethod
5675
+ && fresh.matchConfidence === obs.matchConfidence
5676
+ && JSON.stringify([...fresh.matchedFlowIds].sort()) === JSON.stringify([...obs.matchedFlowIds].sort());
5677
+ if (!same) {
5678
+ drifted.push({
5679
+ observationId: obs.id,
5680
+ stored: { matchMethod: obs.matchMethod, matchConfidence: obs.matchConfidence, matchedFlowIds: obs.matchedFlowIds },
5681
+ currentGraph: { matchMethod: fresh.matchMethod, matchConfidence: fresh.matchConfidence, matchedFlowIds: fresh.matchedFlowIds },
5682
+ });
5683
+ }
5684
+ }
5685
+ process.stdout.write(JSON.stringify({
5686
+ checked: loaded.observations.length,
5687
+ drifted,
5688
+ note: drifted.length > 0
5689
+ ? 'These observations would match differently against the CURRENT graph than what is stored — the graph changed since import. Re-import with `runtime ingest` to update the stored match; nothing was changed automatically.'
5690
+ : 'Every stored observation still matches the current graph identically.',
5691
+ }, null, 2) + '\n');
5692
+ return 0;
5693
+ }
5694
+
5695
+ const _RUNTIME_SENSITIVE_CLASSES = ['PII', 'PHI', 'PCI', 'FIN', 'CREDENTIALS'];
5696
+
5697
+ const _RUNTIME_QUERIES = Object.freeze({
5698
+ 'internet-facing-to-sensitive': {
5699
+ description: 'Internet-facing flows observed reaching sensitive data (PRD example: "Show Internet-facing flows observed reaching PCI data").',
5700
+ run(graph, correlation) {
5701
+ const nodesById = new Map(graph.nodes.map((n) => [n.id, n]));
5702
+ const dataElementsById = new Map(graph.dataElements.map((d) => [d.id, d]));
5703
+ return graph.flows.filter((f) => {
5704
+ if (correlation.byFlow[f.id]?.layer !== 'runtime_observed') return false;
5705
+ const sink = nodesById.get(f.sink);
5706
+ if (sink?.externality?.value !== 'external') return false;
5707
+ return f.dataElementIds.some((id) => (dataElementsById.get(id)?.dataClasses ?? []).some((c) => _RUNTIME_SENSITIVE_CLASSES.includes(c)));
5708
+ }).map((f) => f.id);
5709
+ },
5710
+ },
5711
+ 'sensitive-crossing-boundary': {
5712
+ description: 'Sensitive-data paths observed crossing a trust boundary (PRD example: "Show sensitive-data paths observed crossing trust boundaries").',
5713
+ run(graph, correlation) {
5714
+ const edgesById = new Map(graph.edges.map((e) => [e.id, e]));
5715
+ const dataElementsById = new Map(graph.dataElements.map((d) => [d.id, d]));
5716
+ return graph.flows.filter((f) => {
5717
+ if (correlation.byFlow[f.id]?.layer !== 'runtime_observed') return false;
5718
+ const crosses = f.edgeIds.some((id) => (edgesById.get(id)?.boundaryCrossings ?? []).length > 0);
5719
+ if (!crosses) return false;
5720
+ return f.dataElementIds.some((id) => (dataElementsById.get(id)?.dataClasses ?? []).some((c) => _RUNTIME_SENSITIVE_CLASSES.includes(c)));
5721
+ }).map((f) => f.id);
5722
+ },
5723
+ },
5724
+ });
5725
+
5726
+ // PRD-named example queries this architecture cannot honestly answer —
5727
+ // disclosed, never silently omitted. "Runtime flows absent from the
5728
+ // static graph" needs a config-declared/auto-correlated edge mechanism
5729
+ // (M5 7a / M2 F2-F3, both out of scope here — every RuntimeObservation
5730
+ // matches an EXISTING static node, it never mints a new one). "Vulnerable
5731
+ // functions actually exercised in production" and "identities using
5732
+ // risky paths" both need identity/function-level telemetry this
5733
+ // contract's closed-world schema deliberately omits (see this PRD's
5734
+ // implementation-status doc).
5735
+ const _RUNTIME_QUERIES_NOT_SUPPORTED = Object.freeze({
5736
+ 'runtime-flows-absent-from-static': 'Needs a config-declared/auto-correlated edge mechanism (not built — see M5 7a / M2 F2-F3 in scanner/src/lineage/CLAUDE.md) — every RuntimeObservation matches an existing static graph node, it never mints a new one.',
5737
+ 'vulnerable-functions-in-production': 'Needs function/symbol-level runtime telemetry; this adapter set only matches destinations (route/service/table/topic), never a function symbol.',
5738
+ 'identities-using-risky-paths': 'The RuntimeObservation contract deliberately carries no identity field (a payload-smuggling risk) — see this PRD\'s implementation-status doc for why.',
5739
+ });
5740
+
5741
+ async function cmdRuntimeQuery(args) {
5742
+ const name = args._[2];
5743
+ if (!name || args.flags.list) {
5744
+ process.stdout.write('Supported queries:\n');
5745
+ for (const [k, v] of Object.entries(_RUNTIME_QUERIES)) process.stdout.write(` ${k} — ${v.description}\n`);
5746
+ process.stdout.write('\nNamed but NOT supported by this architecture (disclosed, not silently omitted):\n');
5747
+ for (const [k, v] of Object.entries(_RUNTIME_QUERIES_NOT_SUPPORTED)) process.stdout.write(` ${k} — ${v}\n`);
5748
+ // --list is always a successful, intentional operation (exit 0);
5749
+ // calling `runtime query` with neither a name nor --list is the only
5750
+ // real misuse here.
5751
+ return args.flags.list ? 0 : 2;
5752
+ }
5753
+
5754
+ if (Object.prototype.hasOwnProperty.call(_RUNTIME_QUERIES_NOT_SUPPORTED, name)) {
5755
+ process.stdout.write(JSON.stringify({ query: name, supported: false, reason: _RUNTIME_QUERIES_NOT_SUPPORTED[name] }, null, 2) + '\n');
5756
+ return 0;
5757
+ }
5758
+ const query = _RUNTIME_QUERIES[name];
5759
+ if (!query) {
5760
+ process.stderr.write(`agentic-security runtime query: unrecognized query "${name}" — run with --list to see every supported (and disclosed-unsupported) query name.\n`);
5761
+ return 2;
5762
+ }
5763
+
5764
+ const target = args._[3] || '.';
5765
+ const targetAbs = path.resolve(target);
5766
+ const loaded = await _loadRuntimeInputs(targetAbs);
5767
+ if (!loaded.ok) {
5768
+ process.stderr.write(`agentic-security runtime query: ${loaded.message}\n`);
5769
+ return 1;
5770
+ }
5771
+
5772
+ const environment = typeof args.flags.environment === 'string' ? args.flags.environment : null;
5773
+ const { correlateObservations } = await import('../src/lineage/observation-correlation.js');
5774
+ const correlation = correlateObservations(loaded.graph, loaded.observations, { environment, windowStart: null, windowEnd: null });
5775
+
5776
+ const flowIds = query.run(loaded.graph, correlation);
5777
+ process.stdout.write(JSON.stringify({ query: name, supported: true, flowIds }, null, 2) + '\n');
5778
+ return 0;
5779
+ }
5780
+
5781
+ // ─── PRD 5: Source-to-Artifact Security & Provenance ───────────────────────
5782
+ // `image scan/verify`, `provenance trace`, `supply vex`. See
5783
+ // docs/implementation-status/source-to-artifact-provenance.md for the full
5784
+ // scope, naming-collision discipline (why the graph field below is
5785
+ // `artifactProvenanceGraph`, never a bare `.provenance`), and what is
5786
+ // deliberately NOT implemented (local-daemon image / remote registry
5787
+ // reference inputs; keyless Rekor signature lookup).
5788
+
5789
+ function _readFileOrError(target) {
5790
+ const abs = path.resolve(target);
5791
+ if (!fs.existsSync(abs)) return { ok: false, message: `no such file: ${target}` };
5792
+ if (!fs.statSync(abs).isFile()) return { ok: false, message: `${target} is not a file` };
5793
+ try {
5794
+ return { ok: true, buf: fs.readFileSync(abs) };
5795
+ } catch (err) {
5796
+ return { ok: false, message: `failed to read ${target}: ${err.message}` };
5797
+ }
5798
+ }
5799
+
5800
+ function _artifactScanDigestHex(digestOrTarget) {
5801
+ const s = String(digestOrTarget || '');
5802
+ const hex = s.startsWith('sha256:') ? s.slice(7) : s;
5803
+ return /^[0-9a-f]{64}$/.test(hex) ? hex : null;
5804
+ }
5805
+
5806
+ // Parses an archive fresh and runs inventory + vuln correlation — the one
5807
+ // real analysis path every `image scan`/`provenance trace`/`supply vex`
5808
+ // invocation that isn't reading a stored result funnels through.
5809
+ async function _scanArchiveFresh(buf) {
5810
+ const { buildImageInventory } = await import('../src/artifact/inventory.js');
5811
+ const { ImageArchiveError } = await import('../src/artifact/image-archive.js');
5812
+ const { TarSafetyError } = await import('../src/artifact/safe-tar.js');
5813
+ const { parseManifests, queryOSV } = await import('../src/sca/index.js');
5814
+ const { correlateImageVulnerabilities } = await import('../src/artifact/vuln-correlate.js');
5815
+ const { analyzeLayerSecrets } = await import('../src/artifact/layer-secrets.js');
5816
+ const { scanCredentials } = await import('../src/secrets/index.js');
5817
+
5818
+ let built;
5819
+ try {
5820
+ built = buildImageInventory(buf, { manifestParser: parseManifests });
5821
+ } catch (err) {
5822
+ if (err instanceof ImageArchiveError || err instanceof TarSafetyError) return { ok: false, message: err.message };
5823
+ throw err;
5824
+ }
5825
+ const { vulnerabilities } = await correlateImageVulnerabilities({
5826
+ inventory: built.inventory, fileContents: built.mergedFilesystem.fileContents, queryOSVFn: queryOSV,
5827
+ });
5828
+ const secrets = analyzeLayerSecrets({
5829
+ readLayersResult: built.readLayersResult, deletedPaths: built.mergedFilesystem.deletedPaths, detectFn: scanCredentials,
5830
+ });
5831
+ return {
5832
+ ok: true,
5833
+ built,
5834
+ record: {
5835
+ inventory: built.inventory,
5836
+ vulnerabilities,
5837
+ secrets: { deletedButRecoverable: secrets.deletedButRecoverable, totalFindings: secrets.findings.length },
5838
+ scannedAt: new Date().toISOString(),
5839
+ },
5840
+ };
5841
+ }
5842
+
5843
+ async function _persistArtifactScan(record) {
5844
+ const { statePath, safeWriteState } = await import('../src/posture/state-dir.js');
5845
+ const root = process.cwd();
5846
+ const hex = _artifactScanDigestHex(record.inventory.digest);
5847
+ if (!hex) return false; // defensive: digest is always well-formed sha256:<hex> from image-archive.js
5848
+ return safeWriteState(statePath(root, 'artifact-scans', `${hex}.json`), JSON.stringify(record, null, 2), { category: 'artifact-scans' });
5849
+ }
5850
+
5851
+ // Resolves `target` to a scan record: a digest/sha256:<hex> that matches a
5852
+ // PREVIOUSLY `image scan`-ned, persisted record is loaded from state;
5853
+ // anything else is treated as an archive file path and parsed fresh. This
5854
+ // is what lets `provenance trace <digest>` and `supply vex <digest>` work
5855
+ // after an earlier `image scan <path>` without re-reading the archive.
5856
+ async function _resolveArtifactScan(target) {
5857
+ const hex = _artifactScanDigestHex(target);
5858
+ if (hex) {
5859
+ const { statePath } = await import('../src/posture/state-dir.js');
5860
+ const storedPath = statePath(process.cwd(), 'artifact-scans', `${hex}.json`);
5861
+ if (fs.existsSync(storedPath)) {
5862
+ try {
5863
+ return { ok: true, record: JSON.parse(fs.readFileSync(storedPath, 'utf8')) };
5864
+ } catch (err) {
5865
+ return { ok: false, message: `stored scan at ${storedPath} is corrupt: ${err.message}` };
5866
+ }
5867
+ }
5868
+ return { ok: false, message: `no stored scan for digest ${target} — run "image scan" on the archive first` };
5869
+ }
5870
+ const read = _readFileOrError(target);
5871
+ if (!read.ok) return read;
5872
+ const scanned = await _scanArchiveFresh(read.buf);
5873
+ if (!scanned.ok) return scanned;
5874
+ return { ok: true, record: scanned.record };
5875
+ }
5876
+
5877
+ async function cmdImageScan(args) {
5878
+ const target = args._[2];
5879
+ if (!target) {
5880
+ process.stderr.write('agentic-security image scan: provide a path to a docker-archive or oci-archive tar (OCI tar / Docker tar — see commands/image.md for the two supported input shapes).\n');
5881
+ return 2;
5882
+ }
5883
+ const format = args.flags.format ?? 'json';
5884
+ if (format !== 'json' && format !== 'cli') {
5885
+ process.stderr.write('agentic-security image scan: --format must be json|cli.\n');
5886
+ return 2;
5887
+ }
5888
+ const read = _readFileOrError(target);
5889
+ if (!read.ok) { process.stderr.write(`agentic-security image scan: ${read.message}\n`); return 1; }
5890
+
5891
+ const scanned = await _scanArchiveFresh(read.buf);
5892
+ if (!scanned.ok) { process.stderr.write(`agentic-security image scan: ${scanned.message}\n`); return 1; }
5893
+
5894
+ await _persistArtifactScan(scanned.record);
5895
+
5896
+ if (format === 'json') {
5897
+ process.stdout.write(JSON.stringify(scanned.record, null, 2) + '\n');
5898
+ return 0;
5899
+ }
5900
+ const { inventory, vulnerabilities, secrets } = scanned.record;
5901
+ const vulnCount = vulnerabilities.filter((v) => v.type === 'vulnerable_dep').length;
5902
+ process.stdout.write([
5903
+ `Image digest: ${inventory.digest} (${inventory.digestKind})`,
5904
+ `OS/architecture: ${inventory.os}/${inventory.architecture}`,
5905
+ `OS packages: ${inventory.osPackages.length}, application packages: ${inventory.appPackages.length}`,
5906
+ `Vulnerabilities (OSV): ${vulnCount}`,
5907
+ `Secrets deleted-but-recoverable from an earlier layer: ${secrets.deletedButRecoverable.length}`,
5908
+ inventory.materializationTruncated ? 'NOTE: some layer content exceeded the scan size cap and was skipped.' : '',
5909
+ ].filter(Boolean).join('\n') + '\n');
5910
+ return 0;
5911
+ }
5912
+
5913
+ async function cmdImageVerify(args) {
5914
+ const target = args._[2];
5915
+ if (!target) {
5916
+ process.stderr.write('agentic-security image verify: provide a path to a docker-archive or oci-archive tar, or a previously-scanned digest.\n');
5917
+ return 2;
5918
+ }
5919
+ const resolved = await _resolveArtifactScan(target);
5920
+ if (!resolved.ok) { process.stderr.write(`agentic-security image verify: ${resolved.message}\n`); return 1; }
5921
+ const { inventory } = resolved.record;
5922
+
5923
+ const { verifyImageSignature, verifyProvenanceAttestation } = await import('../src/artifact/signature.js');
5924
+
5925
+ let signature = null;
5926
+ let publicKeyPem = null;
5927
+ let attestation = null;
5928
+ if (typeof args.flags.signature === 'string') {
5929
+ const r = _readFileOrError(args.flags.signature);
5930
+ if (!r.ok) { process.stderr.write(`agentic-security image verify: --signature: ${r.message}\n`); return 1; }
5931
+ signature = r.buf;
5932
+ }
5933
+ if (typeof args.flags['public-key'] === 'string') {
5934
+ const r = _readFileOrError(args.flags['public-key']);
5935
+ if (!r.ok) { process.stderr.write(`agentic-security image verify: --public-key: ${r.message}\n`); return 1; }
5936
+ publicKeyPem = r.buf.toString('utf8');
5937
+ }
5938
+ if (typeof args.flags.attestation === 'string') {
5939
+ const r = _readFileOrError(args.flags.attestation);
5940
+ if (!r.ok) { process.stderr.write(`agentic-security image verify: --attestation: ${r.message}\n`); return 1; }
5941
+ try { attestation = JSON.parse(r.buf.toString('utf8')); } catch (err) {
5942
+ process.stderr.write(`agentic-security image verify: --attestation is not valid JSON: ${err.message}\n`);
5943
+ return 1;
5944
+ }
5945
+ }
5946
+
5947
+ const signatureResult = verifyImageSignature({ digest: inventory.digest, signature, publicKeyPem });
5948
+ const attestationResult = verifyProvenanceAttestation({ digest: inventory.digest, envelope: attestation, publicKeyPem });
5949
+
5950
+ const result = { digest: inventory.digest, signature: signatureResult, attestation: attestationResult };
5951
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
5952
+ // Exit non-zero only on a definite "invalid" — "missing"/"unavailable" are
5953
+ // honest reports of absent material, not a failed check, and must not be
5954
+ // conflated with a cryptographic failure a CI gate should actually block on.
5955
+ return (signatureResult.state === 'invalid' || attestationResult.state === 'invalid') ? 1 : 0;
5956
+ }
5957
+
5958
+ async function cmdProvenanceTrace(args) {
5959
+ const target = args._[2];
5960
+ if (!target) {
5961
+ process.stderr.write('agentic-security provenance trace: provide a path to an image archive, or a previously-scanned digest.\n');
5962
+ return 2;
5963
+ }
5964
+ const resolved = await _resolveArtifactScan(target);
5965
+ if (!resolved.ok) { process.stderr.write(`agentic-security provenance trace: ${resolved.message}\n`); return 1; }
5966
+ const { inventory, vulnerabilities } = resolved.record;
5967
+
5968
+ let deployment = null;
5969
+ if (typeof args.flags['deployment-id'] === 'string') {
5970
+ deployment = {
5971
+ id: args.flags['deployment-id'],
5972
+ environment: typeof args.flags['deployment-environment'] === 'string' ? args.flags['deployment-environment'] : null,
5973
+ digest: typeof args.flags['deployment-digest'] === 'string' ? args.flags['deployment-digest'] : null,
5974
+ evidence: 'operator-declared',
5975
+ };
5976
+ }
5977
+
5978
+ const { buildArtifactProvenanceGraph, traceArtifactProvenanceGraph } = await import('../src/artifact/provenance-graph.js');
5979
+ const artifactProvenanceGraph = buildArtifactProvenanceGraph({ inventory, vulnerabilities, deployment });
5980
+ const commitNode = artifactProvenanceGraph.nodes.find((n) => n.kind === 'gitCommit');
5981
+ const trace = traceArtifactProvenanceGraph(artifactProvenanceGraph, commitNode.id);
5982
+
5983
+ let mismatch = null;
5984
+ if (typeof args.flags.source === 'string') {
5985
+ const sourceRoot = path.resolve(args.flags.source);
5986
+ const lastScanPath = path.join(sourceRoot, '.agentic-security', 'last-scan.json');
5987
+ if (!fs.existsSync(lastScanPath)) {
5988
+ process.stderr.write(`agentic-security provenance trace: --source "${args.flags.source}" has no .agentic-security/last-scan.json — run "scan" there first.\n`);
5989
+ return 1;
5990
+ }
5991
+ const sourceScan = JSON.parse(fs.readFileSync(lastScanPath, 'utf8'));
5992
+ const { detectSourceArtifactMismatch } = await import('../src/artifact/mismatch.js');
5993
+ mismatch = detectSourceArtifactMismatch({
5994
+ sourceVulnerabilities: sourceScan.supplyChain || [],
5995
+ artifactVulnerabilities: vulnerabilities,
5996
+ artifactComponents: [...inventory.osPackages, ...inventory.appPackages],
5997
+ });
5998
+ }
5999
+
6000
+ const format = args.flags.format ?? 'json';
6001
+ if (format === 'json') {
6002
+ process.stdout.write(JSON.stringify({ artifactProvenanceGraph, trace, mismatch }, null, 2) + '\n');
6003
+ return 0;
6004
+ }
6005
+
6006
+ const lines = [`Artifact digest: ${inventory.digest}`, `Source commit: ${commitNode.known ? commitNode.sha : '(not declared — no org.opencontainers.image.revision label)'}`];
6007
+ if (mismatch) {
6008
+ lines.push('');
6009
+ if (mismatch.scenarioA.length > 0) {
6010
+ lines.push('SOURCE: FIXED');
6011
+ lines.push('DEPLOYED ARTIFACT: STILL VULNERABLE');
6012
+ lines.push('PRODUCTION EXPOSURE: PRESENT');
6013
+ lines.push(` ${mismatch.scenarioA.length} vulnerabilit${mismatch.scenarioA.length === 1 ? 'y' : 'ies'} present in this artifact, already fixed in current source: ${mismatch.scenarioA.map((v) => v.osvId).join(', ')}`);
6014
+ }
6015
+ if (mismatch.scenarioB.length > 0) {
6016
+ lines.push(`${mismatch.scenarioA.length > 0 ? '' : 'SOURCE: VULNERABLE\n'}BUILT ARTIFACT: AFFECTED COMPONENT ABSENT`);
6017
+ lines.push(` ${mismatch.scenarioB.length} vulnerabilit${mismatch.scenarioB.length === 1 ? 'y' : 'ies'} flagged in current source did not actually ship in this artifact: ${mismatch.scenarioB.map((v) => v.osvId).join(', ')}`);
6018
+ }
6019
+ if (!mismatch.hasMismatch) lines.push('No mismatch: current source and this deployed artifact agree.');
6020
+ }
6021
+ process.stdout.write(lines.join('\n') + '\n');
6022
+ return 0;
6023
+ }
6024
+
6025
+ async function cmdSupplyVex(args) {
6026
+ const target = args._[2];
6027
+ if (!target) {
6028
+ process.stderr.write('agentic-security supply vex: provide a path to an image archive, or a previously-scanned digest.\n');
6029
+ return 2;
6030
+ }
6031
+ const resolved = await _resolveArtifactScan(target);
6032
+ if (!resolved.ok) { process.stderr.write(`agentic-security supply vex: ${resolved.message}\n`); return 1; }
6033
+ const { inventory, vulnerabilities } = resolved.record;
6034
+
6035
+ let reachabilityByKey = new Map();
6036
+ if (typeof args.flags.source === 'string') {
6037
+ const sourceRoot = path.resolve(args.flags.source);
6038
+ const lastScanPath = path.join(sourceRoot, '.agentic-security', 'last-scan.json');
6039
+ if (fs.existsSync(lastScanPath)) {
6040
+ const sourceScan = JSON.parse(fs.readFileSync(lastScanPath, 'utf8'));
6041
+ for (const sc of (sourceScan.supplyChain || [])) {
6042
+ if (sc.type !== 'vulnerable_dep') continue;
6043
+ const key = sc.purl || `${sc.ecosystem}:${sc.name}`;
6044
+ const reachable = sc.functionReachable === 'reachable' ? true : sc.functionReachable === 'unreachable' ? false : null;
6045
+ if (reachable !== null) reachabilityByKey.set(key, { reachable, reason: sc.reachabilityTier || null });
6046
+ }
6047
+ }
6048
+ }
6049
+
6050
+ const { deriveVexStatus } = await import('../src/artifact/vex.js');
6051
+ const artifactComponents = [...inventory.osPackages, ...inventory.appPackages];
6052
+ const statements = vulnerabilities.filter((v) => v.type === 'vulnerable_dep').map((v) => {
6053
+ const key = v.purl || `${v.ecosystem}:${v.name}`;
6054
+ const reachability = reachabilityByKey.get(key) || null;
6055
+ const vex = deriveVexStatus({ vulnerability: v, artifactComponents, reachability });
6056
+ return { purl: v.purl || null, name: v.name, ecosystem: v.ecosystem, osvId: v.osvId, ...vex };
6057
+ });
6058
+
6059
+ const format = args.flags.format ?? 'json';
6060
+ if (format === 'json') {
6061
+ process.stdout.write(JSON.stringify({ digest: inventory.digest, statements }, null, 2) + '\n');
6062
+ return 0;
6063
+ }
6064
+ const lines = [`VEX for ${inventory.digest}:`];
6065
+ for (const s of statements) lines.push(` ${s.osvId} (${s.name}): ${s.status}${s.justification ? ` [${s.justification}]` : ''}`);
6066
+ process.stdout.write(lines.join('\n') + '\n');
6067
+ return 0;
6068
+ }
6069
+
5564
6070
  // Mirrors posture/fix-history.js's own _writeAtomicAndSync (temp file in
5565
6071
  // the same directory, fsync, then rename over the target) — a crash
5566
6072
  // mid-write must never leave recipient-profiles.json as invalid JSON,
@@ -7288,6 +7794,67 @@ async function main() {
7288
7794
  }
7289
7795
  break;
7290
7796
  }
7797
+ case 'runtime': {
7798
+ // NOT a `dataflow` subcommand — PRD "Runtime-Corroborated Security
7799
+ // Twin" names this literal CLI shape (`runtime ingest/status/
7800
+ // reconcile/query`). `ingest` is a thin shim over the already-
7801
+ // shipped `dataflow observations import`; `status`/`reconcile`/
7802
+ // `query` are new, built entirely on the already-shipped
7803
+ // correlation/match/store functions. See cmdRuntimeStatus's own
7804
+ // header comment above.
7805
+ const sub = args._[1];
7806
+ if (sub === 'ingest') { process.exit(await cmdRuntimeIngest(args)); }
7807
+ else if (sub === 'status') { process.exit(await cmdRuntimeStatus(args)); }
7808
+ else if (sub === 'reconcile') { process.exit(await cmdRuntimeReconcile(args)); }
7809
+ else if (sub === 'query') { process.exit(await cmdRuntimeQuery(args)); }
7810
+ else {
7811
+ process.stderr.write(`agentic-security runtime: unrecognized sub-command "${sub}" — must be "ingest", "status", "reconcile", or "query".\n`);
7812
+ process.exit(2);
7813
+ }
7814
+ break;
7815
+ }
7816
+ case 'image': {
7817
+ // PRD 5 (Source-to-Artifact Security & Provenance) names this
7818
+ // literal CLI shape. `scan` parses + correlates; `verify` checks
7819
+ // signature/attestation material only (never re-runs correlation).
7820
+ const sub = args._[1];
7821
+ if (sub === 'scan') { process.exit(await cmdImageScan(args)); }
7822
+ else if (sub === 'verify') { process.exit(await cmdImageVerify(args)); }
7823
+ else {
7824
+ process.stderr.write(`agentic-security image: unrecognized sub-command "${sub}" — must be "scan" or "verify".\n`);
7825
+ process.exit(2);
7826
+ }
7827
+ break;
7828
+ }
7829
+ case 'provenance': {
7830
+ // NOT `posture/provenance/`'s git-commit finding provenance, NOT
7831
+ // `sca/sigstore-verify.js`'s package-build provenance — this is PRD
7832
+ // 5's own artifact provenance graph (Git Commit -> Build -> OCI
7833
+ // Digest -> SBOM Component -> Finding -> Deployment). See
7834
+ // docs/implementation-status/source-to-artifact-provenance.md's
7835
+ // naming-discipline section.
7836
+ const sub = args._[1];
7837
+ if (sub === 'trace') { process.exit(await cmdProvenanceTrace(args)); }
7838
+ else {
7839
+ process.stderr.write(`agentic-security provenance: unrecognized sub-command "${sub}" — must be "trace".\n`);
7840
+ process.exit(2);
7841
+ }
7842
+ break;
7843
+ }
7844
+ case 'supply': {
7845
+ // The `/supply` SLASH COMMAND (commands/supply.md) is a different
7846
+ // layer entirely — Claude Code UI sugar that shells out to `scan
7847
+ // --only sca`/`cve-watch`. This is PRD 5's own literal CLI shape,
7848
+ // `agentic-security supply vex`, operating on an IMAGE ARTIFACT
7849
+ // rather than a source directory.
7850
+ const sub = args._[1];
7851
+ if (sub === 'vex') { process.exit(await cmdSupplyVex(args)); }
7852
+ else {
7853
+ process.stderr.write(`agentic-security supply: unrecognized sub-command "${sub}" — must be "vex".\n`);
7854
+ process.exit(2);
7855
+ }
7856
+ break;
7857
+ }
7291
7858
  case 'remediation': {
7292
7859
  // NOT a `dataflow` subcommand — this writes operator/incident
7293
7860
  // state (the append-only remediation ledger), never the scanned