@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.
- package/CHANGELOG.md +304 -0
- package/bin/agentic-security.js +477 -47
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +108 -17
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +1 -1
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +165 -52
- package/dist/526.index.js +108 -17
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +1 -1
- package/dist/730.index.js +311 -0
- package/dist/736.index.js +301 -0
- package/dist/824.index.js +7 -0
- package/dist/905.index.js +88 -22
- package/dist/920.index.js +491 -0
- package/dist/970.index.js +109 -0
- package/dist/agentic-security.mjs +13 -13
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/calibration-seed.json +2 -0
- package/package.json +19 -11
- package/src/dataflow/index.js +18 -0
- package/src/dataflow/privacy-catalog.js +290 -0
- package/src/dataflow/privacy-deep-walker.js +515 -0
- package/src/dataflow/privacy-governance.js +126 -0
- package/src/dataflow/privacy-inventory.js +154 -0
- package/src/dataflow/privacy-sink-policy.js +125 -0
- package/src/dataflow/privacy-taint.js +115 -54
- package/src/dataflow/privacy-taxonomy.js +233 -0
- package/src/discovery/disprove.js +7 -3
- package/src/discovery/hunter.js +9 -5
- package/src/discovery/index.js +2 -2
- package/src/discovery/llm-invoke.js +69 -13
- package/src/egress/audit.js +147 -0
- package/src/egress/policy.js +313 -0
- package/src/egress/redact.js +180 -0
- package/src/engine.js +575 -288
- package/src/fix/apply-fix-service.js +403 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/mcp/tools.js +157 -50
- package/src/pipeline/analyzer-supervisor.js +93 -0
- package/src/pipeline/analyzer-worker.js +26 -0
- package/src/pipeline/annotator-runner.js +33 -0
- package/src/pipeline/assurance-mode.js +91 -0
- package/src/pipeline/cascade-worker-pool.js +172 -0
- package/src/pipeline/cascade-worker.js +43 -0
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/detector-runner.js +51 -0
- package/src/pipeline/enrichment-completion.js +58 -0
- package/src/pipeline/evidence-provenance.js +91 -0
- package/src/pipeline/finding-schema.js +101 -0
- package/src/pipeline/legacy-compat.js +101 -0
- package/src/pipeline/producer-collector.js +48 -0
- package/src/pipeline/producer-registry.js +112 -0
- package/src/pipeline/scan-health.js +144 -0
- package/src/posture/CLAUDE.md +2 -0
- package/src/posture/accuracy-scorecard.js +96 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +217 -0
- package/src/posture/auditor-walkthrough.js +70 -8
- package/src/posture/calibration-feedback.js +201 -0
- package/src/posture/calibration-seed.json +2 -0
- package/src/posture/calibration.js +25 -0
- package/src/posture/compliance-evidence-signing.js +131 -0
- package/src/posture/compliance-policy.js +314 -17
- package/src/posture/custom-rules.js +36 -0
- package/src/posture/deterministic.js +8 -1
- package/src/posture/encryption-provider.js +205 -0
- package/src/posture/evidence-grade-wording.js +71 -0
- package/src/posture/fix-history.js +113 -19
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +56 -7
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +90 -0
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/privacy-framework.js +33 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/state-dir.js +36 -1
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +51 -2
- package/src/report/oscal.js +7 -2
- package/src/sast/cpp.js +3 -14
- 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:
|
|
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
|
-
|
|
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(
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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(
|
|
508
|
+
lines.push(`> ${EVIDENCE_GRADE_DISCLAIMER_SHORT}`);
|
|
228
509
|
lines.push('');
|
|
229
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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';
|