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