@clear-capabilities/agentic-security-scanner 0.167.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,85 @@
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
+
49
+ ## 0.168.0 - fleet: multi-repository scan orchestration and cross-repository dependency blast radius
50
+
51
+ A real, honestly-scoped slice of the organization-wide security graph recommendation: a new CLI command group,
52
+ `fleet init|scan|status|resume|query`, built almost entirely on existing machinery rather than a new engine.
53
+
54
+ **The manifest and the scan.** `fleet.yaml` declares an organization and a list of `{id, path}` repositories
55
+ (plus `workers` and `perRepoTimeout`), validated closed-world — an unknown key, a duplicate id, a non-kebab id or
56
+ an unresolvable path refuses the whole manifest. `fleet scan fleet.yaml` drives the EXISTING `posture/fleet.js`
57
+ (`runFleet`) over that list: bounded concurrency, a resumable on-disk checkpoint, per-repository isolation (one
58
+ repo exploding never takes the run down), and an honest rollup (a failed repo is reported failed, never folded
59
+ into "0 findings"). No new scanning engine was written for this.
60
+
61
+ **The new part: a cross-repository dependency graph.** Built purely from each repository's own package manifest
62
+ (`package.json`/`go.mod`/`Cargo.toml`/`requirements.txt`/`pyproject.toml` — name plus dependencies), with no
63
+ deployment configuration required, so a fleet of plain application repositories works immediately. `fleet query
64
+ --depends-on <repo-id>` answers "which repositories depend on this one, directly or transitively, and through
65
+ what path" — a real question about organization-wide blast radius, not a guess.
66
+
67
+ **A real, grounded demonstration, not an asserted-away fixture.** A four-repository synthetic organization
68
+ (frontend, payments-api, shared-auth-lib, payments-worker) where only `shared-auth-lib` carries an actual
69
+ command-injection vulnerability the deterministic engine genuinely finds (the same confirmed-detected shape —
70
+ `req.body.host` into `child_process.exec` — already used in this repository's own fixtures). The blast-radius
71
+ query correctly reports `frontend` and `payments-api` as direct dependents and `payments-worker` as a transitive
72
+ one, through `payments-api`, at the right depth, with the right path.
73
+
74
+ **A bug this process itself caught, twice.** A first draft of the fleet CLI wrapped `runScan()`'s own
75
+ `{ scan, meta }` return value in a SECOND `{ scan }` object; `runFleet`'s internal summarizer reads fields
76
+ defensively rather than throwing on the wrong shape, so every repository silently reported zero findings with no
77
+ error of any kind. Found by running the CLI against the fixture and noticing the finding count was wrong, not by
78
+ the automated test — whose own fixture at the time also happened to use a vulnerability shape the deterministic
79
+ engine does not actually flag (an unlabeled function parameter reaching `exec`, no recognized untrusted source).
80
+ Both are fixed, and the fixture now uses the same confirmed-detected shape the test asserts against.
81
+
82
+ **Still open, and disclosed as such** (`docs/implementation-status/fleet.md`): no Internet-facing/regulated-data
83
+ overlay — the fuller "which Internet-facing app can reach PCI data through this component" question needs the
84
+ existing deployment-boundary graph's trust-zone model layered on top of this dependency graph, not attempted
85
+ here; no natural-language query parser (`--depends-on <repo-id>` is the one supported shape); no SQLite-backed
86
+ storage (reuses the existing flat-JSON fleet checkpoint; `posture/portfolio/backend.js`'s existing storage
87
+ abstraction is the natural place for a SQLite implementation later); no persisted job queue with lease/retry
88
+ (reuses in-process `runFleet` rather than the heavier, operator-only `portfolio/work-units.js` scheduler); matching
89
+ across repositories is by declared package name only, with no ecosystem disambiguation.
90
+
12
91
  ## 0.167.0 - execution-backed exploit proof, and a self-learning rule compiler that turns one confirmed finding into a deterministic detection rule
13
92
 
14
93
  Two new, independently opt-in capabilities, each off by default until explicitly invoked.
@@ -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,
@@ -7115,6 +7332,7 @@ async function main() {
7115
7332
  case 'triage': process.exit(await cmdTriage(args));
7116
7333
  case 'org-scan': process.exit(await cmdOrgScan(args));
7117
7334
  case 'rules': process.exit(await cmdRules(args));
7335
+ case 'fleet': process.exit(await (await import('../src/posture/fleet/cli.js')).cmdFleet(args, path.resolve(args.flags.root || '.')));
7118
7336
  case 'rule': process.exit(await cmdRule(args));
7119
7337
  case 'tickets': process.exit(await cmdTickets(args));
7120
7338
  case 'secure': process.exit(await cmdSecure(args));
@@ -7287,6 +7505,25 @@ async function main() {
7287
7505
  }
7288
7506
  break;
7289
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
+ }
7290
7527
  case 'remediation': {
7291
7528
  // NOT a `dataflow` subcommand — this writes operator/incident
7292
7529
  // state (the append-only remediation ledger), never the scanned