@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.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 (135) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/bin/agentic-security.js +770 -49
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +117 -18
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +24 -6
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/271.index.js +165 -0
  10. package/dist/384.index.js +1 -1
  11. package/dist/435.index.js +187 -52
  12. package/dist/444.index.js +11 -2
  13. package/dist/449.index.js +76 -12
  14. package/dist/526.index.js +117 -18
  15. package/dist/552.index.js +97 -0
  16. package/dist/637.index.js +27 -5
  17. package/dist/730.index.js +311 -0
  18. package/dist/736.index.js +301 -0
  19. package/dist/824.index.js +7 -0
  20. package/dist/905.index.js +88 -22
  21. package/dist/920.index.js +491 -0
  22. package/dist/970.index.js +173 -0
  23. package/dist/agentic-security.mjs +13 -13
  24. package/dist/agentic-security.mjs.sha256 +1 -1
  25. package/dist/calibration-seed.json +2 -0
  26. package/package.json +23 -9
  27. package/src/compare.js +6 -1
  28. package/src/dataflow/CLAUDE.md +1 -1
  29. package/src/dataflow/index.js +18 -0
  30. package/src/dataflow/privacy-catalog.js +290 -0
  31. package/src/dataflow/privacy-deep-walker.js +515 -0
  32. package/src/dataflow/privacy-governance.js +126 -0
  33. package/src/dataflow/privacy-inventory.js +154 -0
  34. package/src/dataflow/privacy-sink-policy.js +125 -0
  35. package/src/dataflow/privacy-taint.js +115 -54
  36. package/src/dataflow/privacy-taxonomy.js +233 -0
  37. package/src/discovery/disprove.js +7 -3
  38. package/src/discovery/hunter.js +9 -5
  39. package/src/discovery/index.js +2 -2
  40. package/src/discovery/llm-invoke.js +69 -13
  41. package/src/egress/audit.js +147 -0
  42. package/src/egress/policy.js +313 -0
  43. package/src/egress/redact.js +180 -0
  44. package/src/engine.js +1048 -302
  45. package/src/fix/apply-fix-service.js +404 -0
  46. package/src/fix/approver-registry.js +157 -0
  47. package/src/history-scan.js +22 -5
  48. package/src/ir/CLAUDE.md +1 -1
  49. package/src/llm-validator/index.js +86 -9
  50. package/src/llm-validator/model-status.js +66 -0
  51. package/src/lsp/server.js +49 -2
  52. package/src/mcp/tools.js +177 -50
  53. package/src/pipeline/analyzer-supervisor.js +93 -0
  54. package/src/pipeline/analyzer-worker.js +26 -0
  55. package/src/pipeline/annotator-runner.js +33 -0
  56. package/src/pipeline/assurance-mode.js +154 -0
  57. package/src/pipeline/cascade-worker-pool.js +172 -0
  58. package/src/pipeline/cascade-worker.js +43 -0
  59. package/src/pipeline/coverage-ledger.js +0 -0
  60. package/src/pipeline/detector-runner.js +51 -0
  61. package/src/pipeline/enrichment-completion.js +58 -0
  62. package/src/pipeline/evidence-provenance.js +91 -0
  63. package/src/pipeline/finding-schema.js +108 -0
  64. package/src/pipeline/legacy-compat.js +101 -0
  65. package/src/pipeline/producer-collector.js +48 -0
  66. package/src/pipeline/producer-registry.js +112 -0
  67. package/src/pipeline/scan-health.js +144 -0
  68. package/src/posture/CLAUDE.md +123 -0
  69. package/src/posture/accuracy-scorecard.js +156 -1
  70. package/src/posture/adversary-agent.js +15 -3
  71. package/src/posture/artifact-registry.js +241 -0
  72. package/src/posture/auditor-walkthrough.js +186 -21
  73. package/src/posture/calibration-feedback.js +201 -0
  74. package/src/posture/calibration-seed.json +2 -0
  75. package/src/posture/calibration.js +25 -0
  76. package/src/posture/compliance-evidence-signing.js +131 -0
  77. package/src/posture/compliance-policy.js +324 -17
  78. package/src/posture/cross-repo-memory.js +7 -2
  79. package/src/posture/custom-rules.js +36 -0
  80. package/src/posture/deterministic.js +8 -1
  81. package/src/posture/encryption-provider.js +205 -0
  82. package/src/posture/evidence-grade-wording.js +71 -0
  83. package/src/posture/fix-history.js +137 -20
  84. package/src/posture/fix-honesty-gate.js +47 -6
  85. package/src/posture/fix-verify.js +65 -8
  86. package/src/posture/fleet.js +0 -0
  87. package/src/posture/flow-narration.js +7 -2
  88. package/src/posture/git-history.js +13 -5
  89. package/src/posture/legal-hold.js +140 -0
  90. package/src/posture/llm-redteam.js +10 -1
  91. package/src/posture/material-change.js +111 -2
  92. package/src/posture/mttr.js +75 -12
  93. package/src/posture/policy-bundle.js +274 -0
  94. package/src/posture/pre-incident-archaeology.js +39 -7
  95. package/src/posture/privacy-framework.js +47 -6
  96. package/src/posture/production-feedback.js +179 -0
  97. package/src/posture/provenance/ai-authorship.js +68 -0
  98. package/src/posture/provenance/branch-entry.js +80 -0
  99. package/src/posture/provenance/cache.js +143 -0
  100. package/src/posture/provenance/confidence.js +36 -0
  101. package/src/posture/provenance/coordinator.js +786 -0
  102. package/src/posture/provenance/dag-walk.js +249 -0
  103. package/src/posture/provenance/evidence-attribution.js +59 -0
  104. package/src/posture/provenance/git-evidence.js +310 -0
  105. package/src/posture/provenance/lifecycle.js +208 -0
  106. package/src/posture/provenance/missing-control-resolver.js +137 -0
  107. package/src/posture/provenance/origin-resolver.js +342 -0
  108. package/src/posture/provenance/predicate-replay.js +133 -0
  109. package/src/posture/provenance/providers/config.js +39 -0
  110. package/src/posture/provenance/providers/github.js +62 -0
  111. package/src/posture/provenance/providers/gitlab.js +58 -0
  112. package/src/posture/provenance/repo-lineage.js +74 -0
  113. package/src/posture/provenance/sca-origin.js +139 -0
  114. package/src/posture/provenance/schema.js +255 -0
  115. package/src/posture/provenance/transitive-sca.js +147 -0
  116. package/src/posture/provenance/validate.js +30 -0
  117. package/src/posture/provenance-evidence-bundle.js +144 -0
  118. package/src/posture/retention-policy.js +132 -0
  119. package/src/posture/risk-dollars.js +216 -26
  120. package/src/posture/sbom-diff.js +15 -2
  121. package/src/posture/scan-checkpoint.js +176 -31
  122. package/src/posture/secret-history.js +10 -2
  123. package/src/posture/state-dir.js +64 -5
  124. package/src/posture/state-lifecycle-report.js +77 -0
  125. package/src/posture/suppressions.js +59 -3
  126. package/src/posture/vuln-archaeology.js +8 -2
  127. package/src/pr-delta.js +25 -4
  128. package/src/privacy/ir-adapter.js +380 -0
  129. package/src/report/index.js +248 -5
  130. package/src/report/oscal.js +7 -2
  131. package/src/runScan.js +34 -5
  132. package/src/sast/cpp.js +3 -14
  133. package/src/sast/rate-limit.js +33 -3
  134. package/src/sca/llm-function-extract.js +6 -0
  135. package/src/util/git-hardening.js +128 -0
