@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.
- package/CHANGELOG.md +555 -0
- package/bin/agentic-security.js +770 -49
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +117 -18
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +24 -6
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +187 -52
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +117 -18
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +27 -5
- 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 +173 -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 +23 -9
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- 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 +1048 -302
- package/src/fix/apply-fix-service.js +404 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +177 -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 +154 -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 +108 -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 +123 -0
- package/src/posture/accuracy-scorecard.js +156 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +241 -0
- package/src/posture/auditor-walkthrough.js +186 -21
- 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 +324 -17
- package/src/posture/cross-repo-memory.js +7 -2
- 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 +137 -20
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +65 -8
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/git-history.js +13 -5
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +111 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +47 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +64 -5
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +248 -5
- package/src/report/oscal.js +7 -2
- package/src/runScan.js +34 -5
- package/src/sast/cpp.js +3 -14
- package/src/sast/rate-limit.js +33 -3
- package/src/sca/llm-function-extract.js +6 -0
- 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:
|
|
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
|
-
|
|
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(
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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(
|
|
518
|
+
lines.push(`> ${EVIDENCE_GRADE_DISCLAIMER_SHORT}`);
|
|
228
519
|
lines.push('');
|
|
229
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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';
|