@clear-capabilities/agentic-security-scanner 0.143.0 → 0.144.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 (95) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/bin/agentic-security.js +477 -47
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +108 -17
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +1 -1
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/435.index.js +165 -52
  11. package/dist/526.index.js +108 -17
  12. package/dist/552.index.js +97 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/730.index.js +311 -0
  15. package/dist/736.index.js +301 -0
  16. package/dist/824.index.js +7 -0
  17. package/dist/905.index.js +88 -22
  18. package/dist/920.index.js +491 -0
  19. package/dist/970.index.js +109 -0
  20. package/dist/agentic-security.mjs +13 -13
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/dist/calibration-seed.json +2 -0
  23. package/package.json +19 -11
  24. package/src/dataflow/index.js +18 -0
  25. package/src/dataflow/privacy-catalog.js +290 -0
  26. package/src/dataflow/privacy-deep-walker.js +515 -0
  27. package/src/dataflow/privacy-governance.js +126 -0
  28. package/src/dataflow/privacy-inventory.js +154 -0
  29. package/src/dataflow/privacy-sink-policy.js +125 -0
  30. package/src/dataflow/privacy-taint.js +115 -54
  31. package/src/dataflow/privacy-taxonomy.js +233 -0
  32. package/src/discovery/disprove.js +7 -3
  33. package/src/discovery/hunter.js +9 -5
  34. package/src/discovery/index.js +2 -2
  35. package/src/discovery/llm-invoke.js +69 -13
  36. package/src/egress/audit.js +147 -0
  37. package/src/egress/policy.js +313 -0
  38. package/src/egress/redact.js +180 -0
  39. package/src/engine.js +575 -288
  40. package/src/fix/apply-fix-service.js +403 -0
  41. package/src/fix/approver-registry.js +157 -0
  42. package/src/llm-validator/index.js +86 -9
  43. package/src/llm-validator/model-status.js +66 -0
  44. package/src/mcp/tools.js +157 -50
  45. package/src/pipeline/analyzer-supervisor.js +93 -0
  46. package/src/pipeline/analyzer-worker.js +26 -0
  47. package/src/pipeline/annotator-runner.js +33 -0
  48. package/src/pipeline/assurance-mode.js +91 -0
  49. package/src/pipeline/cascade-worker-pool.js +172 -0
  50. package/src/pipeline/cascade-worker.js +43 -0
  51. package/src/pipeline/coverage-ledger.js +0 -0
  52. package/src/pipeline/detector-runner.js +51 -0
  53. package/src/pipeline/enrichment-completion.js +58 -0
  54. package/src/pipeline/evidence-provenance.js +91 -0
  55. package/src/pipeline/finding-schema.js +101 -0
  56. package/src/pipeline/legacy-compat.js +101 -0
  57. package/src/pipeline/producer-collector.js +48 -0
  58. package/src/pipeline/producer-registry.js +112 -0
  59. package/src/pipeline/scan-health.js +144 -0
  60. package/src/posture/CLAUDE.md +2 -0
  61. package/src/posture/accuracy-scorecard.js +96 -1
  62. package/src/posture/adversary-agent.js +15 -3
  63. package/src/posture/artifact-registry.js +217 -0
  64. package/src/posture/auditor-walkthrough.js +70 -8
  65. package/src/posture/calibration-feedback.js +201 -0
  66. package/src/posture/calibration-seed.json +2 -0
  67. package/src/posture/calibration.js +25 -0
  68. package/src/posture/compliance-evidence-signing.js +131 -0
  69. package/src/posture/compliance-policy.js +314 -17
  70. package/src/posture/custom-rules.js +36 -0
  71. package/src/posture/deterministic.js +8 -1
  72. package/src/posture/encryption-provider.js +205 -0
  73. package/src/posture/evidence-grade-wording.js +71 -0
  74. package/src/posture/fix-history.js +113 -19
  75. package/src/posture/fix-honesty-gate.js +47 -6
  76. package/src/posture/fix-verify.js +56 -7
  77. package/src/posture/fleet.js +0 -0
  78. package/src/posture/flow-narration.js +7 -2
  79. package/src/posture/legal-hold.js +140 -0
  80. package/src/posture/llm-redteam.js +10 -1
  81. package/src/posture/material-change.js +90 -0
  82. package/src/posture/policy-bundle.js +274 -0
  83. package/src/posture/privacy-framework.js +33 -6
  84. package/src/posture/production-feedback.js +179 -0
  85. package/src/posture/retention-policy.js +132 -0
  86. package/src/posture/risk-dollars.js +216 -26
  87. package/src/posture/scan-checkpoint.js +176 -31
  88. package/src/posture/state-dir.js +36 -1
  89. package/src/posture/state-lifecycle-report.js +77 -0
  90. package/src/posture/suppressions.js +59 -3
  91. package/src/privacy/ir-adapter.js +380 -0
  92. package/src/report/index.js +51 -2
  93. package/src/report/oscal.js +7 -2
  94. package/src/sast/cpp.js +3 -14
  95. package/src/sca/llm-function-extract.js +6 -0