@@ -29,9 +29,33 @@
29
29
  // finding-family: <name> must-be: zero | min: <n> | max: <n>
30
30
  // file-exists: <relative-path>
31
31
  // documented: <relative-path> (alias for file-exists)
32
+ // file-contains: <relative-path> pattern: <regex>
33
+ // FR-503: for a control that demands the file's CONTENT prove something,
34
+ // not just its existence — "mere artifact existence... is insufficient
35
+ // unless the mapping explicitly defines it." file-exists remains valid
36
+ // for controls that genuinely only need a document to exist.
32
37
  // env-var-set: <name>
33
38
  // sca-policy-has-entry: <type> (e.g. accept-risk, sla)
34
39
  //
40
+ // FR-504/FR-506 (assurance-hardening PRD) — optional, per-control fields,
41
+ // all additive (a control naming none of these is unaffected):
42
+ // owner: <string> — who is accountable for this control
43
+ // reviewer: <string> — who last reviewed the evidence
44
+ // reviewed-at: <ISO date> — when it was last reviewed
45
+ // review-interval-days: <number> — opts into staleness: once
46
+ // reviewed-at + this interval has
47
+ // passed, status becomes `stale`
48
+ // not-applicable: true — legacy bare exception, never expires
49
+ // not-applicable: — structured exception; an EXPIRED one
50
+ // reason: "..." reopens the control as status `gap`
51
+ // owner: "..." rather than silently staying
52
+ // expires-at: <ISO date> not-applicable
53
+ //
54
+ // Every verification also computes `evidenceDigest` (FR-504) — a sha256 of
55
+ // {repository, commit, scope, engine, ruleset, analyzerHealth,
56
+ // mappingVersion, controls[{id,status}]} — so changing any bound input is a
57
+ // checkable property, not an assertion.
58
+ //
35
59
  // Output:
36
60
  // .agentic-security/compliance-evidence.json — JSON-LD compliant
37
61
  // structured artifact
@@ -39,12 +63,42 @@
39
63
 
40
64
  import * as fs from 'node:fs';
41
65
  import * as path from 'node:path';
