@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
@@ -262,6 +262,370 @@ export function parseNativeJsonlObservations(text, context) {
262
262
  return { drafts, errors };
263
263
  }
264
264
 
265
+ // ── The four PRD "Runtime-Corroborated Security Twin" adapters ─────────
266
+ //
267
+ // Each of the four parsers below follows the identical contract the
268
+ // native-jsonl adapter above already established: pure (zero imports, no
269
+ // fs, never throws), `(text, context) -> {drafts, errors}`, one
270
+ // `{line, message}` error per malformed line (never a value echo), every
271
+ // OTHER line still parses. Each produces drafts carrying ONLY attribute
272
+ // keys already on `RUNTIME_ATTRIBUTE_KEYS` (`runtime-observation.js`) —
273
+ // none of these parsers imports that file (this module stays zero-import
274
+ // by contract), so an attribute key typo here is caught one layer up, at
275
+ // import time, exactly like the native-jsonl adapter's own documented
276
+ // two-layer split. Each adapter deliberately DROPS every field of its
277
+ // source format with no safe, approved destination — a raw URL path/query
278
+ // string, a user agent, a client IP, a trace/span id, a username/identity
279
+ // — rather than inventing a new attribute key to carry it (PRD "Privacy
280
+ // Protection" P0-7: schema/field classification only, never a captured
281
+ // value). What each adapter keeps and drops is documented at its own
282
+ // definition.
283
+
284
+ const _IDENTIFIER_ATTR_RE = /^[A-Za-z0-9._:-]{1,128}$/;
285
+
286
+ // Only ever WRITES a value that already looks like an approved identifier
287
+ // — never force-sanitizes an unsafe value into a misleadingly different
288
+ // one. Returns `null` (meaning "drop this attribute") for anything that
289
+ // doesn't already look safe, rather than guessing.
290
+ function _safeIdentifierOrNull(v) {
291
+ if (typeof v !== 'string' || v.length === 0) return null;
292
+ return _IDENTIFIER_ATTR_RE.test(v) ? v : null;
293
+ }
294
+
295
+ // `"host:port"` -> `{host, port}` (port `null` when absent/unparseable).
296
+ // Never a full URL parse — this shape (Envoy's `upstream_host`, a bare
297
+ // `host:port` pair) has no scheme/path to confuse it with.
298
+ function _splitHostPort(v) {
299
+ if (typeof v !== 'string' || v.length === 0) return { host: null, port: null };
300
+ const i = v.lastIndexOf(':');
301
+ if (i === -1) return { host: _safeIdentifierOrNull(v), port: null };
302
+ const host = v.slice(0, i);
303
+ const portStr = v.slice(i + 1);
304
+ const port = /^[0-9]{1,5}$/.test(portStr) ? Number(portStr) : null;
305
+ return { host: _safeIdentifierOrNull(host), port };
306
+ }
307
+
308
+ function _draftBase(context, environmentOverride) {
309
+ const { version, adapter, source, environment, windowStart, windowEnd, importedAt, retention } = context;
310
+ return {
311
+ version, adapter, source,
312
+ environment: environmentOverride !== undefined ? environmentOverride : environment,
313
+ windowStart, windowEnd, retention, importedAt,
314
+ };
315
+ }
316
+
317
+ /**
318
+ * OpenTelemetry JSON trace export (the standard OTLP/JSON `resourceSpans`
319
+ * document — a SINGLE JSON object, never JSONL; this is the format
320
+ * `otlptracehttp` with `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/json`
321
+ * writes). One draft per span. Reads only well-known OpenTelemetry
322
+ * semantic-convention attribute keys — `server.address`/`net.peer.name`,
323
+ * `server.port`/`net.peer.port`, `url.scheme`/`http.scheme`,
324
+ * `network.protocol.name`, `db.system`/`db.name`,
325
+ * `messaging.destination.name` — mapped onto the approved
326
+ * `destination.*`/`network.protocol`/`schema.name` keys; `resource`-level
327
+ * `service.name` is carried as `service.name`. Every other span
328
+ * attribute (`http.url`'s full query string, `http.request.header.*`,
329
+ * `gen_ai.*` prompt/completion content, trace/span ids themselves) is
330
+ * dropped — none has a safe, approved destination. `eventCountBand` is
331
+ * always `'1'`: a single exported span is a single observed event, never
332
+ * an aggregate count OTel itself does not report.
333
+ */
334
+ export function parseOtelJsonObservations(text, context) {
335
+ if (typeof text !== 'string' || !_isPlainObject(context)) {
336
+ return { drafts: [], errors: [{ line: 0, message: 'parseOtelJsonObservations requires a string text and a plain-object context' }] };
337
+ }
338
+
339
+ let doc;
340
+ try {
341
+ doc = JSON.parse(text);
342
+ } catch {
343
+ return { drafts: [], errors: [{ line: 0, message: 'invalid JSON: expected one OTLP/JSON resourceSpans document' }] };
344
+ }
345
+ if (!_isPlainObject(doc) || !Array.isArray(doc.resourceSpans)) {
346
+ return { drafts: [], errors: [{ line: 0, message: 'expected a JSON object with a top-level "resourceSpans" array (the OTLP/JSON export shape)' }] };
347
+ }
348
+
349
+ const _attrMap = (attrs) => {
350
+ const m = {};
351
+ if (!Array.isArray(attrs)) return m;
352
+ for (const a of attrs) {
353
+ if (!_isPlainObject(a) || typeof a.key !== 'string' || !_isPlainObject(a.value)) continue;
354
+ const v = a.value;
355
+ if (typeof v.stringValue === 'string') m[a.key] = v.stringValue;
356
+ else if (typeof v.intValue === 'number' || typeof v.intValue === 'string') m[a.key] = String(v.intValue);
357
+ else if (typeof v.boolValue === 'boolean') m[a.key] = v.boolValue;
358
+ }
359
+ return m;
360
+ };
361
+
362
+ // Nanoseconds-since-epoch (OTLP's own wire unit) as a string or number
363
+ // -> ISO-8601. Returns null (never a fabricated "now") when absent or
364
+ // unparseable, letting the caller fall back to the import window.
365
+ const _isoFromUnixNano = (v) => {
366
+ if (v === undefined || v === null) return null;
367
+ const n = typeof v === 'string' ? Number(v) : v;
368
+ if (!Number.isFinite(n) || n <= 0) return null;
369
+ const ms = Math.round(n / 1e6);
370
+ const d = new Date(ms);
371
+ return Number.isFinite(d.getTime()) ? d.toISOString() : null;
372
+ };
373
+
374
+ const drafts = [];
375
+ for (const rs of doc.resourceSpans) {
376
+ if (!_isPlainObject(rs)) continue;
377
+ const resourceAttrs = _attrMap(rs.resource?.attributes);
378
+ const serviceName = _safeIdentifierOrNull(resourceAttrs['service.name']);
379
+
380
+ for (const ss of Array.isArray(rs.scopeSpans) ? rs.scopeSpans : []) {
381
+ if (!_isPlainObject(ss)) continue;
382
+ for (const span of Array.isArray(ss.spans) ? ss.spans : []) {
383
+ if (!_isPlainObject(span)) continue;
384
+ const a = _attrMap(span.attributes);
385
+
386
+ const attributes = {};
387
+ if (serviceName) attributes['service.name'] = serviceName;
388
+ const host = _safeIdentifierOrNull(a['server.address'] ?? a['net.peer.name']);
389
+ if (host) attributes['destination.host'] = host;
390
+ const portRaw = a['server.port'] ?? a['net.peer.port'];
391
+ const port = /^[0-9]{1,5}$/.test(String(portRaw ?? '')) ? Number(portRaw) : null;
392
+ if (port !== null) attributes['destination.port'] = port;
393
+ const scheme = _safeIdentifierOrNull(a['url.scheme'] ?? a['http.scheme']);
394
+ if (scheme) attributes['destination.scheme'] = scheme;
395
+ const protocol = _safeIdentifierOrNull(a['network.protocol.name']);
396
+ if (protocol) attributes['network.protocol'] = protocol;
397
+ const storeService = _safeIdentifierOrNull(a['db.system'] ?? a['db.name'] ?? a['messaging.destination.name']);
398
+ if (storeService) attributes['destination.service'] = storeService;
399
+
400
+ if (Object.keys(attributes).length === 0) continue; // nothing safe to report for this span
401
+
402
+ const at = _isoFromUnixNano(span.startTimeUnixNano) ?? context.windowStart;
403
+ drafts.push({
404
+ ..._draftBase(context),
405
+ attributes,
406
+ eventCountBand: '1',
407
+ firstObservedAt: at,
408
+ lastObservedAt: _isoFromUnixNano(span.endTimeUnixNano) ?? at,
409
+ });
410
+ }
411
+ }
412
+ }
413
+
414
+ return { drafts, errors: [] };
415
+ }
416
+
417
+ /**
418
+ * Envoy's default JSON access log (`json_format` logging, one JSON
419
+ * object per line). Reads `upstream_host` (split into
420
+ * `destination.host`/`destination.port`), `upstream_cluster` (as
421
+ * `destination.service`), `protocol` (normalized — Envoy writes
422
+ * `"HTTP/1.1"`, which contains `/` and is not an approved identifier
423
+ * shape; lowercased and `/` stripped, e.g. `"http1.1"`), and
424
+ * `response_flags` (`'-'`/empty means no flag, i.e. allowed; a non-empty
425
+ * flags string, e.g. `"UAEX"` for unauthorized, is treated as `'denied'`
426
+ * via `network.securityDecision`). `start_time`/`end_time` (or
427
+ * `start_time` alone, with `duration` in milliseconds added) feed
428
+ * `firstObservedAt`/`lastObservedAt`. Everything else — client address,
429
+ * request path/query, user agent, request id, response code's own exact
430
+ * value — is dropped.
431
+ */
432
+ export function parseEnvoyAccessLogObservations(text, context) {
433
+ if (typeof text !== 'string' || !_isPlainObject(context)) {
434
+ return { drafts: [], errors: [{ line: 0, message: 'parseEnvoyAccessLogObservations requires a string text and a plain-object context' }] };
435
+ }
436
+
437
+ const drafts = [];
438
+ const errors = [];
439
+ const lines = text.split(/\r?\n/);
440
+ for (let i = 0; i < lines.length; i++) {
441
+ const rawLine = lines[i];
442
+ if (rawLine.trim().length === 0) continue;
443
+ const line = i + 1;
444
+
445
+ let record;
446
+ try {
447
+ record = JSON.parse(rawLine);
448
+ } catch {
449
+ errors.push({ line, message: `invalid JSON on line ${line}` });
450
+ continue;
451
+ }
452
+ if (!_isPlainObject(record)) {
453
+ errors.push({ line, message: `record on line ${line} must be a JSON object` });
454
+ continue;
455
+ }
456
+
457
+ const { host, port } = _splitHostPort(record.upstream_host);
458
+ const cluster = _safeIdentifierOrNull(record.upstream_cluster);
459
+ const protocol = typeof record.protocol === 'string'
460
+ ? _safeIdentifierOrNull(record.protocol.toLowerCase().replace(/\//g, ''))
461
+ : null;
462
+
463
+ const attributes = {};
464
+ if (host) attributes['destination.host'] = host;
465
+ if (port !== null) attributes['destination.port'] = port;
466
+ if (cluster) attributes['destination.service'] = cluster;
467
+ if (protocol) attributes['network.protocol'] = protocol;
468
+ if (typeof record.response_flags === 'string') {
469
+ attributes['network.securityDecision'] = record.response_flags.trim().length === 0 || record.response_flags.trim() === '-' ? 'allowed' : 'denied';
470
+ }
471
+
472
+ if (Object.keys(attributes).length === 0) continue; // nothing safe/present to report for this line
473
+
474
+ const startIso = typeof record.start_time === 'string' && Number.isFinite(Date.parse(record.start_time))
475
+ ? record.start_time : context.windowStart;
476
+ let endIso = typeof record.end_time === 'string' && Number.isFinite(Date.parse(record.end_time))
477
+ ? record.end_time : null;
478
+ if (endIso === null && typeof record.duration === 'number' && Number.isFinite(record.duration)) {
479
+ endIso = new Date(Date.parse(startIso) + record.duration).toISOString();
480
+ }
481
+
482
+ drafts.push({
483
+ ..._draftBase(context),
484
+ attributes,
485
+ eventCountBand: '1',
486
+ firstObservedAt: startIso,
487
+ lastObservedAt: endIso ?? startIso,
488
+ });
489
+ }
490
+
491
+ return { drafts, errors };
492
+ }
493
+
494
+ /**
495
+ * Kubernetes `audit.k8s.io` audit-event JSONL (one event object per
496
+ * line — the default `webhook`/file backend format). Reads `objectRef`
497
+ * (`namespace` + `resource`, joined as `"<namespace>:<resource>"` into
498
+ * `destination.service` — never `objectRef.name`, which is frequently a
499
+ * user-chosen, potentially sensitive resource name) and
500
+ * `responseStatus.code` (mapped to `network.securityDecision`: `< 400`
501
+ * is `'allowed'`, `>= 400` is `'denied'` — a client error is treated as a
502
+ * decision here because the audit log's own admission/authorization
503
+ * failures surface as 401/403/409, which is exactly the "security
504
+ * decision" fact this adapter exists to carry). `requestReceivedTimestamp`/
505
+ * `stageTimestamp` feed the observed-at window. **`user.username` and
506
+ * every request/response body field are deliberately never read** — a
507
+ * K8s audit event's `user` block is exactly the "authenticated identity"
508
+ * PRD 2 names, and `runtime-observation.js`'s own contract has no
509
+ * approved field for it (a deliberate, disclosed scope boundary — see
510
+ * this PRD's implementation-status doc).
511
+ */
512
+ export function parseK8sAuditLogObservations(text, context) {
513
+ if (typeof text !== 'string' || !_isPlainObject(context)) {
514
+ return { drafts: [], errors: [{ line: 0, message: 'parseK8sAuditLogObservations requires a string text and a plain-object context' }] };
515
+ }
516
+
517
+ const drafts = [];
518
+ const errors = [];
519
+ const lines = text.split(/\r?\n/);
520
+ for (let i = 0; i < lines.length; i++) {
521
+ const rawLine = lines[i];
522
+ if (rawLine.trim().length === 0) continue;
523
+ const line = i + 1;
524
+
525
+ let record;
526
+ try {
527
+ record = JSON.parse(rawLine);
528
+ } catch {
529
+ errors.push({ line, message: `invalid JSON on line ${line}` });
530
+ continue;
531
+ }
532
+ if (!_isPlainObject(record)) {
533
+ errors.push({ line, message: `record on line ${line} must be a JSON object` });
534
+ continue;
535
+ }
536
+
537
+ const objectRef = _isPlainObject(record.objectRef) ? record.objectRef : {};
538
+ const namespace = _safeIdentifierOrNull(objectRef.namespace);
539
+ const resource = _safeIdentifierOrNull(objectRef.resource);
540
+ const code = record.responseStatus && typeof record.responseStatus === 'object' ? record.responseStatus.code : undefined;
541
+
542
+ const attributes = {};
543
+ if (namespace && resource) attributes['destination.service'] = `${namespace}:${resource}`;
544
+ else if (resource) attributes['destination.service'] = resource;
545
+ if (typeof code === 'number' && Number.isFinite(code)) {
546
+ attributes['network.securityDecision'] = code < 400 ? 'allowed' : 'denied';
547
+ }
548
+
549
+ if (Object.keys(attributes).length === 0) continue;
550
+
551
+ const observedAt = typeof record.requestReceivedTimestamp === 'string' && Number.isFinite(Date.parse(record.requestReceivedTimestamp))
552
+ ? record.requestReceivedTimestamp
553
+ : (typeof record.stageTimestamp === 'string' && Number.isFinite(Date.parse(record.stageTimestamp)) ? record.stageTimestamp : context.windowStart);
554
+
555
+ drafts.push({
556
+ ..._draftBase(context),
557
+ attributes,
558
+ eventCountBand: '1',
559
+ firstObservedAt: observedAt,
560
+ lastObservedAt: observedAt,
561
+ });
562
+ }
563
+
564
+ return { drafts, errors };
565
+ }
566
+
567
+ /**
568
+ * A generic JSON-lines HTTP access log (one JSON object per line — the
569
+ * shape a structured reverse proxy or load-balancer access log normalizes
570
+ * to; deliberately not an Apache/Nginx TEXT combined-log parser, which
571
+ * would need its own separate, much larger format-string handling).
572
+ * Reads `host`/`destination_host` -> `destination.host`,
573
+ * `upstream`/`upstream_service` -> `destination.service`, `scheme` ->
574
+ * `destination.scheme`. `timestamp` (or `time`) feeds the observed-at
575
+ * window. Request path/query, client IP, user agent, status code's own
576
+ * value, and every header are dropped.
577
+ */
578
+ export function parseHttpAccessLogObservations(text, context) {
579
+ if (typeof text !== 'string' || !_isPlainObject(context)) {
580
+ return { drafts: [], errors: [{ line: 0, message: 'parseHttpAccessLogObservations requires a string text and a plain-object context' }] };
581
+ }
582
+
583
+ const drafts = [];
584
+ const errors = [];
585
+ const lines = text.split(/\r?\n/);
586
+ for (let i = 0; i < lines.length; i++) {
587
+ const rawLine = lines[i];
588
+ if (rawLine.trim().length === 0) continue;
589
+ const line = i + 1;
590
+
591
+ let record;
592
+ try {
593
+ record = JSON.parse(rawLine);
594
+ } catch {
595
+ errors.push({ line, message: `invalid JSON on line ${line}` });
596
+ continue;
597
+ }
598
+ if (!_isPlainObject(record)) {
599
+ errors.push({ line, message: `record on line ${line} must be a JSON object` });
600
+ continue;
601
+ }
602
+
603
+ const host = _safeIdentifierOrNull(record.host ?? record.destination_host);
604
+ const upstream = _safeIdentifierOrNull(record.upstream ?? record.upstream_service);
605
+ const scheme = _safeIdentifierOrNull(record.scheme);
606
+
607
+ const attributes = {};
608
+ if (host) attributes['destination.host'] = host;
609
+ if (upstream) attributes['destination.service'] = upstream;
610
+ if (scheme) attributes['destination.scheme'] = scheme;
611
+
612
+ if (Object.keys(attributes).length === 0) continue;
613
+
614
+ const ts = record.timestamp ?? record.time;
615
+ const observedAt = typeof ts === 'string' && Number.isFinite(Date.parse(ts)) ? ts : context.windowStart;
616
+
617
+ drafts.push({
618
+ ..._draftBase(context),
619
+ attributes,
620
+ eventCountBand: '1',
621
+ firstObservedAt: observedAt,
622
+ lastObservedAt: observedAt,
623
+ });
624
+ }
625
+
626
+ return { drafts, errors };
627
+ }
628
+
265
629
  // The adapter registry — a frozen, module-level lookup. `adapterFor`
266
630
  // returns `null` for anything unknown, NEVER a default: an unimplemented
267
631
  // adapter name (e.g. `'otlp'`, deferred per the scoping doc's §4.3 ruling)
@@ -269,6 +633,10 @@ export function parseNativeJsonlObservations(text, context) {
269
633
  // someday exist.
270
634
  const _ADAPTERS = Object.freeze({
271
635
  'native-jsonl': Object.freeze({ name: 'native-jsonl', parse: parseNativeJsonlObservations }),
636
+ 'otel-json': Object.freeze({ name: 'otel-json', parse: parseOtelJsonObservations }),
637
+ 'envoy-access-log': Object.freeze({ name: 'envoy-access-log', parse: parseEnvoyAccessLogObservations }),
638
+ 'k8s-audit-log': Object.freeze({ name: 'k8s-audit-log', parse: parseK8sAuditLogObservations }),
639
+ 'http-access-log': Object.freeze({ name: 'http-access-log', parse: parseHttpAccessLogObservations }),
272
640
  });
273
641
 
274
642
  /**
@@ -0,0 +1,135 @@
1
+ // runtime-assurance.js — PRD "Runtime-Corroborated Security Twin" P0-8
2
+ // (Runtime Assurance). A pure aggregation over already-loaded observation
3
+ // imports and an already-computed `correlateObservations` result — never
4
+ // re-reads a file, never re-runs the match ladder. Honest by construction:
5
+ // the one rule this module exists to enforce is that a MISSING or STALE
6
+ // telemetry source can never collapse into "no runtime risk" — the
7
+ // `runtimeStatus` field is always exactly one of `'complete'`, `'partial'`,
8
+ // or `'unavailable'`, and `'unavailable'` is the answer whenever no
9
+ // observation store was consulted at all, never a silent empty report.
10
+ //
11
+ // `parseFailures` is always reported as 0, with an explicit note why:
12
+ // `dataflow observations import` REFUSES the whole import the moment any
13
+ // record fails to parse or validate (AC-29 clause 5) — a failed import is
14
+ // never persisted, so there is no on-disk history of parse failures for
15
+ // this module to read. This is a real, disclosed limitation, not a false
16
+ // "zero failures ever happened" claim.
17
+
18
+ export const RUNTIME_STATUS_VALUES = Object.freeze(['complete', 'partial', 'unavailable']);
19
+
20
+ function _isPlainObject(v) {
21
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
22
+ }
23
+
24
+ function _maxIso(list) {
25
+ let best = null;
26
+ let bestT = -Infinity;
27
+ for (const s of list) {
28
+ const t = Date.parse(s);
29
+ if (Number.isFinite(t) && t > bestT) { bestT = t; best = s; }
30
+ }
31
+ return best;
32
+ }
33
+
34
+ function _minIso(list) {
35
+ let best = null;
36
+ let bestT = Infinity;
37
+ for (const s of list) {
38
+ const t = Date.parse(s);
39
+ if (Number.isFinite(t) && t < bestT) { bestT = t; best = s; }
40
+ }
41
+ return best;
42
+ }
43
+
44
+ /**
45
+ * `computeRuntimeAssurance(opts) -> RuntimeAssurance`
46
+ *
47
+ * `opts`:
48
+ * - `importRecords`: the `ObservationImport[]` read back from the store
49
+ * (`loadObservationImports`), or `null`/`undefined` when the store was
50
+ * never consulted at all (mirrors `correlateObservations`'s own
51
+ * `observations === null` convention).
52
+ * - `correlation`: the `CorrelationResult` from `correlateObservations`
53
+ * for the SAME graph/observations/window this call is reporting on.
54
+ * - `now`: an ISO-8601 string (defaults to `new Date().toISOString()`,
55
+ * injectable for deterministic tests).
56
+ * - `staleAfterHours`: how old the most recent observed event can be
57
+ * before freshness is reported stale (default 24 * 30 = 720, i.e. 30
58
+ * days — telemetry windows in this package's own fixtures/CLI default
59
+ * to month-long exports, so a 30-day staleness bar is the honest
60
+ * "this is still describing roughly the current system" line, not an
61
+ * arbitrary short window).
62
+ *
63
+ * Never throws.
64
+ */
65
+ export function computeRuntimeAssurance(opts) {
66
+ const o = opts && typeof opts === 'object' ? opts : {};
67
+ const now = typeof o.now === 'string' && Number.isFinite(Date.parse(o.now)) ? o.now : new Date().toISOString();
68
+ const staleAfterHours = Number.isFinite(o.staleAfterHours) ? o.staleAfterHours : 720;
69
+
70
+ const importRecords = Array.isArray(o.importRecords) ? o.importRecords : null;
71
+ const correlation = _isPlainObject(o.correlation) ? o.correlation : null;
72
+
73
+ const base = {
74
+ runtimeStatus: 'unavailable',
75
+ telemetryWindow: { start: null, end: null },
76
+ freshness: { lastObservedAt: null, lastImportedAt: null, staleHours: null, stale: true },
77
+ parseFailures: 0,
78
+ parseFailuresNote: 'A failed import is refused in full and never persisted (AC-29 clause 5) — '
79
+ + 'this count reflects the import store, not the history of every attempt. Check `runtime ingest`\'s '
80
+ + 'own exit code/stderr at import time for parse failures.',
81
+ mappingPercentage: null,
82
+ unmatchedObservationCount: 0,
83
+ totalObservationCount: 0,
84
+ missingTelemetry: true,
85
+ limitations: [],
86
+ };
87
+
88
+ // No store was ever consulted — the honest 'unavailable' case, never a
89
+ // fabricated empty-but-fine report.
90
+ if (importRecords === null || importRecords.length === 0 || !correlation || correlation.evaluated !== true) {
91
+ base.limitations.push(
92
+ 'No runtime observation import exists for this scan — runtimeStatus is \'unavailable\', never \'complete\' '
93
+ + 'or a silent absence of risk. Import telemetry with `runtime ingest` before relying on this report.',
94
+ );
95
+ return base;
96
+ }
97
+
98
+ const windowStart = _minIso(importRecords.map((r) => r?.windowStart));
99
+ const windowEnd = _maxIso(importRecords.map((r) => r?.windowEnd));
100
+ base.telemetryWindow = { start: windowStart, end: windowEnd };
101
+ base.missingTelemetry = false;
102
+
103
+ const allObservations = importRecords.flatMap((r) => (Array.isArray(r?.observations) ? r.observations : []));
104
+ const total = allObservations.length;
105
+ const unmatched = allObservations.filter((obs) => obs && obs.matchMethod === 'unmatched').length;
106
+ base.totalObservationCount = total;
107
+ base.unmatchedObservationCount = unmatched;
108
+ base.mappingPercentage = total > 0 ? Math.round(((total - unmatched) / total) * 10000) / 100 : null;
109
+
110
+ const lastObservedAt = _maxIso(allObservations.map((obs) => obs?.lastObservedAt));
111
+ const lastImportedAt = _maxIso(importRecords.map((r) => r?.importedAt));
112
+ base.freshness.lastObservedAt = lastObservedAt;
113
+ base.freshness.lastImportedAt = lastImportedAt;
114
+ if (lastObservedAt !== null) {
115
+ const ageMs = Date.parse(now) - Date.parse(lastObservedAt);
116
+ const ageHours = Math.round((ageMs / 3600000) * 100) / 100;
117
+ base.freshness.staleHours = ageHours;
118
+ base.freshness.stale = ageHours > staleAfterHours;
119
+ }
120
+
121
+ if (total === 0) {
122
+ base.limitations.push('The import store exists but carries zero observation records.');
123
+ }
124
+ if (unmatched > 0) {
125
+ base.limitations.push(`${unmatched} of ${total} imported observation(s) never matched any graph node (matchMethod 'unmatched') — these contribute no corroboration to any flow.`);
126
+ }
127
+ if (base.freshness.stale) {
128
+ base.limitations.push(`The most recent observed event is ${base.freshness.staleHours} hour(s) old (beyond the ${staleAfterHours}-hour staleness bar) — this twin may no longer describe current production behavior.`);
129
+ }
130
+
131
+ const complete = total > 0 && unmatched === 0 && !base.freshness.stale;
132
+ base.runtimeStatus = complete ? 'complete' : 'partial';
133
+
134
+ return base;
135
+ }
@@ -135,10 +135,16 @@ export const RUNTIME_OBSERVATION_FIELDS = Object.freeze([
135
135
  'firstObservedAt', 'lastObservedAt', 'matchMethod', 'matchConfidence', 'retention', 'importedAt',
136
136
  ]);
137
137
 
138
- // Only one adapter is actually implemented today — an unimplemented
139
- // adapter name must be rejected, not accepted on faith that it will
140
- // someday exist (RO/3b).
141
- export const RUNTIME_OBSERVATION_ADAPTERS = Object.freeze(['native-jsonl']);
138
+ // An unimplemented adapter name must be rejected, not accepted on faith
139
+ // that it will someday exist (RO/3b). The four PRD "Runtime-Corroborated
140
+ // Security Twin" adapters (`observation-adapters.js`) were added
141
+ // alongside `native-jsonl` as the real P0 telemetry-input gap that PRD
142
+ // closes — OTLP's own richer, evolving attribute surface stays deferred
143
+ // (`'otel-json'` here is this codebase's own minimal OTLP/JSON reader,
144
+ // never the full semantic-conventions vocabulary).
145
+ export const RUNTIME_OBSERVATION_ADAPTERS = Object.freeze([
146
+ 'native-jsonl', 'otel-json', 'envoy-access-log', 'k8s-audit-log', 'http-access-log',
147
+ ]);
142
148
 
143
149
  // FR-505's own four named metadata families: service/workload identity,
144
150
  // endpoint/destination identity, protocol/TLS metadata, and schema/
@@ -160,8 +166,23 @@ export const RUNTIME_ATTRIBUTE_KEYS = Object.freeze([
160
166
  'network.protocol', 'network.transport', 'tls.version', 'tls.cipher', 'tls.verified',
161
167
  // schema/attribute NAMES already approved for telemetry
162
168
  'schema.name', 'schema.attributeNames',
169
+ // the one security-DECISION field (PRD "Runtime-Corroborated Security
170
+ // Twin" §P0-1's "security decision" family) — added for the K8s audit
171
+ // adapter, which is defined by the exact allow/deny fact this key
172
+ // records. A closed two-value enum, never a free-form identifier: see
173
+ // RUNTIME_SECURITY_DECISION_VALUES below and its own dedicated check in
174
+ // validateObservationAttributes.
175
+ 'network.securityDecision',
163
176
  ]);
164
177
 
178
+ // The closed enum for 'network.securityDecision' — deliberately narrower
179
+ // than the generic identifier-value check every other scalar attribute
180
+ // gets, since this field has exactly two legitimate values and nothing
181
+ // else should ever be accepted under it (not 'allow'/'deny', not 'true'/
182
+ // 'false', not a raw HTTP status code — an adapter normalizes to one of
183
+ // these two strings itself).
184
+ export const RUNTIME_SECURITY_DECISION_VALUES = Object.freeze(['allowed', 'denied']);
185
+
165
186
  // The one attribute key whose value is an array rather than a scalar —
166
187
  // itself must be a member of RUNTIME_ATTRIBUTE_KEYS.
167
188
  export const RUNTIME_ARRAY_ATTRIBUTE_KEYS = Object.freeze(['schema.attributeNames']);
@@ -272,6 +293,18 @@ export function validateObservationAttributes(attributes) {
272
293
  }
273
294
  continue;
274
295
  }
296
+ if (key === 'network.securityDecision') {
297
+ // A closed two-value enum, deliberately narrower than the generic
298
+ // identifier-value check below — this field has exactly two
299
+ // legitimate values, and accepting anything identifier-shaped
300
+ // ('unknown', 'maybe', a raw status code string) would let an
301
+ // adapter smuggle an unreviewed vocabulary through a field whose
302
+ // whole purpose is a binary allow/deny fact.
303
+ if (!RUNTIME_SECURITY_DECISION_VALUES.includes(value)) {
304
+ err(`$.attributes["${key}"]`, `must be one of ${RUNTIME_SECURITY_DECISION_VALUES.join('|')} — this field records a closed allow/deny fact, never a free-form identifier`);
305
+ }
306
+ continue;
307
+ }
275
308
  const t = typeof value;
276
309
  if (t === 'string') {
277
310
  if (!_isIdentifierValue(value)) {
@@ -95,6 +95,14 @@ export const ARTIFACT_REGISTRY = [
95
95
  // clears by default, same as last-scan.json itself.
96
96
  { name: 'lineage-graph.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
97
97
  { name: 'lineage-graph.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan' },
98
+ // PRD 5 (Source-to-Artifact Security & Provenance): one <digest-hex>.json
99
+ // per `image scan`-ned artifact (src/artifact/inventory.js's inventory +
100
+ // vuln-correlate.js's vulnerabilities + layer-secrets.js's summary), so
101
+ // `provenance trace <digest>`/`supply vex <digest>` can re-reference an
102
+ // already-scanned archive without re-parsing the tar. A rescan
103
+ // regenerates it byte-for-byte from the same archive, so 'generated'/
104
+ // 'scan' — same bucket as last-scan.json/lineage-graph.json above.
105
+ { name: 'artifact-scans', kind: 'dir', classification: 'generated', retentionClass: 'scan', source: 'bin/agentic-security.js (_persistArtifactScan)' },
98
106
  // X-016: Haskell/Nix analysis metadata (capability status, parser/resolver ledger, limitations, input digests) and the
99
107
  // language BOM, written by src/language/state-artifacts.js beside last-scan.json. Paths and digests only, never source
100
108
  // text. Scan-class retention for the metadata; the BOM is evidence.