@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -41,9 +41,82 @@ import { statePath, stateWritesEnabled } from './state-dir.js';
41
41
  import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
42
42
  import { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys } from './family-resolve.js';
43
43
  import { strengthOfControl as _strengthOfControl } from './coverage-strength.js';
44
+ // FR-PROV-026: earliestOrigin.authorName below is untrusted git commit
45
+ // metadata. renderWalkthrough()'s output is console.log'd verbatim by
46
+ // bin/agentic-security.js's `compliance --walkthrough` — the ONLY live
47
+ // consumer today (persistWalkthrough below is exported and tested but has
48
+ // zero callers anywhere in the CLI/command surface; no code in this repo
49
+ // ever runs this text through a real Markdown renderer). sanitizeForTerminal
50
+ // is therefore the correct sanitizer here, not a Markdown-escaping one — a
51
+ // backslash-escaping sibling was tried and reverted (see schema.js's header
52
+ // comment): printed raw or read as plain text, `Jean-Luc Picard` rendering
53
+ // as `Jean\-Luc Picard` and `dependabot[bot]` as `dependabot\[bot\]` is a
54
+ // visible regression on common real-world author names, not a fix.
55
+ import { sanitizeForTerminal, pseudonymizeAuthor, PROVENANCE_COMPLIANCE_DISCLAIMER } from './provenance/schema.js';
56
+ import { evaluateGraphFlowPredicate, buildObligationMappingFromGraphPredicate } from '../lineage/obligation-predicates.js';
57
+
58
+ // Fix-round item 4b: this renderer had `sanitizeForTerminal` (injection
59
+ // safety) but never honoured `--pseudonymize-authors` at all — an operator
60
+ // who set that policy still saw a raw committer name in walkthrough output,
61
+ // the one boundary that gap missed. Reads the SAME env var
62
+ // `report/index.js`'s `_normalizedProvenance` and `mcp/tools.js`'s
63
+ // `providerEnrichment`-aware call read back
64
+ // (AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 / --pseudonymize-authors). Keyed
65
+ // on name only, not email: `deriveComplianceProvenance`'s `earliestOrigin`
66
+ // deliberately never carries `authorEmail` (see its own comment — that
67
+ // object bypasses the `redactFindingProvenance` sweep entirely), so the
68
+ // pseudonym here is stable across repeated runs for the same author name but
69
+ // not necessarily identical to the email-keyed pseudonym shown at other
70
+ // output boundaries for the same person.
71
+ function _maybePseudonymizeName(name) {
72
+ if (!name) return name;
73
+ return process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1' ? pseudonymizeAuthor(name, null) : name;
74
+ }
44
75
 
45
76
  // Re-exported so existing callers/tests keep importing these from here.
46
77
  export { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys };
78
+
79
+ // FR-PROV-016 (M2): "earliest proven open condition" among a control's
80
+ // contributing findings. Prefers a finding whose findingProvenance resolved
81
+ // findingOrigin.status:'complete' (the OLDEST such authorDate wins); falls
82
+ // back to 'partial' entries with a resolved findingOrigin.authorDate when no
83
+ // complete one exists. Never fabricates an origin — zero usable entries is
84
+ // reported as null/'unknown', not the repo's first commit or "now".
85
+ export function deriveComplianceProvenance(findings) {
86
+ const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
87
+ const withOrigin = list
88
+ .map((f) => ({ f, fp: f && f.findingProvenance }))
89
+ .filter((x) => x.fp && x.fp.findingOrigin && x.fp.findingOrigin.authorDate);
90
+ const complete = withOrigin.filter((x) => x.fp.status === 'complete');
91
+ const partial = withOrigin.filter((x) => x.fp.status === 'partial');
92
+ // authorDate is git's `%aI` (strict ISO-8601, author's LOCAL UTC offset —
93
+ // see git-evidence.js's commitMeta), never normalized to Z. Two commits
94
+ // authored in different timezones near a day boundary can lexically sort
95
+ // in the wrong chronological order, so compare actual instants via
96
+ // Date.parse, never the raw strings.
97
+ const pickEarliest = (arr) => arr.reduce(
98
+ (min, x) => (!min || Date.parse(x.fp.findingOrigin.authorDate) < Date.parse(min.fp.findingOrigin.authorDate)) ? x : min,
99
+ null,
100
+ );
101
+ const best = complete.length ? pickEarliest(complete) : (partial.length ? pickEarliest(partial) : null);
102
+ return {
103
+ derivedFrom: [...new Set(list.map((f) => f && f.id).filter(Boolean))],
104
+ // Only commit/authorDate/authorName are ever read from findingOrigin
105
+ // here — this object is a SIBLING field to findingProvenance (not
106
+ // nested inside it), so it bypasses the redactFindingProvenance sweep
107
+ // that runs at report/mcp output boundaries. authorEmail must never be
108
+ // added to this shape without first routing it through
109
+ // redactFindingProvenance.
110
+ earliestOrigin: best ? {
111
+ commit: best.fp.findingOrigin.commit || null,
112
+ authorDate: best.fp.findingOrigin.authorDate,
113
+ authorName: best.fp.findingOrigin.authorName || null,
114
+ } : null,
115
+ confidence: complete.length ? 'high' : (partial.length ? 'low' : 'unknown'),
116
+ limitations: best ? [] : ['no contributing finding resolved a verified origin'],
117
+ };
118
+ }
119
+
47
120
  const BUNDLED_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), 'compliance-frameworks');
