@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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// FR-806 (assurance-hardening PRD): "Validate model calibration against
|
|
2
|
+
// accepted and realized incidents where customers opt in | Calibration
|
|
3
|
+
// reports are aggregated and privacy-preserving."
|
|
4
|
+
//
|
|
5
|
+
// SCOPE, stated explicitly because the PRD's own wording is terse: this
|
|
6
|
+
// codebase has no SaaS control plane and takes no runtime cloud calls
|
|
7
|
+
// (root CLAUDE.md's own "No runtime cloud calls" rule, and E10's own goal
|
|
8
|
+
// of "consistent policy... without requiring a SaaS control plane") — so
|
|
9
|
+
// "aggregated" here means aggregated WITHIN one installation, across every
|
|
10
|
+
// feedback event an operator has recorded over time, never aggregated
|
|
11
|
+
// ACROSS installations on some central server. An operator who wants a
|
|
12
|
+
// cross-organization rollup can feed this module's own report output into
|
|
13
|
+
// their own aggregation, the same way `fleet.js` composes many single-repo
|
|
14
|
+
// scans without a hosted backend.
|
|
15
|
+
//
|
|
16
|
+
// TWO OUTCOMES, matching the PRD's own two named cases:
|
|
17
|
+
// 'accepted-risk' — an operator/customer reviewed a finding, accepted
|
|
18
|
+
// the risk, and (later, of their own accord) reports
|
|
19
|
+
// that no incident occurred. A well-calibrated model
|
|
20
|
+
// should have predicted LOW confidence/risk for
|
|
21
|
+
// these.
|
|
22
|
+
// 'realized-incident' — an operator reports that a finding's
|
|
23
|
+
// vulnerability WAS actually exploited or otherwise
|
|
24
|
+
// caused a real incident. A well-calibrated model
|
|
25
|
+
// should have predicted HIGH confidence/risk for
|
|
26
|
+
// these — a realized incident on a LOW-predicted
|
|
27
|
+
// finding is exactly the miscalibration this
|
|
28
|
+
// requirement exists to surface.
|
|
29
|
+
//
|
|
30
|
+
// PRIVACY-PRESERVING AT THE SOURCE, not just at the report layer: a
|
|
31
|
+
// feedback record snapshots only the model's OWN prediction signals
|
|
32
|
+
// (confidence, severity, riskDollars.ev) plus the operator's outcome and
|
|
33
|
+
// optional free-text note — never file path, line, vuln title, or code
|
|
34
|
+
// snippet. This mirrors this codebase's existing privacy modules' own
|
|
35
|
+
// discipline (dataflow/privacy-*.js) of never persisting more than a
|
|
36
|
+
// report needs to answer its one question.
|
|
37
|
+
//
|
|
38
|
+
// OPT-IN, genuinely: nothing here is ever auto-populated by a scan. A
|
|
39
|
+
// record exists only when an operator explicitly calls
|
|
40
|
+
// `recordCalibrationFeedback` (via the CLI's `calibration-feedback record`
|
|
41
|
+
// command) — the file simply does not exist for every project that never
|
|
42
|
+
// opts in, and every read degrades to "no data" rather than throwing.
|
|
43
|
+
|
|
44
|
+
import * as fs from 'node:fs';
|
|
45
|
+
import * as crypto from 'node:crypto';
|
|
46
|
+
import { statePath, stateDir, isSafeStateDir, stateWritesEnabled } from './state-dir.js';
|
|
47
|
+
|
|
48
|
+
export const CALIBRATION_FEEDBACK_FILE = 'calibration-feedback.jsonl';
|
|
49
|
+
export const OUTCOMES = Object.freeze(['accepted-risk', 'realized-incident']);
|
|
50
|
+
|
|
51
|
+
// Below this many samples, a rate is an artifact of the sample, not a
|
|
52
|
+
// property of the model — same precedent as fix-metrics.js's RELIABLE_N.
|
|
53
|
+
const RELIABLE_N = 10;
|
|
54
|
+
|
|
55
|
+
function _findFinding(scanRoot, findingId) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8'));
|
|
58
|
+
const findings = Array.isArray(raw.findings) ? raw.findings : [];
|
|
59
|
+
return findings.find(f => f && (f.id === findingId || f.stableId === findingId)) || null;
|
|
60
|
+
} catch { return null; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// FR-806 privacy fix: the caller-supplied findingId is routinely a finding's
|
|
64
|
+
// plain `.id` (e.g. "client-side:DANGEROUS_INNERHTML:src/billing/secret.js:142"),
|
|
65
|
+
// which embeds the exact file path and line this module's own docstring
|
|
66
|
+
// promises never to persist. Never write the caller's raw string to disk:
|
|
67
|
+
// prefer the matched finding's own privacy-safe `.stableId` (a hash, by
|
|
68
|
+
// construction elsewhere in this codebase), and otherwise hash the input
|
|
69
|
+
// ourselves so a stale/removed finding's id can never leak path/line either.
|
|
70
|
+
function _privacySafeFindingId(findingId, finding) {
|
|
71
|
+
if (finding && typeof finding.stableId === 'string' && finding.stableId) return finding.stableId;
|
|
72
|
+
return crypto.createHash('sha256').update(findingId).digest('hex').slice(0, 16);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Record one opt-in calibration-feedback event. Snapshots ONLY the
|
|
77
|
+
* model's own prediction signals for the named finding (if it is still
|
|
78
|
+
* present in the last scan — a finding fixed/removed since is still
|
|
79
|
+
* recordable, just without a fresh snapshot) plus the outcome and an
|
|
80
|
+
* optional note. Never throws; returns {ok, record} or {ok:false, reason}.
|
|
81
|
+
*/
|
|
82
|
+
export function recordCalibrationFeedback(scanRoot, { findingId, outcome, note } = {}) {
|
|
83
|
+
if (!findingId || typeof findingId !== 'string') return { ok: false, reason: '--finding-id is required' };
|
|
84
|
+
if (!OUTCOMES.includes(outcome)) return { ok: false, reason: `--outcome must be one of: ${OUTCOMES.join(', ')}` };
|
|
85
|
+
const finding = _findFinding(scanRoot, findingId);
|
|
86
|
+
const record = {
|
|
87
|
+
at: new Date().toISOString(),
|
|
88
|
+
findingId: _privacySafeFindingId(findingId, finding),
|
|
89
|
+
outcome,
|
|
90
|
+
predictedConfidence: finding && typeof finding.confidence === 'number' ? finding.confidence : null,
|
|
91
|
+
predictedConfidenceTier: finding?.confidenceTier || null,
|
|
92
|
+
predictedSeverity: finding?.severity || null,
|
|
93
|
+
predictedRiskEv: finding?.riskDollars && typeof finding.riskDollars.ev === 'number' ? finding.riskDollars.ev : null,
|
|
94
|
+
note: note ? String(note).slice(0, 280) : null,
|
|
95
|
+
};
|
|
96
|
+
// Append-only, same primitives as fix-metrics.js/triage-memory.js's own
|
|
97
|
+
// JSONL writers — a single fs.appendFileSync, never a read-modify-write
|
|
98
|
+
// of the whole file (which would also reintroduce a TOCTOU between an
|
|
99
|
+
// existence check and the write, the exact anti-pattern this codebase's
|
|
100
|
+
// own conventions forbid).
|
|
101
|
+
const dir = stateDir(scanRoot);
|
|
102
|
+
if (!isSafeStateDir(dir)) return { ok: false, reason: 'no safe state directory' };
|
|
103
|
+
if (!stateWritesEnabled()) return { ok: false, reason: 'state writes are disabled (--no-state)' };
|
|
104
|
+
try {
|
|
105
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
106
|
+
fs.appendFileSync(statePath(scanRoot, CALIBRATION_FEEDBACK_FILE), JSON.stringify(record) + '\n', 'utf8');
|
|
107
|
+
} catch (e) { return { ok: false, reason: e.message }; }
|
|
108
|
+
return { ok: true, record };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Every well-formed feedback event ever recorded. A line that fails to
|
|
113
|
+
* parse or lacks a valid outcome is skipped, not fatal.
|
|
114
|
+
*/
|
|
115
|
+
export function loadCalibrationFeedback(scanRoot) {
|
|
116
|
+
let fp;
|
|
117
|
+
try { fp = statePath(scanRoot, CALIBRATION_FEEDBACK_FILE); } catch { return []; }
|
|
118
|
+
let raw;
|
|
119
|
+
try { raw = fs.readFileSync(fp, 'utf8'); } catch { return []; }
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const line of raw.split('\n')) {
|
|
122
|
+
if (!line.trim()) continue;
|
|
123
|
+
try {
|
|
124
|
+
const rec = JSON.parse(line);
|
|
125
|
+
if (rec && OUTCOMES.includes(rec.outcome)) out.push(rec);
|
|
126
|
+
} catch { /* torn or hand-edited line — drop it, keep the rest */ }
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _avg(nums) {
|
|
132
|
+
const v = nums.filter(n => typeof n === 'number' && Number.isFinite(n));
|
|
133
|
+
return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _summarizeOutcome(records) {
|
|
137
|
+
const withConfidence = records.map(r => r.predictedConfidence).filter(c => typeof c === 'number');
|
|
138
|
+
return {
|
|
139
|
+
n: records.length,
|
|
140
|
+
reliable: records.length >= RELIABLE_N,
|
|
141
|
+
avgPredictedConfidence: _avg(withConfidence),
|
|
142
|
+
withoutPrediction: records.length - withConfidence.length,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Aggregate ALL recorded feedback (this installation only — see the
|
|
148
|
+
* module header for why cross-installation aggregation is out of scope)
|
|
149
|
+
* into a privacy-preserving report: rates and averages only, never a
|
|
150
|
+
* per-finding breakdown, never file/line/vuln text (none of that was ever
|
|
151
|
+
* stored in the first place).
|
|
152
|
+
*/
|
|
153
|
+
export function buildCalibrationReport(scanRoot) {
|
|
154
|
+
const records = loadCalibrationFeedback(scanRoot);
|
|
155
|
+
const acceptedRisk = records.filter(r => r.outcome === 'accepted-risk');
|
|
156
|
+
const realizedIncident = records.filter(r => r.outcome === 'realized-incident');
|
|
157
|
+
return {
|
|
158
|
+
schema: 'agentic-security/calibration-report@1',
|
|
159
|
+
generatedAt: new Date().toISOString(),
|
|
160
|
+
totalEvents: records.length,
|
|
161
|
+
acceptedRisk: _summarizeOutcome(acceptedRisk),
|
|
162
|
+
realizedIncident: _summarizeOutcome(realizedIncident),
|
|
163
|
+
// The calibration question itself: accepted-risk events SHOULD skew
|
|
164
|
+
// toward low predicted confidence; realized-incident events SHOULD
|
|
165
|
+
// skew toward high. This flag is a coarse, disclosed-uncertainty
|
|
166
|
+
// signal, not a verdict — it only ever fires when BOTH buckets have
|
|
167
|
+
// enough samples to say anything at all (RELIABLE_N each).
|
|
168
|
+
possibleMiscalibration: (() => {
|
|
169
|
+
const a = _summarizeOutcome(acceptedRisk);
|
|
170
|
+
const r = _summarizeOutcome(realizedIncident);
|
|
171
|
+
if (!a.reliable || !r.reliable || a.avgPredictedConfidence == null || r.avgPredictedConfidence == null) return null;
|
|
172
|
+
return r.avgPredictedConfidence <= a.avgPredictedConfidence;
|
|
173
|
+
})(),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* One block of human-readable summary, or null when nothing has ever been
|
|
179
|
+
* recorded — genuinely opt-in, so "nothing recorded" is the expected
|
|
180
|
+
* default state for almost every project, not an error.
|
|
181
|
+
*/
|
|
182
|
+
export function renderCalibrationReportSummary(report) {
|
|
183
|
+
if (!report || report.totalEvents === 0) return null;
|
|
184
|
+
const lines = [
|
|
185
|
+
'Calibration feedback (this installation, opt-in):',
|
|
186
|
+
` accepted-risk: n=${report.acceptedRisk.n}${report.acceptedRisk.reliable ? '' : ' (below reliable sample size)'}` +
|
|
187
|
+
(report.acceptedRisk.avgPredictedConfidence != null ? ` avg predicted confidence=${report.acceptedRisk.avgPredictedConfidence.toFixed(2)}` : ''),
|
|
188
|
+
` realized-incident: n=${report.realizedIncident.n}${report.realizedIncident.reliable ? '' : ' (below reliable sample size)'}` +
|
|
189
|
+
(report.realizedIncident.avgPredictedConfidence != null ? ` avg predicted confidence=${report.realizedIncident.avgPredictedConfidence.toFixed(2)}` : ''),
|
|
190
|
+
];
|
|
191
|
+
if (report.possibleMiscalibration === true) {
|
|
192
|
+
lines.push(' ⚠ realized incidents were NOT predicted with higher confidence than accepted risks — possible miscalibration.');
|
|
193
|
+
} else if (report.possibleMiscalibration === false) {
|
|
194
|
+
lines.push(' ✓ realized incidents were predicted with higher confidence than accepted risks, as expected.');
|
|
195
|
+
} else {
|
|
196
|
+
lines.push(' (not enough samples in both buckets yet to assess calibration — this is not a pass or fail.)');
|
|
197
|
+
}
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const _internals = { RELIABLE_N, _findFinding };
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_doc": "Seed calibration corpus for P1.3 / FR-UX-1. Each entry is (family, tp, fp) from running the engine against labeled benchmarks. The runtime merges this with the customer's .agentic-security/validator-metrics.json. Customer counts override when their n is higher.",
|
|
3
3
|
"_source": "OWASP Benchmark v1.2 (Java) + Juliet Java + Juliet C/C++ + synthetic-bench fixtures + curated NodeGoat — counts as-of v0.48.0. ~30 samples per family minimum for calibrated emit.",
|
|
4
|
+
"_generatedAt": "2026-05-18T19:15:00-07:00",
|
|
5
|
+
"_generatedAtNote": "FR-207: the actual git commit timestamp of this file (git log -1 --format=%aI), recorded here as a machine-readable field so posture/calibration.js's calibrationFreshness() can compute a real age instead of inferring one from the '_source' version string. Update this value only when the family counts below actually change.",
|
|
4
6
|
"_caveat": "This is a SEED corpus, not a held-out test set. The PRD G1 target (Brier ≤ 0.10) requires a separate held-out labeled set; that work is queued for Phase 5 finalization. Calibrated values shipped now are honest empirical TP rates from this seed, with their Wilson 95% CI and N visible so consumers can judge.",
|
|
5
7
|
"families": {
|
|
6
8
|
"sql-injection": { "tp": 41, "fp": 3 },
|
|
@@ -102,6 +102,31 @@ function _readJsonMaybe(fp) {
|
|
|
102
102
|
// Load history from .agentic-security/validator-metrics.json + the bundled
|
|
103
103
|
// seed file. The bundled seed ships with this release; the customer file
|
|
104
104
|
// overrides per-family when N is higher there.
|
|
105
|
+
// FR-207: freshness of the seed calibration table itself. This is
|
|
106
|
+
// maintainer-authored data (not a per-customer opt-in like
|
|
107
|
+
// compliance-policy.js's `_staleness`), so the check is always-on against
|
|
108
|
+
// one fixed threshold rather than an opt-in interval — there is no
|
|
109
|
+
// owner/reviewer workflow for it to key off. `_generatedAt` missing (an
|
|
110
|
+
// old seed file predating this field) is treated the same as this
|
|
111
|
+
// codebase's other "never dated == already stale" cases, not a free pass.
|
|
112
|
+
const CALIBRATION_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1000;
|
|
113
|
+
let _calibrationFreshnessCache = null;
|
|
114
|
+
export function calibrationFreshness() {
|
|
115
|
+
if (_calibrationFreshnessCache) return _calibrationFreshnessCache;
|
|
116
|
+
const seedPath = new URL('./calibration-seed.json', import.meta.url);
|
|
117
|
+
let generatedAt = null;
|
|
118
|
+
try { generatedAt = JSON.parse(fs.readFileSync(seedPath, 'utf8'))._generatedAt || null; } catch { /* unreadable seed -> unknown, not fabricated */ }
|
|
119
|
+
const ts = generatedAt ? Date.parse(generatedAt) : NaN;
|
|
120
|
+
const baseline = Number.isFinite(ts) ? ts : 0;
|
|
121
|
+
const ageMs = Date.now() - baseline;
|
|
122
|
+
_calibrationFreshnessCache = {
|
|
123
|
+
generatedAt,
|
|
124
|
+
ageDays: Math.floor(ageMs / 86400000),
|
|
125
|
+
stale: ageMs > CALIBRATION_MAX_AGE_MS,
|
|
126
|
+
};
|
|
127
|
+
return _calibrationFreshnessCache;
|
|
128
|
+
}
|
|
129
|
+
|
|
105
130
|
export function loadCalibrationHistory(scanRoot) {
|
|
106
131
|
const customer = _readJsonMaybe(statePath(scanRoot, 'validator-metrics.json')) || {};
|
|
107
132
|
const seedPath = new URL('./calibration-seed.json', import.meta.url);
|
|
@@ -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
|
+
}
|