66
+ import * as crypto from 'node:crypto';
67
+ import { execFileSync } from 'node:child_process';
68
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
42
69
  import * as yaml from '../util/yaml.js';
43
- import { statePath, safeWriteState } from './state-dir.js';
70
+ import { statePath, safeWriteState, STATE_DIR_NAME } from './state-dir.js';
44
71
  import { SCANNER_VERSION } from './version.js';
72
+ import { EVIDENCE_GRADE_DISCLAIMER, EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
73
+ import { loadSigningKeyIfConfigured, signComplianceEvidence } from './compliance-evidence-signing.js';
74
+ import { maybeEncryptForWrite } from './encryption-provider.js';
45
75
 
46
76
  const POLICY_FILE = 'compliance.policy.yml';
47
77
 
78
+ // FR-508: "export evidence in a stable API suitable for external GRC
79
+ // ingestion | exported records retain source references, status
80
+ // semantics, and schema version." The JSON-LD shape itself has been
81
+ // additive-only since it was introduced (every field this session has
82
+ // added — evidenceDigest, owner/reviewer/staleReason/gapReason, signature —
83
+ // was optional and backward-compatible), which IS the stability guarantee;
84
+ // this constant is what lets a consumer verify that claim programmatically
85
+ // instead of taking it on faith. Bump when — and only when — an existing
86
+ // field's MEANING changes incompatibly (a genuinely additive field does
87
+ // not require a bump, matching every semver-adjacent convention in this
88
+ // codebase: new optional fields are not breaking changes).
89
+ export const EVIDENCE_SCHEMA_VERSION = 1;
90
+
91
+ // FR-508: "status semantics" — a GRC tool ingesting this artifact should
92
+ // not have to reverse-engineer what each status string means from this
93
+ // codebase's own source; the definitions travel WITH the document.
94
+ export const STATUS_SEMANTICS = {
95
+ compliant: 'Every check for this control passed against the current scan.',
96
+ 'non-compliant': 'At least one check for this control failed against the current scan.',
97
+ 'not-applicable': 'The control is explicitly excepted by the policy mapping (not-applicable) and was not evaluated.',
98
+ stale: 'The control passed its checks, but its evidence exceeded an operator-configured review interval (FR-506) and has not been re-reviewed.',
99
+ gap: 'A previously-recorded not-applicable exception has expired (FR-506); the control has neither a fresh exception nor a fresh evaluation.',
100
+ };
101
+
48
102
  export function loadPolicy(scanRoot) {
49
103
  const fp = statePath(scanRoot, POLICY_FILE);
50
104
  if (!fs.existsSync(fp)) return null;
@@ -57,6 +111,27 @@ export function loadPolicy(scanRoot) {
57
111
  }
58
112
  }
59
113
 
114
+ // FR-506: `not-applicable` accepts either the legacy bare `true` (kept
115
+ // working forever — many existing compliance policy YAMLs use this shape,
116
+ // and a bare exception with no expiry is a valid, if less accountable,
117
+ // choice) or a structured exception object `{reason, owner, expires_at}`.
118
+ // Only the structured shape can expire; a bare `true` never does, matching
119
+ // this session's own "no expiry configured = never expires" convention
120
+ // (D-0025's suppression exceptions, the waiver-file pattern).
121
+ function _normalizeNotApplicable(raw) {
122
+ if (!raw) return null;
123
+ if (raw === true) return { legacy: true };
124
+ if (typeof raw === 'object') {
125
+ return {
126
+ legacy: false,
127
+ reason: raw.reason || null,
128
+ owner: raw.owner || null,
129
+ expires_at: raw['expires-at'] || raw.expires_at || null,
130
+ };
131
+ }
132
+ return null;
133
+ }
134
+
60
135
  function _normalize(doc) {
61
136
  if (!doc) return null;
62
137
  return {
@@ -67,11 +142,55 @@ function _normalize(doc) {
67
142
  title: c.title || id,
68
143
  requires: Array.isArray(c.requires) ? c.requires : [],
69
144
  evidence: Array.isArray(c.evidence) ? c.evidence : [],
70
- not_applicable: !!c['not-applicable'],
145
+ not_applicable: _normalizeNotApplicable(c['not-applicable']),
146
+ // FR-506: freshness + accountability metadata. All optional and
147
+ // additive — a control naming neither owner/reviewer nor a review
148
+ // interval is completely unaffected (no-op, matching every other
149
+ // gate this session has built).
150
+ owner: c.owner || null,
151
+ reviewer: c.reviewer || null,
152
+ reviewed_at: c['reviewed-at'] || c.reviewed_at || null,
153
+ review_interval_days: typeof c['review-interval-days'] === 'number' ? c['review-interval-days']
154
+ : (typeof c.review_interval_days === 'number' ? c.review_interval_days : null),
71
155
  })),
72
156
  };
73
157
  }
74
158
 