@@ -0,0 +1,131 @@
1
+ // Sign compliance evidence manifests (assurance-hardening PRD FR-505).
2
+ //
3
+ // "Sign evidence manifests when signing is configured | Signature
4
+ // verification detects altered findings, scope, policy, or evidence
5
+ // references."
6
+ //
7
+ // SAME TRUST DOMAIN AS evidence-bundle.js, DELIBERATELY REUSING ITS KEY
8
+ // --------------------------------------------------------------------------
9
+ // A compliance evidence manifest and a finding evidence bundle make the
10
+ // SAME kind of claim: "this artifact is exactly what MY install produced,
11
+ // unaltered since." Both are self-attestation by the running install, not
12
+ // a claim authored by a third party and distributed for others to verify
13
+ // (that is policy-bundle.js's job, and why IT needed a separate key — see
14
+ // D-0026). Reusing evidence-bundle.js's `ensureKeyPair()` directly means an
15
+ // operator who already generated a key for finding attestations does not
16
+ // need a second one for compliance evidence; the two artifact types are
17
+ // simply two things the SAME key can honestly attest to.
18
+ //
19
+ // EA-03 DISCIPLINE, APPLIED FROM THE FIRST WRITE (see D-0026)
20
+ // --------------------------------------------------------------------------
21
+ // canonicalComplianceEvidenceBytes signs an explicit ALLOWLIST — every key
22
+ // emitEvidenceJsonLd() can actually produce, no more, no less — and
23
+ // verifyComplianceEvidence REJECTS any top-level key outside
24
+ // {that allowlist, signature}. A field stapled on after signing (a status
25
+ // silently edited, a control removed, a narrative_evidence line added) must
26
+ // fail verification, not verify successfully with the addition unnoticed.
27
+ //
28
+ // WHY THE FULL DOCUMENT, NOT JUST evidenceDigest (FR-504)
29
+ // --------------------------------------------------------------------------
30
+ // computeEvidenceDigest (FR-504) binds `controls` down to {id, status} only
31
+ // — enough to prove "the CONCLUSION did not change," but not enough to
32
+ // catch an edited check reason or narrative evidence bullet without
33
+ // touching the top-line status. FR-505's acceptance criterion is broader
34
+ // ("altered ... evidence references"), so the signature here covers the
35
+ // full per-control shape (checks[], narrative_evidence[]) that
36
+ // emitEvidenceJsonLd actually renders, not just the FR-504 digest.
37
+
38
+ import * as fs from 'node:fs';
39
+ import * as crypto from 'node:crypto';
40
+ import { keyPaths } from './evidence-bundle.js';
41
+
42
+ export const COMPLIANCE_EVIDENCE_SCHEMA = 'agentic-security/compliance-evidence@1';
43
+
44
+ /**
45
+ * "Sign evidence manifests WHEN SIGNING IS CONFIGURED" — this is the opt-in
46
+ * check. Unlike `agentic-security attest` (an explicit command an operator
47
+ * runs specifically to sign something, where generating a key on first use
48
+ * is the right default), compliance evidence is emitted automatically on
49
+ * every ordinary scan — auto-generating a signing key as a side effect of a
50
+ * routine scan would be surprising, unrequested behavior. "Configured"
51
+ * therefore means an operator has ALREADY set up the shared key (by running
52
+ * `attest` once, or by any other means) — this function only ever READS,
53
+ * never generates. Read-first-in-try/catch (D-0012) — no
54
+ * existsSync-then-readFileSync.
55
+ *
56
+ * @returns {{privateKeyPem: string, publicKeyPem: string}|null}
57
+ */
58
+ export function loadSigningKeyIfConfigured() {
59
+ const p = keyPaths();
60
+ let privateKeyPem, publicKeyPem;
61
+ try { privateKeyPem = fs.readFileSync(p.privateKey, 'utf8'); } catch { return null; }
62
+ try { publicKeyPem = fs.readFileSync(p.publicKey, 'utf8'); } catch { return null; }
63
+ return { privateKeyPem, publicKeyPem };
64
+ }
65
+
66
+ // The complete set of top-level keys a real emitEvidenceJsonLd() output can
67
+ // carry, BEFORE signing. Must be kept in sync with that function — the
68
+ // signed allowlist and the "known keys" allowlist for unsigned-field
69
+ // rejection are the SAME set, exactly as evidence-bundle.js/policy-bundle.js
70
+ // both already establish.
71
+ const EVIDENCE_FIELDS = [
72
+ '@context', '@type', 'schemaVersion', 'statusSemantics', 'policySource',
73
+ 'framework', 'version', 'generatedAt',
74
+ 'disclaimer', 'provenance', 'evidenceDigest', 'summary', 'controls',
75
+ ];
76
+ const EVIDENCE_TOP_LEVEL_KEYS = new Set([...EVIDENCE_FIELDS, 'signature']);
77
+
78
+ // Deterministic JSON, keys sorted at every level — same algorithm as
79
+ // evidence-bundle.js's canonicalJson and policy-bundle.js's canonicalJson
80
+ // (each duplicated locally rather than imported, per those modules' own
81
+ // stated reasoning: a pure, three-line function not worth coupling three
82
+ // signing modules' formats together for).
83
+ function _canonicalJson(value) {
84
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
85
+ if (Array.isArray(value)) return `[${value.map(_canonicalJson).join(',')}]`;
86
+ const keys = Object.keys(value).sort();
87
+ return `{${keys.map(k => `${JSON.stringify(k)}:${_canonicalJson(value[k])}`).join(',')}}`;
88
+ }
89
+
90
+ export function canonicalComplianceEvidenceBytes(jsonld) {
91
+ const signed = {};
92
+ for (const k of EVIDENCE_FIELDS) signed[k] = jsonld[k] ?? null;
93
+ return Buffer.from(_canonicalJson(signed), 'utf8');
94
+ }
95
+
96
+ /** Sign a compliance evidence document. Returns a new object; the input is not mutated. */
97
+ export function signComplianceEvidence(jsonld, privateKeyPem) {
98
+ const sig = crypto.sign(null, canonicalComplianceEvidenceBytes(jsonld), privateKeyPem);
99
+ return {
100
+ ...jsonld,
101
+ signature: { algorithm: 'ed25519', canonicalisation: COMPLIANCE_EVIDENCE_SCHEMA, value: sig.toString('base64') },
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Verify a signed compliance evidence document with a PUBLIC key. Never
107
+ * throws — a malformed or tampered document from an untrusted source (an
108
+ * auditor or GRC tool checking what they were handed) is an expected input.
109
+ *
110
+ * @returns {{ok: boolean, reason: string|null}}
111
+ */
112
+ export function verifyComplianceEvidence(jsonld, publicKeyPem) {
113
+ if (!jsonld || typeof jsonld !== 'object') return { ok: false, reason: 'document is not an object' };
114
+ const unknownKeys = Object.keys(jsonld).filter(k => !EVIDENCE_TOP_LEVEL_KEYS.has(k));
115
+ if (unknownKeys.length) {
116
+ return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
117
+ }
118
+ const sig = jsonld.signature;
119
+ if (!sig?.value) return { ok: false, reason: 'document is unsigned' };
120
+ if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
121
+ if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
122
+ let sigOk = false;
123
+ try {
124
+ sigOk = crypto.verify(null, canonicalComplianceEvidenceBytes(jsonld), publicKeyPem, Buffer.from(sig.value, 'base64'));
125
+ } catch (e) {
126
+ return { ok: false, reason: `verification error: ${e.message}` };
127
+ }
128
+ return sigOk
129
+ ? { ok: true, reason: null }
130
+ : { ok: false, reason: 'signature does not match the document contents — it was modified after signing' };
131
+ }
@@ -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,41 @@
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 { execSync } from 'node:child_process';
42
68
  import * as yaml from '../util/yaml.js';
43
- import { statePath, safeWriteState } from './state-dir.js';
69
+ import { statePath, safeWriteState, STATE_DIR_NAME } from './state-dir.js';
44
70
  import { SCANNER_VERSION } from './version.js';
71
+ import { EVIDENCE_GRADE_DISCLAIMER, EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
72
+ import { loadSigningKeyIfConfigured, signComplianceEvidence } from './compliance-evidence-signing.js';
73
+ import { maybeEncryptForWrite } from './encryption-provider.js';
45
74
 
46
75
  const POLICY_FILE = 'compliance.policy.yml';
47
76
 
77
+ // FR-508: "export evidence in a stable API suitable for external GRC
78
+ // ingestion | exported records retain source references, status
79
+ // semantics, and schema version." The JSON-LD shape itself has been
80
+ // additive-only since it was introduced (every field this session has
81
+ // added — evidenceDigest, owner/reviewer/staleReason/gapReason, signature —
82
+ // was optional and backward-compatible), which IS the stability guarantee;
83
+ // this constant is what lets a consumer verify that claim programmatically
84
+ // instead of taking it on faith. Bump when — and only when — an existing
85
+ // field's MEANING changes incompatibly (a genuinely additive field does
86
+ // not require a bump, matching every semver-adjacent convention in this
87
+ // codebase: new optional fields are not breaking changes).
88
+ export const EVIDENCE_SCHEMA_VERSION = 1;
89
+
90
+ // FR-508: "status semantics" — a GRC tool ingesting this artifact should
91
+ // not have to reverse-engineer what each status string means from this
92
+ // codebase's own source; the definitions travel WITH the document.
93
+ export const STATUS_SEMANTICS = {
94
+ compliant: 'Every check for this control passed against the current scan.',
95
+ 'non-compliant': 'At least one check for this control failed against the current scan.',
96
+ 'not-applicable': 'The control is explicitly excepted by the policy mapping (not-applicable) and was not evaluated.',
97
+ stale: 'The control passed its checks, but its evidence exceeded an operator-configured review interval (FR-506) and has not been re-reviewed.',
98
+ gap: 'A previously-recorded not-applicable exception has expired (FR-506); the control has neither a fresh exception nor a fresh evaluation.',
99
+ };
100
+
48
101
  export function loadPolicy(scanRoot) {
49
102
  const fp = statePath(scanRoot, POLICY_FILE);
50
103
  if (!fs.existsSync(fp)) return null;
@@ -57,6 +110,27 @@ export function loadPolicy(scanRoot) {
57
110
  }
58
111
  }
59
112
 
113
+ // FR-506: `not-applicable` accepts either the legacy bare `true` (kept
114
+ // working forever — many existing compliance policy YAMLs use this shape,
115
+ // and a bare exception with no expiry is a valid, if less accountable,
116
+ // choice) or a structured exception object `{reason, owner, expires_at}`.
117
+ // Only the structured shape can expire; a bare `true` never does, matching
118
+ // this session's own "no expiry configured = never expires" convention
119
+ // (D-0025's suppression exceptions, the waiver-file pattern).
120
+ function _normalizeNotApplicable(raw) {
121
+ if (!raw) return null;
122
+ if (raw === true) return { legacy: true };
123
+ if (typeof raw === 'object') {
124
+ return {
125
+ legacy: false,
126
+ reason: raw.reason || null,
127
+ owner: raw.owner || null,
128
+ expires_at: raw['expires-at'] || raw.expires_at || null,
129
+ };
130
+ }
131
+ return null;
132
+ }
133
+
60
134
  function _normalize(doc) {
61
135
  if (!doc) return null;
62
136
  return {
@@ -67,11 +141,55 @@ function _normalize(doc) {
67
141
  title: c.title || id,
68
142
  requires: Array.isArray(c.requires) ? c.requires : [],
69
143
  evidence: Array.isArray(c.evidence) ? c.evidence : [],
70
- not_applicable: !!c['not-applicable'],
144
+ not_applicable: _normalizeNotApplicable(c['not-applicable']),
145
+ // FR-506: freshness + accountability metadata. All optional and
146
+ // additive — a control naming neither owner/reviewer nor a review
147
+ // interval is completely unaffected (no-op, matching every other
148
+ // gate this session has built).
149
+ owner: c.owner || null,
150
+ reviewer: c.reviewer || null,
151
+ reviewed_at: c['reviewed-at'] || c.reviewed_at || null,
152
+ review_interval_days: typeof c['review-interval-days'] === 'number' ? c['review-interval-days']
153
+ : (typeof c.review_interval_days === 'number' ? c.review_interval_days : null),
71
154
  })),
72
155
  };
73
156
  }
74
157
 
158
+ /**
159
+ * FR-506: is this control's evidence stale? Only meaningful when the
160
+ * mapping author opted in via `review-interval-days` — a control with none
161
+ * set is never stale, by construction (there is no baseline to be stale
162
+ * relative to). `reviewed_at` missing while an interval IS set counts as
163
+ * "never reviewed," which is at least as stale as a review that happened
164
+ * on day zero — not a free pass.
165
+ */
166
+ function _staleness(control, now) {
167
+ if (!Number.isFinite(control.review_interval_days)) return { stale: false };
168
+ const reviewedAt = control.reviewed_at ? Date.parse(control.reviewed_at) : NaN;
169
+ const baseline = Number.isFinite(reviewedAt) ? reviewedAt : 0; // never-reviewed => already maximally stale
170
+ const ageMs = now - baseline;
171
+ const staleAfterMs = control.review_interval_days * 24 * 3600 * 1000;
172
+ if (ageMs > staleAfterMs) {
173
+ 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)` };
174
+ }
175
+ return { stale: false };
176
+ }
177
+
178
+ /**
179
+ * FR-506: has this control's not-applicable EXCEPTION expired? A bare-`true`
180
+ * exception (legacy shape) never expires. An expired structured exception
181
+ * reopens the control as a GAP (unevidenced), not silently back to
182
+ * whatever `requires` would have said — an operator who marked something
183
+ * not-applicable never ran the underlying checks, so there is no fresh
184
+ * compliant/non-compliant verdict to fall back to.
185
+ */
186
+ function _exceptionExpired(notApplicable, now) {
187
+ if (!notApplicable || notApplicable.legacy) return false;
188
+ if (!notApplicable.expires_at) return false;
189
+ const exp = Date.parse(notApplicable.expires_at);
190
+ return Number.isFinite(exp) && exp < now;
191
+ }
192
+
75
193
  /**
76
194
  * Run a single primitive check against the scanner state.
77
195
  * { passed, reason }
@@ -100,6 +218,29 @@ function _runCheck(check, ctx) {
100
218
  if (fs.existsSync(fp)) return { passed: true, reason: `${rel} exists` };
101
219
  return { passed: false, reason: `${rel} not found` };
102
220
  }
221
+ // FR-503: "mere artifact existence... is insufficient UNLESS the mapping
222
+ // explicitly defines it." `file-exists` remains legitimate for controls
223
+ // that genuinely only need "is there a policy document" — this is a NEW,
224
+ // separate primitive a mapping author opts into for a control that
225
+ // demands the file's CONTENT prove something (e.g. a workflow file that
226
+ // must actually configure dependency automation, not merely exist under
227
+ // that name with an empty body). Read-first-in-try/catch (D-0012) — no
228
+ // existsSync-then-readFileSync, unlike the sibling file-exists check
229
+ // above (pre-existing code, not touched here).
230
+ if (check['file-contains']) {
231
+ const rel = check['file-contains'];
232
+ const fp = path.join(ctx.scanRoot, rel);
233
+ let content;
234
+ try { content = fs.readFileSync(fp, 'utf8'); }
235
+ catch { return { passed: false, reason: `${rel} not found` }; }
236
+ const patternStr = check.pattern;
237
+ if (!patternStr) return { passed: false, reason: 'file-contains check has no pattern' };
238
+ let re;
239
+ try { re = new RegExp(patternStr, 'i'); }
240
+ catch (e) { return { passed: false, reason: `file-contains pattern is not a valid regex: ${e.message}` }; }
241
+ if (re.test(content)) return { passed: true, reason: `${rel} exists and matches required pattern` };
242
+ return { passed: false, reason: `${rel} exists but does not match required pattern (mere existence is not enough for this control)` };
243
+ }
103
244
  if (check['env-var-set']) {
104
245
  const name = check['env-var-set'];
105
246
  if (process.env[name]) return { passed: true, reason: `$${name} set` };
@@ -108,9 +249,16 @@ function _runCheck(check, ctx) {
108
249
  if (check['sca-policy-has-entry']) {
109
250
  const type = check['sca-policy-has-entry'];
110
251
  const policyPath = statePath(ctx.scanRoot, 'sca-policy.yml');
111
- if (!fs.existsSync(policyPath)) return { passed: false, reason: 'sca-policy.yml not found' };
252
+ // FR-503 self-scan finding, fixed in passing: this was an
253
+ // existsSync-then-readFileSync TOCTOU (D-0012's own named
254
+ // anti-pattern) — read first, treat ENOENT as "not found" inside the
255
+ // same catch that already handles a parse error, rather than a
256
+ // separate pre-check with a window between it and the read.
257
+ let raw;
258
+ try { raw = fs.readFileSync(policyPath, 'utf8'); }
259
+ catch { return { passed: false, reason: 'sca-policy.yml not found' }; }
112
260
  try {
113
- const policy = yaml.load(fs.readFileSync(policyPath, 'utf8'));
261
+ const policy = yaml.load(raw);
114
262
  if (type === 'accept-risk' && Array.isArray(policy['accept-risk']) && policy['accept-risk'].length) {
115
263
  return { passed: true, reason: `${policy['accept-risk'].length} accept-risk entries` };
116
264
  }
@@ -152,17 +300,34 @@ export function verifyPolicy(policy, ctx) {
152
300
  ...(ctx.supplyChain || []).map(sc => ({ ...sc, family: sc.family || 'vulnerable-dep' })),
153
301
  ],
154
302
  };
303
+ const now = Date.now();
155
304
  const results = [];
156
305
  for (const control of policy.controls) {
157
306
  if (control.not_applicable) {
307
+ // FR-506: an EXPIRED structured exception reopens the control as a
308
+ // gap — nobody re-affirmed the exception, and the underlying checks
309
+ // were never run, so there is no fresher verdict to report instead.
310
+ if (_exceptionExpired(control.not_applicable, now)) {
311
+ results.push({
312
+ ...control, status: 'gap', checks: [],
313
+ gapReason: `not-applicable exception expired on ${control.not_applicable.expires_at} — re-affirm or re-evaluate this control`,
314
+ });
315
+ continue;
316
+ }
158
317
  results.push({ ...control, status: 'not-applicable', checks: [] });
159
318
  continue;
160
319
  }
161
320
  const checkResults = control.requires.map(c => ({ check: c, result: _runCheck(c, ctx) }));
162
321
  const allPassed = checkResults.every(r => r.result.passed);
322
+ // FR-506: staleness is checked AFTER the real verdict, and can only ever
323
+ // downgrade a `compliant` reading — a control already reporting
324
+ // non-compliant findings does not need a second, redundant caveat that
325
+ // its (already-failing) evidence is also old.
326
+ const staleness = allPassed ? _staleness(control, now) : { stale: false };
163
327
  results.push({
164
328
  ...control,
165
- status: allPassed ? 'compliant' : 'non-compliant',
329
+ status: staleness.stale ? 'stale' : (allPassed ? 'compliant' : 'non-compliant'),
330
+ ...(staleness.stale ? { staleReason: staleness.reason } : {}),
166
331
  checks: checkResults,
167
332
  });
168
333
  }
@@ -171,8 +336,74 @@ export function verifyPolicy(policy, ctx) {
171
336
  compliant: results.filter(r => r.status === 'compliant').length,
172
337
  nonCompliant: results.filter(r => r.status === 'non-compliant').length,
173
338
  notApplicable: results.filter(r => r.status === 'not-applicable').length,
339
+ // FR-506: reported at the top level, not buried inside individual
340
+ // controls — a summary that only says "compliant: 40" while 5 of those
341
+ // are actually stale evidence, or 3 gaps hide behind expired
342
+ // exceptions, is exactly the false-assurance shape this PRD exists to
343
+ // close.
344
+ stale: results.filter(r => r.status === 'stale').length,
345
+ gap: results.filter(r => r.status === 'gap').length,
346
+ };
347
+ // FR-504: bind this conclusion to the inputs that could change it —
348
+ // repository, commit, scope (framework+version), engine, ruleset,
349
+ // analyzer health, and mapping version — so "changing any bound input
350
+ // produces a new evidence digest" is a real, checkable property rather
351
+ // than an assertion. Deliberately mirrors attestation.js's own
352
+ // allowlist-then-sorted-JSON-then-sha256 shape rather than inventing a
353
+ // fourth canonicalisation scheme in this codebase (see D-0026's lesson,
354
+ // applied here even though this isn't a SIGNED bundle — the same
355
+ // "unambiguous, reproducible digest of a defined field set" discipline
356
+ // still applies).
357
+ const evidenceDigest = computeEvidenceDigest({
358
+ repository: ctx.repository ?? null,
359
+ commit: ctx.commit ?? _currentCommit(ctx.scanRoot),
360
+ scope: `${policy.framework}@${policy.version}`,
361
+ engine: SCANNER_VERSION,
362
+ ruleset: ctx.rulesetVersion ?? null,
363
+ analyzerHealth: ctx.scanHealth?.status ?? null,
364
+ mappingVersion: policy.version,
365
+ controls: results.map(r => ({ id: r.id, status: r.status })),
366
+ });
367
+ return { framework: policy.framework, version: policy.version, controls: results, summary, evidenceDigest };
368
+ }
369
+
370
+ function _currentCommit(scanRoot) {
371
+ if (!scanRoot) return null;
372
+ try {
373
+ return execSync('git rev-parse HEAD', { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
374
+ } catch { return null; } // not a git repo, or git unavailable — not an error condition
375
+ }
376
+
377
+ /**
378
+ * FR-504: the signed field set — an allowlist, exactly as attestation.js
379
+ * and evidence-bundle.js canonicalise. A field not named here is not bound,
380
+ * so a future addition to the evidence report cannot silently join or
381
+ * leave the digest's scope.
382
+ */
383
+ export function computeEvidenceDigest(fields) {
384
+ const bound = {
385
+ repository: fields.repository ?? null,
386
+ commit: fields.commit ?? null,
387
+ scope: fields.scope ?? null,
388
+ engine: fields.engine ?? null,
389
+ ruleset: fields.ruleset ?? null,
390
+ analyzerHealth: fields.analyzerHealth ?? null,
391
+ mappingVersion: fields.mappingVersion ?? null,
392
+ controls: [...(fields.controls || [])].sort((a, b) => String(a.id).localeCompare(String(b.id))),
174
393
  };
175
- return { framework: policy.framework, version: policy.version, controls: results, summary };
394
+ return crypto.createHash('sha256').update(_canonicalJson(bound), 'utf8').digest('hex');
395
+ }
396
+
397
+ // Deterministic JSON — keys sorted at every level, arrays order-preserving.
398
+ // Same algorithm as evidence-bundle.js's canonicalJson (duplicated rather
399
+ // than imported for the same reason policy-bundle.js gave: a pure,
400
+ // three-line function, and importing it would couple this module's digest
401
+ // format to a sibling module's internals for no real benefit).
402
+ function _canonicalJson(value) {
403
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
404
+ if (Array.isArray(value)) return `[${value.map(_canonicalJson).join(',')}]`;
405
+ const keys = Object.keys(value).sort();
406
+ return `{${keys.map(k => `${JSON.stringify(k)}:${_canonicalJson(value[k])}`).join(',')}}`;
176
407
  }
177
408
 
178
409
  /**
@@ -186,20 +417,44 @@ export function emitEvidenceJsonLd(report, scanRoot) {
186
417
  'schema': 'https://schema.org/',
187
418
  },
188
419
  '@type': 'ComplianceEvidence',
420
+ // FR-508: schema version and status semantics travel WITH the
421
+ // document — a consumer does not need to know this codebase's own
422
+ // source to interpret either. policySource names exactly which
423
+ // mapping file produced this export (the one per-export "source
424
+ // reference" that is not already implicit in each control's own
425
+ // `checks[].rule`, which already carries the raw check definition).
426
+ schemaVersion: EVIDENCE_SCHEMA_VERSION,
427
+ statusSemantics: STATUS_SEMANTICS,
428
+ policySource: `${STATE_DIR_NAME}/${POLICY_FILE}`,
189
429
  framework: report.framework,
190
430
  version: report.version,
191
431
  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
432
+ // CMP-5 / FR-507: this artifact is fed to GRC tooling (Vanta/Drata/
433
+ // SecureFrame) and auditors largely unread by a human — the same reason
194
434
  // auditor-walkthrough.js's narrative carries this disclaimer verbatim.
195
435
  // 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.',
436
+ // as an attestation, not a scanner's automated observation. Sourced from
437
+ // evidence-grade-wording.js see that module's header for why this text
438
+ // names all three assurance tiers explicitly, not just "not certified."
439
+ disclaimer: EVIDENCE_GRADE_DISCLAIMER,
198
440
  provenance: { engineVersion: SCANNER_VERSION },
441
+ // FR-504: "changing any bound input produces a new evidence digest."
442
+ // Present only when verifyPolicy actually computed one (every real
443
+ // caller does; a hand-built report in a test predating this field
444
+ // simply omits it, rather than this function fabricating one from
445
+ // partial information).
446
+ ...(report.evidenceDigest ? { evidenceDigest: report.evidenceDigest } : {}),
199
447
  summary: report.summary,
200
448
  controls: report.controls.map(c => ({
201
449
  '@type': 'Control',
202
450
  id: c.id, title: c.title, status: c.status,
451
+ // FR-506: present only when set — an owner-less, reviewer-less
452
+ // control (the overwhelming majority, for any policy predating this
453
+ // field) does not gain fabricated accountability metadata.
454
+ ...(c.owner ? { owner: c.owner } : {}),
455
+ ...(c.reviewer ? { reviewer: c.reviewer } : {}),
456
+ ...(c.staleReason ? { staleReason: c.staleReason } : {}),
457
+ ...(c.gapReason ? { gapReason: c.gapReason } : {}),
203
458
  checks: c.checks.map(ck => ({
204
459
  '@type': 'Check',
205
460
  rule: ck.check,
@@ -209,10 +464,36 @@ export function emitEvidenceJsonLd(report, scanRoot) {
209
464
  narrative_evidence: c.evidence || [],
210
465
  })),
211
466
  };
467
+ // FR-505: "sign evidence manifests WHEN SIGNING IS CONFIGURED" — a no-op
468
+ // when the operator has not already set up a signing key (the common
469
+ // case), so an ordinary scan's behavior is unchanged unless an operator
470
+ // opted in. loadSigningKeyIfConfigured only ever READS an existing key,
471
+ // never generates one — see that function's own header for why emitting
472
+ // compliance evidence (an automatic, routine side effect of every scan)
473
+ // must not silently create key material the way an explicit `attest`
474
+ // command legitimately does.
475
+ let signed = jsonld;
476
+ const signingKey = loadSigningKeyIfConfigured();
477
+ if (signingKey) signed = signComplianceEvidence(jsonld, signingKey.privateKeyPem);
212
478
  // Through the seam — see the note in pqc-migration-plan.js. The report is
213
479
  // 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;
480
+ // FR-705: this artifact is marked confidential in artifact-registry.js.
481
+ // maybeEncryptForWrite is a safe no-op when encryption isn't configured
482
+ // (the overwhelming default case) and encrypts the JSON body when it is.
483
+ // The fail-closed half of FR-705's acceptance criterion lives HERE: when
484
+ // encryption is required but unavailable, the write is skipped entirely
485
+ // — never a plaintext fallback — and an operator is told why, rather
486
+ // than silently persisting unencrypted sensitive compliance evidence.
487
+ const gated = maybeEncryptForWrite(scanRoot, 'compliance-evidence.json', JSON.stringify(signed, null, 2));
488
+ if (!gated.ok) {
489
+ // Always visible, not debug-gated: a required control silently not
490
+ // being met is exactly the kind of thing FR-705 exists to surface,
491
+ // not hide behind an opt-in verbosity flag.
492
+ process.stderr.write(`[agentic-security] compliance-evidence.json NOT written: ${gated.reason}\n`);
493
+ } else {
494
+ safeWriteState(statePath(scanRoot, 'compliance-evidence.json'), gated.content);
495
+ }
496
+ return signed;
216
497
  }
217
498
 
218
499
  /**
@@ -224,12 +505,21 @@ export function emitEvidenceMarkdown(report, scanRoot) {
224
505
  lines.push('');
225
506
  lines.push(`Generated by agentic-security (engine ${SCANNER_VERSION}) on ${new Date().toISOString().slice(0,10)}.`);
226
507
  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.');
508
+ lines.push(`> ${EVIDENCE_GRADE_DISCLAIMER_SHORT}`);
228
509
  lines.push('');
229
- lines.push(`Compliant: **${report.summary.compliant}** / Non-compliant: **${report.summary.nonCompliant}** / Not applicable: **${report.summary.notApplicable}** of ${report.summary.total} controls.`);
510
+ if (report.evidenceDigest) {
511
+ lines.push(`Evidence digest (FR-504 — repository, commit, scope, engine, ruleset, analyzer health, and mapping version bound): \`${report.evidenceDigest}\``);
512
+ lines.push('');
513
+ }
514
+ lines.push(`Compliant: **${report.summary.compliant}** / Non-compliant: **${report.summary.nonCompliant}** / Not applicable: **${report.summary.notApplicable}**` +
515
+ `${report.summary.stale ? ` / Stale: **${report.summary.stale}**` : ''}${report.summary.gap ? ` / Gap: **${report.summary.gap}**` : ''} of ${report.summary.total} controls.`);
230
516
  lines.push('');