48
121
  function _readJson(fp) {
49
122
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
@@ -275,24 +348,31 @@ export function evaluateFramework(scanRoot, fw, scan) {
275
348
  const results = [];
276
349
  for (const c of fw.controls || []) {
277
350
  const obs = [];
351
+ // FR-PROV-016: findings that contributed an OPEN condition to this
352
+ // control's `family:` mapping(s) — the exact objects `open` below
353
+ // filters to, not just their ids, so deriveComplianceProvenance can read
354
+ // .findingProvenance off them. Naturally empty for a control that ends
355
+ // up 'present' (present requires zero open findings across every
356
+ // mapping) or 'manual' (no family: mapping ever populates it) — so a
357
+ // consumer can treat a non-empty controlRefs as "this control has an
358
+ // attributable gap" without re-deriving the bucket classification.
359
+ const contributingFindings = [];
278
360
  let status = 'manual';
279
361
  const maps = Array.isArray(c.mapsTo) ? c.mapsTo : [];
280
362
 
281
363
  if (maps.length === 0) {
282
364
  obs.push('No automated mapping — requires manual evidence collection.');
283
- // PRD F10.2: carry the MEASURED strength of the backing detector, so a
284
- // control mapped to a detector that finds 3 of 18 independent advisories
285
- // cannot read the same as one backed by a detector that finds nearly
286
- // everything. Import is lazy so the evaluator keeps working if the bench
287
- // artifacts are absent (they degrade to `unmeasured`, never to a default).
288
- let evidence = null;
289
- try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
290
- results.push({
291
- control: c,
292
- status,
293
- observations: obs,
294
- ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
295
- });
365
+ let evidence = null;
366
+ try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
367
+ results.push({
368
+ control: c,
369
+ status,
370
+ observations: obs,
371
+ controlRefs: [],
372
+ derivedProvenance: deriveComplianceProvenance([]),
373
+ obligationMappings: [],
374
+ ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
375
+ });
296
376
  continue;
297
377
  }
298
378
 
@@ -313,6 +393,15 @@ export function evaluateFramework(scanRoot, fw, scan) {
313
393
  // control at 'partial' — never 'present' — regardless of what the
314
394
  // family:/module: mappings in the same control found.
315
395
  let hasUnverifiableMapping = false;
396
+ // Sub-project 6b: real ObligationMapping records minted by graph:
397
+ // predicates on this control, collected separately from the
398
+ // present/partial/absent/manual status machinery above — a graph:
399
+ // mapping never touches anySignal/allCleared/anyCleared/
400
+ // hasUnverifiableMapping (purely additive, per the plan's own Global
401
+ // Constraints). Always an array, never undefined, so a caller can
402
+ // safely read `.length` regardless of whether this control has any
403
+ // graph: mappings at all.
404
+ const obligationMappings = [];
316
405
  for (const m of maps) {
317
406
  if (m.startsWith('family:')) {
318
407
  // `family:X` and the subfamily-qualified `family:X:Y` (used by
@@ -356,6 +445,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
356
445
  const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
357
446
  if (open.length) {
358
447
  allCleared = false;
448
+ contributingFindings.push(...open);
359
449
  obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
360
450
  } else {
361
451
  obs.push(`✓ ${fam}: no open ${minSeverity}+ findings.`);
@@ -413,6 +503,70 @@ export function evaluateFramework(scanRoot, fw, scan) {
413
503
  obs.push(`(rule mapping) ${m} — verify manually that the bodyguard rule is enabled.`);
414
504
  anySignal = true;
415
505
  hasUnverifiableMapping = true;
506
+ } else if (m.startsWith('graph:')) {
507
+ // A graph: mapping never contributes to anySignal/allCleared/
508
+ // anyCleared/hasUnverifiableMapping — the existing
509
+ // present/partial/absent/manual status stays driven entirely by
510
+ // the family:/module:/rule: mappings a control already has (this
511
+ // task's own Global Constraint: purely additive). It instead
512
+ // mints a real ObligationMapping record, collected separately.
513
+ //
514
+ // First real predicate (scoping doc ruling 6): PHI/PII flowing to
515
+ // an external sink must cross a protected transit edge. The
516
+ // predicate STRING itself is currently a fixed label (not yet a
517
+ // parsed mini-language) — the first real graph: mapping this
518
+ // sub-project ships is HIPAA §164.312(e)'s
519
+ // "graph:transit-protection:PHI:external:transit:protected",
520
+ // hardcoded to this one spec until a second real case proves the
521
+ // parsing is worth generalizing (YAGNI — do not invent a parser
522
+ // for one caller).
523
+ const spec = { type: 'graph-flow', dataClass: 'PHI', sinkKind: 'external', dimension: 'transit', requiredVerdict: 'protected' };
524
+ const graph = scan.lineageGraph ?? null;
525
+ // Belt-and-suspenders, mirroring the sibling _strengthOfControl
526
+ // call above: obligation-predicates.js is now defensively
527
+ // hardened to never throw (see its own header), but this wrap
528
+ // keeps a future regression there from taking down the whole
529
+ // evaluateFramework call for every control in the framework
530
+ // (found by the final whole-branch review).
531
+ let mapping = null;
532
+ try {
533
+ const evaluation = graph ? evaluateGraphFlowPredicate(spec, graph) : null;
534
+ mapping = buildObligationMappingFromGraphPredicate({
535
+ framework: fw.id,
536
+ frameworkVersion: fw.controlsDigest,
537
+ requirementId: c.id,
538
+ requirementSource: fw.url ?? null,
539
+ predicateLabel: m,
540
+ graph,
541
+ evaluation,
542
+ });
543
+ } catch { /* graph obligation mapping is additive; never block evaluation */ }
544
+ if (mapping) {
545
+ obligationMappings.push(mapping);
546
+ // Found by the final whole-branch review of sub-project 6b: a
547
+ // graph: mapping's state is intentionally independent of the
548
+ // pre-existing present/partial/absent/manual status glyph
549
+ // (Global Constraint — see the block comment above), so a
550
+ // control can render a green ✅ header with a 'gap_detected'
551
+ // line buried underneath it in the observations. A ⚠️ prefix on
552
+ // a genuine gap keeps that visible to a reader who only scans
553
+ // headers, without touching the status computation itself.
554
+ // RE-CONFIRMED (sub-project 6c's own final review, F4): at the
555
+ // time this ⚠️ prefix was written, `graph:` mappings could only
556
+ // ever read 'unknown' end-to-end through the real CLI (a
557
+ // separate, then-undiscovered bug — see obligation-predicates.js
558
+ // and cmdCompliance/cmdAttest's own fix history), so this exact
559
+ // ✅-header-hiding-a-real-gap scenario was structurally
560
+ // unreachable. Sub-project 6c's CLI fixes make it genuinely
561
+ // reachable for the first time; the ruling above was written
562
+ // anticipating exactly this and needs no change — confirmed
563
+ // live via a real assessed HIPAA §164.312(e) gap
564
+ // (test/cli/attest-obligations.test.js's own gap_detected case).
565
+ const flag = mapping.state === 'gap_detected' ? '⚠️ ' : '';
566
+ obs.push(`${flag}(graph mapping) ${m} -> ${mapping.state}.`);
567
+ } else {
568
+ obs.push(`(graph mapping) ${m} -> evaluation failed, skipped.`);
569
+ }
416
570
  }
417
571
  }
418
572
 
@@ -461,10 +615,14 @@ export function evaluateFramework(scanRoot, fw, scan) {
461
615
  // artifacts are absent (they degrade to `unmeasured`, never to a default).
462
616
  let evidence = null;
463
617
  try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
618
+ const dedupedRefs = [...new Set(contributingFindings.map((f) => f.id).filter(Boolean))];
464
619
  results.push({
465
620
  control: c,
466
621
  status,
467
622
  observations: obs,
623
+ controlRefs: dedupedRefs,
624
+ derivedProvenance: deriveComplianceProvenance(contributingFindings),
625
+ obligationMappings,
468
626
  ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
469
627
  });
470
628
  }
@@ -519,6 +677,27 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
519
677
  if (ev.status === 'absent' || ev.status === 'partial') {
520
678
  lines.push(`**Remediation:** address the bullet(s) above, then re-run \`/compliance --walkthrough ${fw.id}\` to update this report.`);
521
679
  lines.push('');
680
+ if (Array.isArray(ev.controlRefs) && ev.controlRefs.length) {
681
+ lines.push(`**Contributing findings:** ${ev.controlRefs.join(', ')}`);
682
+ const dp = ev.derivedProvenance;
683
+ if (dp && dp.earliestOrigin) {
684
+ const short = String(dp.earliestOrigin.commit || '').slice(0, 7) || 'unknown';
685
+ const day = String(dp.earliestOrigin.authorDate || '').slice(0, 10);
686
+ // Only commit/authorDate/authorName are ever read here — same
687
+ // caveat as deriveComplianceProvenance's earliestOrigin: this
688
+ // object bypasses redactFindingProvenance, so authorEmail must
689
+ // never be surfaced from it without routing through that function
690
+ // first.
691
+ lines.push(`**Earliest proven origin:** ${short} — ${day} — ${sanitizeForTerminal(_maybePseudonymizeName(dp.earliestOrigin.authorName)) || 'unknown'} (confidence: ${dp.confidence})`);
692
+ // PRD Section 8 REQUIRED DISCLAIMER, alongside the claim it
693
+ // qualifies (not just once at the top of the document) — a reader
694
+ // who skips straight to a control's evidence must still see it.
695
+ lines.push(`_${PROVENANCE_COMPLIANCE_DISCLAIMER}_`);
696
+ } else if (dp) {
697
+ lines.push(`**Earliest proven origin:** unresolved (confidence: ${dp.confidence})`);
698
+ }
699
+ lines.push('');
700
+ }
522
701
  }
523
702
  }
524
703
 
@@ -5,7 +5,7 @@
5
5
  "license": "US Federal regulation (public)",
6
6
  "url": "https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164",
7
7
  "scope": "SELECTIVE SUBSET. 8 of the Security Rule technical safeguards. Administrative and physical safeguards are outside what a code scanner can observe and are NOT represented here.",
8
- "controlsDigest": "a99f9c4015ffbf72",
8
+ "controlsDigest": "7e94038dd9a64d3d",
9
9
  "controlCount": 8,
10
10
  "controls": [
11
11
  {
@@ -102,7 +102,8 @@
102
102
  ],
103
103
  "mapsTo": [
104
104
  "family:crypto-tls-no-verify",
105
- "family:crypto-tls-version"
105
+ "family:crypto-tls-version",
106
+ "graph:transit-protection:PHI:external:transit:protected"
106
107
  ]
107
108
  }
108
109
  ]
@@ -64,7 +64,8 @@
64
64
  import * as fs from 'node:fs';
65
65
  import * as path from 'node:path';
66
66
  import * as crypto from 'node:crypto';
67
- import { execSync } from 'node:child_process';
67
+ import { execFileSync } from 'node:child_process';
68
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
68
69
  import * as yaml from '../util/yaml.js';
69
70
  import { statePath, safeWriteState, STATE_DIR_NAME } from './state-dir.js';
70
71
  import { SCANNER_VERSION } from './version.js';
@@ -367,10 +368,19 @@ export function verifyPolicy(policy, ctx) {
367
368
  return { framework: policy.framework, version: policy.version, controls: results, summary, evidenceDigest };
368
369
  }
369
370
 
371
+ // `scanRoot` is the scanned project's repository, not this project's own
372
+ // trusted checkout — hardened per FR-PROV-024 / the second Finding
373
+ // Provenance PRD audit sweep (found missing here by a follow-up review that
374
+ // grepped for `child_process` usage beyond just `execFileSync('git'` call
375
+ // sites). `rev-parse HEAD` was VERIFIED not to itself trigger
376
+ // `core.fsmonitor`/a hook, so this is not a second live RCE — but the
377
+ // shell-string `execSync` form was gratuitous risk with no upside (no
378
+ // caller-controlled input to interpolate), and left this call outside the
379
+ // config/env hardening every other git call in this codebase now has.
370
380
  function _currentCommit(scanRoot) {
371
381
  if (!scanRoot) return null;
372
382
  try {
373
- return execSync('git rev-parse HEAD', { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
383
+ return execFileSync('git', hardenGitArgs(['rev-parse', 'HEAD']), { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() }).trim();
374
384
  } catch { return null; } // not a git repo, or git unavailable — not an error condition
375
385
  }
376
386
 
@@ -24,6 +24,7 @@ import * as cp from 'node:child_process';
24
24
  import * as fs from 'node:fs';
25
25
  import * as crypto from 'node:crypto';
26
26
  import * as path from 'node:path';
27
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
27
28
 
28
29
  // Lazy — process.env.HOME may be mutated mid-process (e.g. tests isolating).
29
30
  function _storeDir() {
@@ -43,8 +44,12 @@ function _ensureDir() { try { fs.mkdirSync(_storeDir(), { recursive: true }); }
43
44
  export function repoFingerprint(scanRoot) {
44
45
  let source = String(scanRoot || '');
45
46
  try {
46
- const remote = cp.execFileSync('git', ['remote', 'get-url', 'origin'],
47
- { cwd: scanRoot, encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
47
+ // `scanRoot` is the scanned project's repository, not this project's
48
+ // own trusted checkout hardened per FR-PROV-024 / the second Finding
49
+ // Provenance PRD audit (same exposure class as
50
+ // provenance/git-evidence.js's `_run`).
51
+ const remote = cp.execFileSync('git', hardenGitArgs(['remote', 'get-url', 'origin']),
52
+ { cwd: scanRoot, encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() }).trim();
48
53
  if (remote) source = remote;
49
54
  } catch {}
50
55
  return crypto.createHash('sha256').update(source).digest('hex').slice(0, 12);
@@ -13,6 +13,7 @@ import * as fsp from 'node:fs/promises';
13
13
  import * as path from 'node:path';
14
14
  import * as crypto from 'node:crypto';
15
15
  import { isSafeStateDir, statePath, stateWritesEnabled } from './state-dir.js';
16
+ import { AGE_BASIS } from './provenance/schema.js';
16
17
 
17
18
  function historyDir(scanRoot) {
18
19
  return statePath(scanRoot, 'fix-history');
@@ -238,6 +239,26 @@ function _countPriorAttempts(log, stableId, findingId) {
238
239
  return n;
239
240
  }
240
241
 
242
+ // FR-PROV §7.4 / M2 §2.2: how old was this finding, by which basis, at the
243
+ // moment it was fixed. Computed ONCE, at fix time, and never re-derived
244
+ // later — a finding's origin doesn't change, but re-computing "age at fix"
245
+ // from a LATER read of findingProvenance would silently answer "how old is
246
+ // it now", not "how old was it when fixed". Mirrors mttr.js's ageBasis
247
+ // tiering (Task 6) so the two surfaces agree on vocabulary.
248
+ function _snapshotProvenanceAtFix(findingProvenance, appliedAt) {
249
+ if (!findingProvenance) return null;
250
+ const status = findingProvenance.status;
251
+ const origin = findingProvenance.findingOrigin;
252
+ const observedAt = findingProvenance.firstObserved?.observedAt || null;
253
+ let ageBasis, basisDate;
254
+ if (status === 'complete' && origin?.authorDate) { ageBasis = AGE_BASIS.FINDING_ORIGIN; basisDate = origin.authorDate; }
255
+ else if (status === 'partial' && origin?.authorDate) { ageBasis = AGE_BASIS.EARLIEST_OBSERVABLE; basisDate = origin.authorDate; }
256
+ else if (status === 'uncommitted') { ageBasis = AGE_BASIS.UNCOMMITTED; basisDate = observedAt; }
257
+ else { ageBasis = AGE_BASIS.FIRST_OBSERVED; basisDate = observedAt; }
258
+ const ageDays = basisDate ? Math.max(0, Math.floor((Date.parse(appliedAt) - Date.parse(basisDate)) / 86400000)) : null;
259
+ return { commit: origin?.commit || null, authorDate: basisDate, ageBasis, ageDays };
260
+ }
261
+
241
262
  // @param {boolean} [fileExisted] - did `file` exist on disk before this call?
242
263
  // Determines what "restore" means on rollback: write `originalContent`
243
264
  // back for a file that existed (default, for backward compatibility with
@@ -247,7 +268,7 @@ function _countPriorAttempts(log, stableId, findingId) {
247
268
  // real-world meaning in this codebase's callers — writing '' back would
248
269
  // leave a phantom empty file where none existed before, not a true
249
270
  // rollback).
250
- export async function applyFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId, fileExisted = true }) {
271
+ export async function applyFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId, fileExisted = true, findingProvenance = null }) {
251
272
  return _withLogLock(scanRoot, async () => {
252
273
  ensure(scanRoot);
253
274
  const absFile = path.resolve(scanRoot, file);
@@ -269,6 +290,7 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
269
290
  // is below — a corrupted backup is worse than no backup, because it
270
291
  // silently defeats rollback.
271
292
  await _writeAtomicAndSync(bakPath, originalContent);
293
+ const appliedAt = new Date().toISOString();
272
294
  const entry = {
273
295
  id,
274
296
  findingId,
@@ -280,10 +302,11 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
280
302
  backupPath: path.relative(scanRoot, bakPath),
281
303
  originalSha: sha(originalContent),
282
304
  newSha: sha(newContent),
283
- appliedAt: new Date().toISOString(),
305
+ appliedAt,
284
306
  status: 'pending',
285
307
  reverted: false,
286
308
  attemptOrdinal: priorAttempts + 1,
309
+ provenanceAtFix: _snapshotProvenanceAtFix(findingProvenance, appliedAt),
287
310
  };
288
311
  // Phase 2: log entry marked pending + fsync.
289
312
  const log = priorLog;
@@ -38,7 +38,15 @@ export async function verifyPatch({
38
38
  const fileContents = { ...files };
39
39
  let scan;
40
40
  try {
41
- scan = await runFullScan({ fileContents, depFileContents, scanRoot }, () => {});
41
+ // `provenance:false` is REQUIRED here, not an optimisation. This scan is
42
+ // deliberately scoped to just the patched file(s), so its finding set is a
43
+ // tiny subset of the project's. updateLifecycle marks every open stableId
44
+ // NOT in the set it is handed as `remediated` — so a single fix
45
+ // verification (every /fix, apply_fix, and autopilot iteration runs one)
46
+ // would mass-mark the rest of the project as remediated, then
47
+ // `reintroduced` on the next real scan. The patched content is also not
48
+ // committed, so there is no history to resolve provenance against anyway.
49
+ scan = await runFullScan({ fileContents, depFileContents, scanRoot, provenance: false }, () => {});
42
50
  } catch (e) {
43
51
  return { ok: false, reason: 'rescan-failed', error: e.message };
44
52
  }
Binary file
@@ -19,14 +19,19 @@
19
19
  import * as cp from 'node:child_process';
20
20
  import * as fs from 'node:fs';
21
21
  import * as path from 'node:path';
22
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
22
23
 
23
24
  const MAX_BLAME_PER_SCAN = 500;
24
25
  const SUBPROC_TIMEOUT_MS = 1500;
25
26
  const PROMPT_MARKER_RE = /(?:^|\n)(?:Prompt|User asked|Original request|Co-Authored-By:\s*Claude)/i;
26
27
 
28
+ // `scanRoot` is the scanned project's repository, not this project's own
29
+ // trusted checkout — every call below is hardened per FR-PROV-024 / the
30
+ // second Finding Provenance PRD audit (same exposure class as
31
+ // provenance/git-evidence.js's `_run`).
27
32
  function _isGitRepo(scanRoot) {
28
33
  try {
29
- cp.execFileSync('git', ['rev-parse', '--git-dir'], { cwd: scanRoot, stdio: 'ignore', timeout: SUBPROC_TIMEOUT_MS });
34
+ cp.execFileSync('git', hardenGitArgs(['rev-parse', '--git-dir']), { cwd: scanRoot, stdio: 'ignore', timeout: SUBPROC_TIMEOUT_MS, env: hardenGitEnv() });
30
35
  return true;
31
36
  } catch { return false; }
32
37
  }
@@ -36,10 +41,13 @@ function _blame(scanRoot, file, line) {
36
41
  const rel = path.isAbsolute(file) ? path.relative(scanRoot, file) : file;
37
42
  if (rel.startsWith('..')) return null;
38
43
  try {
44
+ // `--no-textconv`: VERIFIED exploitable without it — `git blame`
45
+ // applies a hostile `.gitattributes` textconv driver by default in
46
+ // current git, same as provenance/git-evidence.js's blameLine.
39
47
  const stdout = cp.execFileSync(
40
48
  'git',
41
- ['blame', '-L', `${line},${line}`, '--porcelain', '--', rel],
42
- { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] },
49
+ hardenGitArgs(['blame', '-L', `${line},${line}`, '--porcelain', '--no-textconv', '--', rel]),
50
+ { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() },
43
51
  );
44
52
  return _parsePorcelain(stdout);
45
53
  } catch { return null; }
@@ -66,8 +74,8 @@ function _parsePorcelain(out) {
66
74
  function _fullMessage(scanRoot, sha) {
67
75
  try {
68
76
  return cp.execFileSync(
69
- 'git', ['show', '-s', '--format=%B', sha],
70
- { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] },
77
+ 'git', hardenGitArgs(['show', '-s', '--no-textconv', '--format=%B', sha]),
78
+ { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() },
71
79
  );
72
80
  } catch { return ''; }
73
81
  }
@@ -11,6 +11,7 @@
11
11
  // or the command runner) collects the unified diff and feeds hunks into classifyHunk.
12
12
 
13
13
  import * as cp from 'node:child_process';
14
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
14
15
  import { loadPrivacyTaxonomy } from '../dataflow/privacy-taxonomy.js';
15
16
 
16
17
  // Patterns that fire on the deletion side (auth/check removed).
@@ -196,11 +197,29 @@ function summarize(findings) {
196
197
  }
197
198
 
198
199
  // Convenience: invoke `git diff <ref>...HEAD` for the project and classify it.
200
+ //
201
+ // `rootDir` is the scanned project's repository, not this project's own
202
+ // trusted checkout. `--no-textconv` is load-bearing here, not
203
+ // defense-in-depth: this renders real diff content, the same shape VERIFIED
204
+ // exploitable via a hostile `.gitattributes` textconv driver in
205
+ // provenance/git-evidence.js's `commitDiff` (FR-PROV-024 / the second audit).
206
+ //
207
+ // `--no-ext-diff` is ALSO load-bearing and is a SEPARATE surface from
208
+ // `--no-textconv`: `git diff` (unlike `git show`/`git log -p`/`git blame`)
209
+ // honours an external diff driver (`.gitattributes` `diff=<name>` +
210
+ // `.git/config [diff "<name>"] command=<script>`, or the global
211
+ // `diff.external`) even with `--no-textconv` set — VERIFIED empirically: the
212
+ // exact argv this function shipped with before this fix
213
+ // (`-c core.fsmonitor= -c core.hooksPath=/dev/null diff --unified=0
214
+ // --no-textconv <ref>...HEAD`) still ran an attacker's `diff.evil.command`
215
+ // script. This was the live, remaining RCE a second review caught: this
216
+ // function is the real entry point for `/scan --diff` and
217
+ // `security-material-change`, both invoked against the scanned project.
199
218
  export function classifyGitDiff(rootDir, ref) {
200
219
  let out;
201
220
  try {
202
- out = cp.execFileSync('git', ['diff', '--unified=0', `${ref}...HEAD`], {
203
- cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
221
+ out = cp.execFileSync('git', hardenGitArgs(['diff', '--unified=0', '--no-textconv', '--no-ext-diff', `${ref}...HEAD`]), {
222
+ cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
204
223
  });
205
224
  } catch (e) {
206
225
  return { materialRisk: 'unknown', error: 'git diff failed: ' + (e.message || e), findings: [], perKindCounts: {}, byFile: {} };
@@ -8,6 +8,29 @@
8
8
  // when to persist firstSeenAt back into the baseline.
9
9
 
10
10
  import * as crypto from 'node:crypto';
11
+ import { AGE_BASIS } from './provenance/schema.js';
12
+
13
+ // FR-PROV-019: "reports never show an age without its basis and confidence."
14
+ // FINDING_ORIGIN is the only basis backed by a resolved git commit for the
15
+ // finding's actual introduction; EARLIEST_OBSERVABLE is also git-derived but
16
+ // partial (weaker claim, no exact introduction commit). UNCOMMITTED and
17
+ // FIRST_OBSERVED are both wall-clock fallbacks — the age is a first-seen
18
+ // timestamp, never resolved against git history — and must say so honestly
19
+ // rather than read like a proven date.
20
+ function _ageBasisLabel(ageBasis, confidence) {
21
+ const level = confidence?.level && confidence.level !== 'unknown' ? confidence.level.toUpperCase() : null;
22
+ switch (ageBasis) {
23
+ case AGE_BASIS.FINDING_ORIGIN:
24
+ return `proven origin${level ? `, ${level} confidence` : ''}`;
25
+ case AGE_BASIS.EARLIEST_OBSERVABLE:
26
+ return `earliest observable commit, partial history${level ? `, ${level} confidence` : ''}`;
27
+ case AGE_BASIS.UNCOMMITTED:
28
+ return 'uncommitted — first-seen fallback, origin not proven';
29
+ case AGE_BASIS.FIRST_OBSERVED:
30
+ default:
31
+ return 'first-seen fallback — origin not proven';
32
+ }
33
+ }
11
34
 
12
35
  // Stable fingerprint for cross-scan finding identity. Mirrors the dedupe key.
13
36
  // Exported so a caller can compute the "removed since baseline" (i.e. fixed)
@@ -37,6 +60,25 @@ export function stampFindingTimestamps(findings, baselineMap = new Map(), now =
37
60
  f.lastSeenAt = nowIso;
38
61
  const firstMs = Date.parse(f.firstSeenAt);
39
62
  f.ageDays = Math.max(0, Math.floor((now - firstMs) / 86400000));
63
+ // FR-PROV-019: age/SLA basis. ageDays above stays pure wall-clock —
64
+ // every existing SLA/computeMTTR consumer keeps its current meaning.
65
+ // ageBasis + provenAgeDays are ADDITIVE: a report can show both and
66
+ // explain the discrepancy, never silently swap which number "age" means.
67
+ const status = f.findingProvenance?.status;
68
+ const origin = f.findingProvenance?.findingOrigin;
69
+ if (status === 'complete' && origin?.authorDate) {
70
+ f.ageBasis = AGE_BASIS.FINDING_ORIGIN;
71
+ f.provenAgeDays = Math.max(0, Math.floor((now - Date.parse(origin.authorDate)) / 86400000));
72
+ } else if (status === 'partial' && origin?.authorDate) {
73
+ f.ageBasis = AGE_BASIS.EARLIEST_OBSERVABLE;
74
+ f.provenAgeDays = Math.max(0, Math.floor((now - Date.parse(origin.authorDate)) / 86400000));
75
+ } else if (status === 'uncommitted') {
76
+ f.ageBasis = AGE_BASIS.UNCOMMITTED;
77
+ f.provenAgeDays = f.ageDays;
78
+ } else {
79
+ f.ageBasis = AGE_BASIS.FIRST_OBSERVED;
80
+ f.provenAgeDays = f.ageDays;
81
+ }
40
82
  }
41
83
  return findings;
42
84
  }
@@ -68,28 +110,49 @@ export function findingsExceedingSLA(findings, slaDays = null) {
68
110
  });
69
111
  }
70
112
 
71
- // Median age (days) of the currently-open findings a single-scan proxy for
72
- // "how long has this debt been sitting". True MTTR (computeMTTR) needs the set
73
- // of findings that were FIXED; this reports the open backlog's median age so a
74
- // scan can show whether debt is getting older. Returns null on empty input.
75
- // Local surfaced only through renderSlaSummary (its sole consumer).
76
- function medianOpenAgeDays(findings) {
77
- const ages = (findings || []).map(f => f.ageDays || 0).sort((a, b) => a - b);
78
- if (!ages.length) return null;
79
- return ages[Math.floor(ages.length / 2)];
113
+ // Median age (days) of the currently-open findings, PLUS the ageBasis/
114
+ // confidence of whichever finding landed on that median a single-scan proxy
115
+ // for "how long has this debt been sitting". True MTTR (computeMTTR) needs the
116
+ // set of findings that were FIXED; this reports the open backlog's median age
117
+ // so a scan can show whether debt is getting older. Returns null on empty
118
+ // input. Local — surfaced only through renderSlaSummary (its sole consumer).
119
+ //
120
+ // FR-PROV-019: the days figure prefers `provenAgeDays` (git-derived when
121
+ // available) over the pure-wall-clock `ageDays`, and the ageBasis/confidence
122
+ // travel WITH the day count they describe — never a bare number with the
123
+ // basis looked up separately, which is how the original miss happened
124
+ // (ageBasis was stamped onto the finding but never reached the string that
125
+ // printed its age). Findings stamped by an older/test caller that never ran
126
+ // through stampFindingTimestamps (no ageBasis at all) degrade to the same
127
+ // honest "not proven" label FIRST_OBSERVED gets, never a false claim.
128
+ function medianOpenAge(findings) {
129
+ const entries = (findings || [])
130
+ .map(f => ({
131
+ days: f.provenAgeDays != null ? f.provenAgeDays : (f.ageDays || 0),
132
+ ageBasis: f.ageBasis || AGE_BASIS.FIRST_OBSERVED,
133
+ confidence: f.findingProvenance?.confidence || null,
134
+ }))
135
+ .sort((a, b) => a.days - b.days);
136
+ if (!entries.length) return null;
137
+ return entries[Math.floor(entries.length / 2)];
80
138
  }
81
139
 
82
140
  // One-line SLA-breach summary for surfacing after a scan (#10). Returns null
83
- // when nothing is past its per-severity SLA. Pairs with medianOpenAgeDays for a
141
+ // when nothing is past its per-severity SLA. Pairs with medianOpenAge for a
84
142
  // "is my security debt aging" readout that the vibecoder can act on.
143
+ //
144
+ // FR-PROV-019: never prints the median age number without its basis and
145
+ // confidence alongside it — see medianOpenAge/_ageBasisLabel above.
85
146
  export function renderSlaSummary(findings, slaDays = null) {
86
147
  const breached = findingsExceedingSLA(findings || [], slaDays);
87
148
  if (!breached.length) return null;
88
149
  const bySev = {};
89
150
  for (const f of breached) bySev[f.severity] = (bySev[f.severity] || 0) + 1;
90
151
  const parts = ['critical', 'high', 'medium', 'low', 'info'].filter(s => bySev[s]).map(s => `${bySev[s]} ${s}`);
91
- const median = medianOpenAgeDays(findings);
92
- const ageNote = median != null ? ` (median open age ${median}d)` : '';
152
+ const median = medianOpenAge(findings);
153
+ const ageNote = median != null
154
+ ? ` (median open age ${median.days}d, ${_ageBasisLabel(median.ageBasis, median.confidence)})`
155
+ : '';
93
156
  return `${breached.length} finding(s) past remediation SLA: ${parts.join(', ')}${ageNote}`;
94
157
  }
95
158