159
+ /**
160
+ * FR-506: is this control's evidence stale? Only meaningful when the
161
+ * mapping author opted in via `review-interval-days` — a control with none
162
+ * set is never stale, by construction (there is no baseline to be stale
163
+ * relative to). `reviewed_at` missing while an interval IS set counts as
164
+ * "never reviewed," which is at least as stale as a review that happened
165
+ * on day zero — not a free pass.
166
+ */
167
+ function _staleness(control, now) {
168
+ if (!Number.isFinite(control.review_interval_days)) return { stale: false };
169
+ const reviewedAt = control.reviewed_at ? Date.parse(control.reviewed_at) : NaN;
170
+ const baseline = Number.isFinite(reviewedAt) ? reviewedAt : 0; // never-reviewed => already maximally stale
171
+ const ageMs = now - baseline;
172
+ const staleAfterMs = control.review_interval_days * 24 * 3600 * 1000;
173
+ if (ageMs > staleAfterMs) {
174
+ return { stale: true, reason: control.reviewed_at ? `last reviewed ${control.reviewed_at}, exceeds ${control.review_interval_days}-day interval` : `never reviewed (review-interval-days: ${control.review_interval_days} requires an initial reviewed-at)` };
175
+ }
176
+ return { stale: false };
177
+ }
178
+
179
+ /**
180
+ * FR-506: has this control's not-applicable EXCEPTION expired? A bare-`true`
181
+ * exception (legacy shape) never expires. An expired structured exception
182
+ * reopens the control as a GAP (unevidenced), not silently back to
183
+ * whatever `requires` would have said — an operator who marked something
184
+ * not-applicable never ran the underlying checks, so there is no fresh
185
+ * compliant/non-compliant verdict to fall back to.
186
+ */
187
+ function _exceptionExpired(notApplicable, now) {
188
+ if (!notApplicable || notApplicable.legacy) return false;
189
+ if (!notApplicable.expires_at) return false;
190
+ const exp = Date.parse(notApplicable.expires_at);
191
+ return Number.isFinite(exp) && exp < now;
192
+ }
193
+
75
194
  /**
76
195
  * Run a single primitive check against the scanner state.
77
196
  * { passed, reason }
@@ -100,6 +219,29 @@ function _runCheck(check, ctx) {
100
219
  if (fs.existsSync(fp)) return { passed: true, reason: `${rel} exists` };
101
220
  return { passed: false, reason: `${rel} not found` };
102
221
  }
222
+ // FR-503: "mere artifact existence... is insufficient UNLESS the mapping
223
+ // explicitly defines it." `file-exists` remains legitimate for controls
224
+ // that genuinely only need "is there a policy document" — this is a NEW,
225
+ // separate primitive a mapping author opts into for a control that
226
+ // demands the file's CONTENT prove something (e.g. a workflow file that
227
+ // must actually configure dependency automation, not merely exist under
228
+ // that name with an empty body). Read-first-in-try/catch (D-0012) — no
229
+ // existsSync-then-readFileSync, unlike the sibling file-exists check
230
+ // above (pre-existing code, not touched here).
231
+ if (check['file-contains']) {
232
+ const rel = check['file-contains'];
233
+ const fp = path.join(ctx.scanRoot, rel);
234
+ let content;
235
+ try { content = fs.readFileSync(fp, 'utf8'); }
236
+ catch { return { passed: false, reason: `${rel} not found` }; }
237
+ const patternStr = check.pattern;
238
+ if (!patternStr) return { passed: false, reason: 'file-contains check has no pattern' };
239
+ let re;
240
+ try { re = new RegExp(patternStr, 'i'); }
241
+ catch (e) { return { passed: false, reason: `file-contains pattern is not a valid regex: ${e.message}` }; }
242
+ if (re.test(content)) return { passed: true, reason: `${rel} exists and matches required pattern` };
243
+ return { passed: false, reason: `${rel} exists but does not match required pattern (mere existence is not enough for this control)` };
244
+ }
103
245
  if (check['env-var-set']) {
104
246
  const name = check['env-var-set'];
105
247
  if (process.env[name]) return { passed: true, reason: `$${name} set` };
@@ -108,9 +250,16 @@ function _runCheck(check, ctx) {
108
250
  if (check['sca-policy-has-entry']) {
109
251
  const type = check['sca-policy-has-entry'];
110
252
  const policyPath = statePath(ctx.scanRoot, 'sca-policy.yml');
111
- if (!fs.existsSync(policyPath)) return { passed: false, reason: 'sca-policy.yml not found' };
253
+ // FR-503 self-scan finding, fixed in passing: this was an
254
+ // existsSync-then-readFileSync TOCTOU (D-0012's own named
255
+ // anti-pattern) — read first, treat ENOENT as "not found" inside the
256
+ // same catch that already handles a parse error, rather than a
257
+ // separate pre-check with a window between it and the read.
258
+ let raw;
259
+ try { raw = fs.readFileSync(policyPath, 'utf8'); }
260
+ catch { return { passed: false, reason: 'sca-policy.yml not found' }; }
112
261
  try {
113
- const policy = yaml.load(fs.readFileSync(policyPath, 'utf8'));
262
+ const policy = yaml.load(raw);
114
263
  if (type === 'accept-risk' && Array.isArray(policy['accept-risk']) && policy['accept-risk'].length) {
115
264
  return { passed: true, reason: `${policy['accept-risk'].length} accept-risk entries` };
116
265
  }
@@ -152,17 +301,34 @@ export function verifyPolicy(policy, ctx) {
152
301
  ...(ctx.supplyChain || []).map(sc => ({ ...sc, family: sc.family || 'vulnerable-dep' })),
153
302
  ],
154
303
  };
304
+ const now = Date.now();
155
305
  const results = [];
156
306
  for (const control of policy.controls) {
157
307
  if (control.not_applicable) {
308
+ // FR-506: an EXPIRED structured exception reopens the control as a
309
+ // gap — nobody re-affirmed the exception, and the underlying checks
310
+ // were never run, so there is no fresher verdict to report instead.
311
+ if (_exceptionExpired(control.not_applicable, now)) {
312
+ results.push({
313
+ ...control, status: 'gap', checks: [],
314
+ gapReason: `not-applicable exception expired on ${control.not_applicable.expires_at} — re-affirm or re-evaluate this control`,
315
+ });
316
+ continue;
317
+ }
158
318
  results.push({ ...control, status: 'not-applicable', checks: [] });
159
319
  continue;
160
320
  }
161
321
  const checkResults = control.requires.map(c => ({ check: c, result: _runCheck(c, ctx) }));
162
322
  const allPassed = checkResults.every(r => r.result.passed);
323
+ // FR-506: staleness is checked AFTER the real verdict, and can only ever
324
+ // downgrade a `compliant` reading — a control already reporting
325
+ // non-compliant findings does not need a second, redundant caveat that
326
+ // its (already-failing) evidence is also old.
327
+ const staleness = allPassed ? _staleness(control, now) : { stale: false };
163
328
  results.push({
164
329
  ...control,
165
- status: allPassed ? 'compliant' : 'non-compliant',
330
+ status: staleness.stale ? 'stale' : (allPassed ? 'compliant' : 'non-compliant'),
331
+ ...(staleness.stale ? { staleReason: staleness.reason } : {}),
166
332
  checks: checkResults,
167
333
  });
168
334
  }
@@ -171,8 +337,83 @@ export function verifyPolicy(policy, ctx) {
171
337
  compliant: results.filter(r => r.status === 'compliant').length,
172
338
  nonCompliant: results.filter(r => r.status === 'non-compliant').length,
173
339
  notApplicable: results.filter(r => r.status === 'not-applicable').length,
340
+ // FR-506: reported at the top level, not buried inside individual
341
+ // controls — a summary that only says "compliant: 40" while 5 of those
342
+ // are actually stale evidence, or 3 gaps hide behind expired
343
+ // exceptions, is exactly the false-assurance shape this PRD exists to
344
+ // close.
345
+ stale: results.filter(r => r.status === 'stale').length,
346
+ gap: results.filter(r => r.status === 'gap').length,
347
+ };
348
+ // FR-504: bind this conclusion to the inputs that could change it —
349
+ // repository, commit, scope (framework+version), engine, ruleset,
350
+ // analyzer health, and mapping version — so "changing any bound input
351
+ // produces a new evidence digest" is a real, checkable property rather
352
+ // than an assertion. Deliberately mirrors attestation.js's own
353
+ // allowlist-then-sorted-JSON-then-sha256 shape rather than inventing a
354
+ // fourth canonicalisation scheme in this codebase (see D-0026's lesson,
355
+ // applied here even though this isn't a SIGNED bundle — the same
356
+ // "unambiguous, reproducible digest of a defined field set" discipline
357
+ // still applies).
358
+ const evidenceDigest = computeEvidenceDigest({
359
+ repository: ctx.repository ?? null,
360
+ commit: ctx.commit ?? _currentCommit(ctx.scanRoot),
361
+ scope: `${policy.framework}@${policy.version}`,
362
+ engine: SCANNER_VERSION,
363
+ ruleset: ctx.rulesetVersion ?? null,
364
+ analyzerHealth: ctx.scanHealth?.status ?? null,
365
+ mappingVersion: policy.version,
366
+ controls: results.map(r => ({ id: r.id, status: r.status })),
367
+ });
368
+ return { framework: policy.framework, version: policy.version, controls: results, summary, evidenceDigest };
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.
380
+ function _currentCommit(scanRoot) {
381
+ if (!scanRoot) return null;
382
+ try {
383
+ return execFileSync('git', hardenGitArgs(['rev-parse', 'HEAD']), { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() }).trim();
384
+ } catch { return null; } // not a git repo, or git unavailable — not an error condition
385
+ }
386
+
387
+ /**
388
+ * FR-504: the signed field set — an allowlist, exactly as attestation.js
389
+ * and evidence-bundle.js canonicalise. A field not named here is not bound,
390
+ * so a future addition to the evidence report cannot silently join or
391
+ * leave the digest's scope.
392
+ */
393
+ export function computeEvidenceDigest(fields) {
394
+ const bound = {
395
+ repository: fields.repository ?? null,
396
+ commit: fields.commit ?? null,
397
+ scope: fields.scope ?? null,
398
+ engine: fields.engine ?? null,
399
+ ruleset: fields.ruleset ?? null,
400
+ analyzerHealth: fields.analyzerHealth ?? null,
401
+ mappingVersion: fields.mappingVersion ?? null,
402
+ controls: [...(fields.controls || [])].sort((a, b) => String(a.id).localeCompare(String(b.id))),
174
403
  };
175
- return { framework: policy.framework, version: policy.version, controls: results, summary };
404
+ return crypto.createHash('sha256').update(_canonicalJson(bound), 'utf8').digest('hex');
405
+ }
406
+
407
+ // Deterministic JSON — keys sorted at every level, arrays order-preserving.
408
+ // Same algorithm as evidence-bundle.js's canonicalJson (duplicated rather
409
+ // than imported for the same reason policy-bundle.js gave: a pure,
410
+ // three-line function, and importing it would couple this module's digest
411
+ // format to a sibling module's internals for no real benefit).
412
+ function _canonicalJson(value) {
413
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
414
+ if (Array.isArray(value)) return `[${value.map(_canonicalJson).join(',')}]`;
415
+ const keys = Object.keys(value).sort();
416
+ return `{${keys.map(k => `${JSON.stringify(k)}:${_canonicalJson(value[k])}`).join(',')}}`;
176
417
  }