231
517
  for (const c of report.controls) {
232
518
  lines.push(`## ${c.id} — ${c.title} (${c.status})`);
519
+ if (c.owner) lines.push(`- owner: ${c.owner}`);
520
+ if (c.reviewer) lines.push(`- reviewer: ${c.reviewer}`);
521
+ if (c.staleReason) lines.push(`- ⚠ stale: ${c.staleReason}`);
522
+ if (c.gapReason) lines.push(`- ⚠ gap: ${c.gapReason}`);
233
523
  for (const ck of c.checks) {
234
524
  const mark = ck.result.passed ? '✓' : '✗';
235
525
  lines.push(`- ${mark} \`${JSON.stringify(ck.check)}\` — ${ck.result.reason}`);
@@ -241,8 +531,15 @@ export function emitEvidenceMarkdown(report, scanRoot) {
241
531
  }
242
532
  lines.push('');
243
533
  }
244
- safeWriteState(statePath(scanRoot, 'compliance-evidence.md'), lines.join('\n'));
245
- return lines.join('\n');
534
+ // FR-705: same fail-closed gate as emitEvidenceJsonLd above.
535
+ const rendered = lines.join('\n');
536
+ const gated = maybeEncryptForWrite(scanRoot, 'compliance-evidence.md', rendered);
537
+ if (!gated.ok) {
538
+ process.stderr.write(`[agentic-security] compliance-evidence.md NOT written: ${gated.reason}\n`);
539
+ } else {
540
+ safeWriteState(statePath(scanRoot, 'compliance-evidence.md'), gated.content);
541
+ }
542
+ return rendered;
246
543
  }
247
544
 
248
- export const _internals = { _normalize, _runCheck };
545
+ export const _internals = { _normalize, _runCheck, _staleness, _exceptionExpired, _currentCommit };
@@ -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';