@clear-capabilities/agentic-security-scanner 0.168.0 → 0.169.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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,43 @@
9
9
  > make the history less accurate, not more.
10
10
 
11
11
 
12
+ ## 0.169.0 - runtime-corroborated security twin: 4 new telemetry adapters, honest runtime assurance, a `runtime` CLI
13
+
14
+ Extends the existing Milestone 5 "Runtime-Corroborated Digital Twin" machinery (closed-world `RuntimeObservation`
15
+ contract, a 3-rung static-to-runtime match ladder, a non-mutating overlay) rather than building a second one —
16
+ closing the real gap that machinery had: only one hand-authored JSONL telemetry format could feed it.
17
+
18
+ **Four new telemetry adapters.** `otel-json` (OpenTelemetry JSON trace export, one draft per span), `envoy-access-log`
19
+ (Envoy's default JSON access log, including `response_flags` → an allow/deny security decision), `k8s-audit-log`
20
+ (Kubernetes `audit.k8s.io` JSONL — `objectRef`/`responseStatus.code`, deliberately never `user.username`), and a
21
+ generic JSON-lines `http-access-log`. Each is a pure, zero-fs parser reading only approved, non-payload metadata
22
+ (destination host/port/scheme, service name, protocol/TLS, store table, queue topic) and dropping everything else
23
+ (query strings, headers, usernames, bodies) — matching the existing contract's own reject-not-redact discipline.
24
+ One new, narrowly-scoped attribute key, `network.securityDecision` (a closed `allowed|denied` enum, not a free-form
25
+ identifier), carries the one new fact these formats need that the existing vocabulary didn't have a slot for.
26
+
27
+ **Honest runtime assurance.** A new `runtime-assurance.js` reports `runtimeStatus: complete|partial|unavailable` —
28
+ `unavailable` whenever no observation store was ever consulted, never a silent "no risk." `runtime status`,
29
+ `runtime reconcile` (diagnostic drift detection against the current graph, never auto-rewriting a stored
30
+ observation) and `runtime query` (two real graph-backed queries, plus honest, named disclosure of the three
31
+ PRD-example queries this architecture genuinely cannot answer) round out a new top-level `runtime` CLI dispatcher;
32
+ `runtime ingest` is a thin shim over the already-shipped `dataflow observations import` — zero duplicated logic.
33
+
34
+ **A real, grounded demonstration, not an asserted-away fixture.** A small app (`scanner/test/fixtures/runtime-twin/`)
35
+ proves all 5 of the PRD's named fixture properties in one scenario against the real built CLI: a database write
36
+ that executes and is observed allowed; an outbound call that never executes and is never observed; an unrecognized
37
+ host that IS observed but matches nothing in the static graph (reported `unmatched`, never dropped); a PII flow
38
+ crossing a trust boundary to a third party whose destination can never be runtime-corroborated by any adapter here
39
+ — a genuine, disclosed architectural limit; and a queue publish observed denied by authorization.
40
+
41
+ **What this deliberately does not do, disclosed rather than silently dropped.** No Data Flow Explorer UI/HTTP-server
42
+ wiring (`scanner/src/server/CLAUDE.md`'s own deferred list already named `runtime/` among the endpoints the
43
+ read-only local server does not serve — bolting one on here without that file's own scoping-doc-plus-test-suite
44
+ rigor would be a real regression). No authenticated-identity field (the existing contract omits it as a
45
+ payload-smuggling risk). No route/function-symbol match rungs or the 3 additional PRD-named edge states
46
+ (`RUNTIME_BLOCKED`/`RUNTIME_POLICY_VIOLATION`/`RUNTIME_EXPLOIT_CORRELATED`) — real, separately-scoped future work.
47
+ See `docs/implementation-status/runtime-corroborated-twin.md` for the full itemized status.
48
+
12
49
  ## 0.168.0 - fleet: multi-repository scan orchestration and cross-repository dependency blast radius
13
50
 
14
51
  A real, honestly-scoped slice of the organization-wide security graph recommendation: a new CLI command group,
@@ -5561,6 +5561,223 @@ 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
+
5564
5781
  // Mirrors posture/fix-history.js's own _writeAtomicAndSync (temp file in
5565
5782
  // the same directory, fsync, then rename over the target) — a crash
5566
5783
  // mid-write must never leave recipient-profiles.json as invalid JSON,
@@ -7288,6 +7505,25 @@ async function main() {
7288
7505
  }
7289
7506
  break;
7290
7507
  }
7508
+ case 'runtime': {
7509
+ // NOT a `dataflow` subcommand — PRD "Runtime-Corroborated Security
7510
+ // Twin" names this literal CLI shape (`runtime ingest/status/
7511
+ // reconcile/query`). `ingest` is a thin shim over the already-
7512
+ // shipped `dataflow observations import`; `status`/`reconcile`/
7513
+ // `query` are new, built entirely on the already-shipped
7514
+ // correlation/match/store functions. See cmdRuntimeStatus's own
7515
+ // header comment above.
7516
+ const sub = args._[1];
7517
+ if (sub === 'ingest') { process.exit(await cmdRuntimeIngest(args)); }
7518
+ else if (sub === 'status') { process.exit(await cmdRuntimeStatus(args)); }
7519
+ else if (sub === 'reconcile') { process.exit(await cmdRuntimeReconcile(args)); }
7520
+ else if (sub === 'query') { process.exit(await cmdRuntimeQuery(args)); }
7521
+ else {
7522
+ process.stderr.write(`agentic-security runtime: unrecognized sub-command "${sub}" — must be "ingest", "status", "reconcile", or "query".\n`);
7523
+ process.exit(2);
7524
+ }
7525
+ break;
7526
+ }
7291
7527
  case 'remediation': {
7292
7528
  // NOT a `dataflow` subcommand — this writes operator/incident
7293
7529
  // state (the append-only remediation ledger), never the scanned