177
418
 
178
419
  /**
@@ -186,20 +427,44 @@ export function emitEvidenceJsonLd(report, scanRoot) {
186
427
  'schema': 'https://schema.org/',
187
428
  },
188
429
  '@type': 'ComplianceEvidence',
430
+ // FR-508: schema version and status semantics travel WITH the
431
+ // document — a consumer does not need to know this codebase's own
432
+ // source to interpret either. policySource names exactly which
433
+ // mapping file produced this export (the one per-export "source
434
+ // reference" that is not already implicit in each control's own
435
+ // `checks[].rule`, which already carries the raw check definition).
436
+ schemaVersion: EVIDENCE_SCHEMA_VERSION,
437
+ statusSemantics: STATUS_SEMANTICS,
438
+ policySource: `${STATE_DIR_NAME}/${POLICY_FILE}`,
189
439
  framework: report.framework,
190
440
  version: report.version,
191
441
  generatedAt: new Date().toISOString(),
192
- // CMP-5: this artifact is fed to GRC tooling (Vanta/Drata/SecureFrame) and
193
- // auditors largely unread by a human — the same reason
442
+ // CMP-5 / FR-507: this artifact is fed to GRC tooling (Vanta/Drata/
443
+ // SecureFrame) and auditors largely unread by a human — the same reason
194
444
  // auditor-walkthrough.js's narrative carries this disclaimer verbatim.
195
445
  // Without it here, a machine-consumed "ComplianceEvidence" document reads
196
- // as an attestation, not a scanner's automated observation.
197
- disclaimer: 'This artifact organizes automated scanner evidence into a structured report. It does not certify compliance. A licensed assessor (CPA / auditor / DPO) is responsible for the final attestation.',
446
+ // as an attestation, not a scanner's automated observation. Sourced from
447
+ // evidence-grade-wording.js see that module's header for why this text
448
+ // names all three assurance tiers explicitly, not just "not certified."
449
+ disclaimer: EVIDENCE_GRADE_DISCLAIMER,
198
450
  provenance: { engineVersion: SCANNER_VERSION },
451
+ // FR-504: "changing any bound input produces a new evidence digest."
452
+ // Present only when verifyPolicy actually computed one (every real
453
+ // caller does; a hand-built report in a test predating this field
454
+ // simply omits it, rather than this function fabricating one from
455
+ // partial information).
456
+ ...(report.evidenceDigest ? { evidenceDigest: report.evidenceDigest } : {}),
199
457
  summary: report.summary,
200
458
  controls: report.controls.map(c => ({
201
459
  '@type': 'Control',
202
460
  id: c.id, title: c.title, status: c.status,
461
+ // FR-506: present only when set — an owner-less, reviewer-less
462
+ // control (the overwhelming majority, for any policy predating this
463
+ // field) does not gain fabricated accountability metadata.
464
+ ...(c.owner ? { owner: c.owner } : {}),
465
+ ...(c.reviewer ? { reviewer: c.reviewer } : {}),
466
+ ...(c.staleReason ? { staleReason: c.staleReason } : {}),
467
+ ...(c.gapReason ? { gapReason: c.gapReason } : {}),
203
468
  checks: c.checks.map(ck => ({
204
469
  '@type': 'Check',
205
470
  rule: ck.check,
@@ -209,10 +474,36 @@ export function emitEvidenceJsonLd(report, scanRoot) {
209
474
  narrative_evidence: c.evidence || [],
210
475
  })),
211
476
  };
477
+ // FR-505: "sign evidence manifests WHEN SIGNING IS CONFIGURED" — a no-op
478
+ // when the operator has not already set up a signing key (the common
479
+ // case), so an ordinary scan's behavior is unchanged unless an operator
480
+ // opted in. loadSigningKeyIfConfigured only ever READS an existing key,
481
+ // never generates one — see that function's own header for why emitting
482
+ // compliance evidence (an automatic, routine side effect of every scan)
483
+ // must not silently create key material the way an explicit `attest`
484
+ // command legitimately does.
485
+ let signed = jsonld;
486
+ const signingKey = loadSigningKeyIfConfigured();
487
+ if (signingKey) signed = signComplianceEvidence(jsonld, signingKey.privateKeyPem);
212
488
  // Through the seam — see the note in pqc-migration-plan.js. The report is
213
489
  // still returned when writing is off; only the artifact is withheld.
214
- safeWriteState(statePath(scanRoot, 'compliance-evidence.json'), JSON.stringify(jsonld, null, 2));
215
- return jsonld;
490
+ // FR-705: this artifact is marked confidential in artifact-registry.js.
491
+ // maybeEncryptForWrite is a safe no-op when encryption isn't configured
492
+ // (the overwhelming default case) and encrypts the JSON body when it is.
493
+ // The fail-closed half of FR-705's acceptance criterion lives HERE: when
494
+ // encryption is required but unavailable, the write is skipped entirely
495
+ // — never a plaintext fallback — and an operator is told why, rather
496
+ // than silently persisting unencrypted sensitive compliance evidence.
497
+ const gated = maybeEncryptForWrite(scanRoot, 'compliance-evidence.json', JSON.stringify(signed, null, 2));
498
+ if (!gated.ok) {
499
+ // Always visible, not debug-gated: a required control silently not
500
+ // being met is exactly the kind of thing FR-705 exists to surface,
501
+ // not hide behind an opt-in verbosity flag.
502
+ process.stderr.write(`[agentic-security] compliance-evidence.json NOT written: ${gated.reason}\n`);
503
+ } else {
504
+ safeWriteState(statePath(scanRoot, 'compliance-evidence.json'), gated.content);
505
+ }
506
+ return signed;
216
507
  }
217
508
 
218
509
  /**
@@ -224,12 +515,21 @@ export function emitEvidenceMarkdown(report, scanRoot) {
224
515
  lines.push('');
225
516
  lines.push(`Generated by agentic-security (engine ${SCANNER_VERSION}) on ${new Date().toISOString().slice(0,10)}.`);
226
517
  lines.push('');
227
- lines.push('> This document organizes automated scanner evidence into a structured report. It does not certify compliance. A licensed assessor (CPA / auditor / DPO) is responsible for the final attestation.');
518
+ lines.push(`> ${EVIDENCE_GRADE_DISCLAIMER_SHORT}`);
228
519
  lines.push('');
229
- lines.push(`Compliant: **${report.summary.compliant}** / Non-compliant: **${report.summary.nonCompliant}** / Not applicable: **${report.summary.notApplicable}** of ${report.summary.total} controls.`);
520
+ if (report.evidenceDigest) {
521
+ lines.push(`Evidence digest (FR-504 — repository, commit, scope, engine, ruleset, analyzer health, and mapping version bound): \`${report.evidenceDigest}\``);
522
+ lines.push('');
523
+ }
524
+ lines.push(`Compliant: **${report.summary.compliant}** / Non-compliant: **${report.summary.nonCompliant}** / Not applicable: **${report.summary.notApplicable}**` +
525
+ `${report.summary.stale ? ` / Stale: **${report.summary.stale}**` : ''}${report.summary.gap ? ` / Gap: **${report.summary.gap}**` : ''} of ${report.summary.total} controls.`);
230
526
  lines.push('');
231
527
  for (const c of report.controls) {
232
528
  lines.push(`## ${c.id} — ${c.title} (${c.status})`);
529
+ if (c.owner) lines.push(`- owner: ${c.owner}`);
530
+ if (c.reviewer) lines.push(`- reviewer: ${c.reviewer}`);
531
+ if (c.staleReason) lines.push(`- ⚠ stale: ${c.staleReason}`);
532
+ if (c.gapReason) lines.push(`- ⚠ gap: ${c.gapReason}`);
233
533
  for (const ck of c.checks) {
234
534
  const mark = ck.result.passed ? '✓' : '✗';
235
535
  lines.push(`- ${mark} \`${JSON.stringify(ck.check)}\` — ${ck.result.reason}`);
@@ -241,8 +541,15 @@ export function emitEvidenceMarkdown(report, scanRoot) {
241
541
  }
242
542
  lines.push('');
243
543
  }
244
- safeWriteState(statePath(scanRoot, 'compliance-evidence.md'), lines.join('\n'));
245
- return lines.join('\n');
544
+ // FR-705: same fail-closed gate as emitEvidenceJsonLd above.
545
+ const rendered = lines.join('\n');
546
+ const gated = maybeEncryptForWrite(scanRoot, 'compliance-evidence.md', rendered);
547
+ if (!gated.ok) {
548
+ process.stderr.write(`[agentic-security] compliance-evidence.md NOT written: ${gated.reason}\n`);
549
+ } else {
550
+ safeWriteState(statePath(scanRoot, 'compliance-evidence.md'), gated.content);
551
+ }
552
+ return rendered;
246
553
  }
247
554
 
248
- export const _internals = { _normalize, _runCheck };
555
+ export const _internals = { _normalize, _runCheck, _staleness, _exceptionExpired, _currentCommit };
@@ -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);
@@ -52,6 +52,42 @@ function rulesDir(scanRoot) {
52
52
  return statePath(scanRoot, 'rules');
53
53
  }
54
54
 
55
+ // FR-207: freshness of the operator's own custom rule packs. Reuses
56
+ // compliance-policy.js's exact opt-in idiom (review-interval-days +
57
+ // reviewed-at at the document's top level, sibling to `rules:`;
58
+ // baseline-zero-if-never-reviewed) rather than the file's mtime — a git
59
+ // checkout or CI clone resets mtimes to checkout time, which would make an
60
+ // mtime-based check silently blind in CI, the one place a strict-mode
61
+ // failure matters most. A rule pack that never opts in (no
62
+ // review-interval-days) is never reported stale, matching every other
63
+ // freshness check in this codebase — no claim, no staleness.
64
+ export function customRulesFreshness(scanRoot, { now = Date.now() } = {}) {
65
+ const dir = rulesDir(scanRoot);
66
+ const result = { checked: 0, stale: false, staleFiles: [] };
67
+ if (!fs.existsSync(dir)) return result;
68
+ let files = [];
69
+ try {
70
+ files = fs.readdirSync(dir).filter(f => /\.(ya?ml)$/i.test(f)).map(f => path.join(dir, f));
71
+ } catch { return result; }
72
+ for (const fp of files) {
73
+ let raw;
74
+ try { raw = yaml.load(fs.readFileSync(fp, 'utf8')); } catch { continue; }
75
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue; // bare rule list can't carry document-level metadata
76
+ const intervalDays = typeof raw['review-interval-days'] === 'number' ? raw['review-interval-days']
77
+ : (typeof raw.review_interval_days === 'number' ? raw.review_interval_days : null);
78
+ if (!Number.isFinite(intervalDays)) continue; // no opt-in => never stale
79
+ result.checked++;
80
+ const reviewedAt = raw['reviewed-at'] || raw.reviewed_at || null;
81
+ const reviewedTs = reviewedAt ? Date.parse(reviewedAt) : NaN;
82
+ const baseline = Number.isFinite(reviewedTs) ? reviewedTs : 0;
83
+ if (now - baseline > intervalDays * 86400000) {
84
+ result.stale = true;
85
+ result.staleFiles.push({ file: path.basename(fp), reviewedAt, intervalDays });
86
+ }
87
+ }
88
+ return result;
89
+ }
90
+
55
91
  export function loadCustomRules(scanRoot) {
56
92
  const dir = rulesDir(scanRoot);
57
93
  const out = [];
@@ -83,8 +83,15 @@ function sortFn(a, b) {
83
83
  }
84
84
 
85
85
  export function makeDeterministic(scan, meta) {
86
+ // Assurance-hardening PRD FR-104: scan.findings is now frozen by
87
+ // engine.js after finalization (no producer may append past that point).
88
+ // Object.prototype.sort() mutates in place and throws a TypeError on a
89
+ // frozen array — replace with a new sorted array and reassign rather than
90
+ // sorting the existing one. `scan` itself (the object holding the
91
+ // reference) is not frozen, only the array was, so this reassignment is
92
+ // legal and produces byte-identical output to the old in-place sort.
86
93
  for (const k of ['findings', 'secrets', 'logicVulns', 'supplyChain']) {
87
- if (Array.isArray(scan[k])) scan[k].sort(sortFn);
94
+ if (Array.isArray(scan[k])) scan[k] = [...scan[k]].sort(sortFn);
88
95
  }
89
96
  if (meta) {
90
97
  meta.scanId = 'deterministic';