@clear-capabilities/agentic-security-scanner 0.142.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 +399 -0
- package/bin/agentic-security.js +530 -54
- 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 +83 -18
- package/src/report/oscal.js +635 -0
- package/src/sast/cpp.js +3 -14
- package/src/sca/llm-function-extract.js +6 -0
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// `unverified-no-llm-endpoint` and the transcript records only the seed input.
|
|
26
26
|
|
|
27
27
|
import * as crypto from 'node:crypto';
|
|
28
|
+
import { evaluateEgress } from '../egress/policy.js';
|
|
28
29
|
|
|
29
30
|
const MAX_CALLS_DEFAULT = 50;
|
|
30
31
|
const MAX_WALL_MS_DEFAULT = 15 * 60 * 1000;
|
|
@@ -170,12 +171,23 @@ export async function runAgent(finding, opts = {}) {
|
|
|
170
171
|
const transcript = startTranscript(finding, opts.target);
|
|
171
172
|
const budget = { maxCalls: opts.maxCalls, maxWallMs: opts.maxWallMs };
|
|
172
173
|
|
|
173
|
-
|
|
174
|
+
// FR-601: evaluated before defaultLlmInvoke is even selected, so a denial
|
|
175
|
+
// means its transcript-to-prompt construction (inside defaultLlmInvoke,
|
|
176
|
+
// called later in the loop below) never runs at all.
|
|
177
|
+
let egressDecision = null;
|
|
178
|
+
let llmInvoke = opts.llmInvoke || null;
|
|
179
|
+
if (!llmInvoke && process.env.AGENTIC_SECURITY_LLM_ENDPOINT) {
|
|
180
|
+
egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'adversary-agent', endpoint: process.env.AGENTIC_SECURITY_LLM_ENDPOINT });
|
|
181
|
+
if (egressDecision.allowed) llmInvoke = defaultLlmInvoke;
|
|
182
|
+
}
|
|
174
183
|
const executeTool = opts.executeTool || (transcript.target ? (call) => defaultExecuteTool(call, transcript) : null);
|
|
175
184
|
|
|
176
185
|
if (typeof llmInvoke !== 'function' || typeof executeTool !== 'function') {
|
|
177
|
-
|
|
178
|
-
|
|
186
|
+
const reason = (egressDecision && !egressDecision.allowed)
|
|
187
|
+
? `egress policy denied this call: ${egressDecision.reason}`
|
|
188
|
+
: 'no llmInvoke/executeTool supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set';
|
|
189
|
+
appendEntry(transcript, { phase: 'init', reason, egressDecision: egressDecision || undefined });
|
|
190
|
+
return { transcript, outcome: 'unverified-no-llm-endpoint', egressDecision };
|
|
179
191
|
}
|
|
180
192
|
|
|
181
193
|
let outcome = null;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// State artifact registry (assurance-hardening PRD, Milestone 0/1, FR-701/FR-703).
|
|
2
|
+
//
|
|
3
|
+
// `bin/agentic-security.js`'s `cmdReset` used to delete from two hardcoded
|
|
4
|
+
// Sets (WIPE / WIPE_DIRS) — an enumeration that had drifted badly behind the
|
|
5
|
+
// state artifacts this codebase actually writes under `.agentic-security/`.
|
|
6
|
+
// This module is the registry FR-701 asks for: every known artifact, with an
|
|
7
|
+
// explicit classification, built by auditing every `statePath(...)` call
|
|
8
|
+
// site in `src/` and `bin/` (not guessed from filenames — several looked
|
|
9
|
+
// like generated output by name but turned out, on reading their actual
|
|
10
|
+
// read/write call sites, to be operator- or agent-authored INPUT).
|
|
11
|
+
//
|
|
12
|
+
// Two classifications:
|
|
13
|
+
// - 'generated': written by the scanner itself, safe to delete — the next
|
|
14
|
+
// scan/command regenerates it. `cmdReset` removes these by default.
|
|
15
|
+
// - 'operator-config': hand-authored (or agent-authored, for
|
|
16
|
+
// logic-claims.json) input the scanner only reads. Deleting it on reset
|
|
17
|
+
// would be data loss, not cleanup — `cmdReset` always preserves these.
|
|
18
|
+
//
|
|
19
|
+
// Corrections this audit made to the assurance-hardening PRD's own evidence
|
|
20
|
+
// table (A-10), which had assumed these were straightforwardly "missing from
|
|
21
|
+
// the wipe list, therefore should be added": `logic-claims.json` is read-only
|
|
22
|
+
// from engine.js (an external reviewing agent authors it); `current-intent.md`
|
|
23
|
+
// has no writer anywhere in src/ or bin/ (developer-authored);
|
|
24
|
+
// `exploit-history.jsonl`'s own header comment calls it an "operator-curated
|
|
25
|
+
// record"; `cve-alerts.json`'s own header comment calls it "Configuration...
|
|
26
|
+
// read from"; `network-policy.json` is documented as an Inputs-section
|
|
27
|
+
// artifact in network-policy-import.js. All five are classified
|
|
28
|
+
// 'operator-config' here — the registry closes the reset-completeness gap
|
|
29
|
+
// without introducing a NEW data-loss bug in the process.
|
|
30
|
+
//
|
|
31
|
+
// This module implements FR-701 (this registry) and FR-703 (registry-driven
|
|
32
|
+
// reset). FR-702 (TTL by artifact class) is implemented here too, as an
|
|
33
|
+
// additive `retentionClass` field: 'cache' | 'scan' | 'evidence' | 'ticket'
|
|
34
|
+
// | 'backup' | undefined, matching the acceptance criterion's own named
|
|
35
|
+
// list verbatim ("expired caches, scans, evidence, tickets, and backups").
|
|
36
|
+
// DELIBERATELY CONSERVATIVE: only entries that unambiguously fit one of
|
|
37
|
+
// those five categories carry a class. Ongoing accumulated state whose
|
|
38
|
+
// deletion would be a real loss rather than cleanup — calibration data
|
|
39
|
+
// (validator-metrics.json, triage-feedback.json), the continual-learning
|
|
40
|
+
// memory file (AGENTS.md), daemon dedup state (cve-alerts-state.json), the
|
|
41
|
+
// gamification streak counter, an operator-set regression baseline
|
|
42
|
+
// (baseline.json, set via --set-baseline — functionally closer to
|
|
43
|
+
// operator intent than scanner output even though it is written by the
|
|
44
|
+
// scanner) — are left with NO retention class rather than forced into the
|
|
45
|
+
// nearest-sounding bucket. A class-less 'generated' artifact is completely
|
|
46
|
+
// unaffected by FR-702's enforcement; it is still deleted unconditionally
|
|
47
|
+
// by an ordinary `reset` (FR-703's own behavior, unchanged).
|
|
48
|
+
//
|
|
49
|
+
// See retention-policy.js for the default/max TTL values per class, the
|
|
50
|
+
// optional operator-override file, and the actual expiry check — kept in
|
|
51
|
+
// a separate module rather than grown into this one, the same "registry
|
|
52
|
+
// vs. policy" separation this session's egress/policy.js and
|
|
53
|
+
// compliance-policy.js already establish.
|
|
54
|
+
//
|
|
55
|
+
// FR-706 (export/deletion manifests) is implemented in
|
|
56
|
+
// posture/state-lifecycle-report.js, consuming this registry directly (an
|
|
57
|
+
// export walks the FULL registry, not just the 'generated' half `reset`
|
|
58
|
+
// acts on — see that module's header).
|
|
59
|
+
//
|
|
60
|
+
// FR-705 (encryption of confidential state classes): an additive
|
|
61
|
+
// `confidential: true` field, enforced by posture/encryption-provider.js.
|
|
62
|
+
// DELIBERATELY CONSERVATIVE, Phase 1 of a staged rollout (see that
|
|
63
|
+
// module's own header for the full rationale): only `compliance-
|
|
64
|
+
// evidence.json`/`.md` are marked in this pass — both have exactly one,
|
|
65
|
+
// well-isolated writer function (compliance-policy.js's emitEvidenceJsonLd/
|
|
66
|
+
// emitEvidenceMarkdown) and zero other production readers besides that
|
|
67
|
+
// same module and the verify-attestation CLI path, both already updated to
|
|
68
|
+
// transparently decrypt. Candidates explicitly DEFERRED, with reasons:
|
|
69
|
+
// `mcp-audit.log`/`egress-audit.log` (hash-CHAINED NDJSON — each entry's
|
|
70
|
+
// hash covers the previous entry, so per-write whole-file encryption would
|
|
71
|
+
// need the chain to be computed over plaintext before encrypting, a real
|
|
72
|
+
// design question left for its own pass), `dpia.md`/`ropa.md`/`data-
|
|
73
|
+
// inventory.json` (written via engine.js's own internal helper, a larger
|
|
74
|
+
// blast radius to verify safely), `last-scan.json`/`findings.json` (read
|
|
75
|
+
// directly as plain JSON by dozens of commands — encrypting these needs a
|
|
76
|
+
// decrypt-on-read hook at every one of those call sites, a much larger,
|
|
77
|
+
// separate migration in the same spirit as E2's own deferred scope).
|
|
78
|
+
|
|
79
|
+
export const ARTIFACT_REGISTRY = [
|
|
80
|
+
// ── Generated: scan output, caches, and system-maintained ledgers ──────
|
|
81
|
+
{ name: 'validator-metrics.json', kind: 'file', classification: 'generated' },
|
|
82
|
+
{ name: 'triage-feedback.json', kind: 'file', classification: 'generated' },
|
|
83
|
+
{ name: 'scan-history.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
84
|
+
{ name: 'last-scan.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
85
|
+
{ name: 'last-scan.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
86
|
+
{ name: 'shadow-findings.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
87
|
+
{ name: 'mcp-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence' },
|
|
88
|
+
{ name: 'egress-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence', note: "FR-604 per-call egress audit log — hash-chained NDJSON written by egress/audit.js's recordEgressCall, never read as config" },
|
|
89
|
+
{ name: 'hook-throttle.json', kind: 'file', classification: 'generated', retentionClass: 'cache' },
|
|
90
|
+
{ name: 'tickets.json', kind: 'file', classification: 'generated', retentionClass: 'ticket' },
|
|
91
|
+
{ name: 'streak.json', kind: 'file', classification: 'generated' },
|
|
92
|
+
{ name: 'findings.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
93
|
+
{ name: 'findings.sarif', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
94
|
+
{ name: 'findings.csv', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
95
|
+
{ name: 'llm-cache', kind: 'dir', classification: 'generated', retentionClass: 'cache' },
|
|
96
|
+
{ name: 'fix-history', kind: 'dir', classification: 'generated', retentionClass: 'backup' },
|
|
97
|
+
{ name: 'fix-plans', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
|
|
98
|
+
// The following were confirmed missing from the old hardcoded WIPE/
|
|
99
|
+
// WIPE_DIRS sets (A-10) and confirmed GENERATED by reading their write
|
|
100
|
+
// call sites (engine.js, or the module named in `source`).
|
|
101
|
+
{ name: 'dpia.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState)' },
|
|
102
|
+
{ name: 'ropa.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-407 RoPA scaffold, dataflow/privacy-governance.js' },
|
|
103
|
+
{ name: 'data-inventory.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-406 code-derived data inventory, dataflow/privacy-inventory.js' },
|
|
104
|
+
{ name: 'data-flow-graph.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-406 mermaid flow graph, dataflow/privacy-inventory.js' },
|
|
105
|
+
{ name: 'privacy-framework.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/privacy-framework.js' },
|
|
106
|
+
{ name: 'privacy-framework.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/privacy-framework.js' },
|
|
107
|
+
{ name: 'ifds-summaries.json', kind: 'file', classification: 'generated', retentionClass: 'cache', source: 'dataflow/ifds-precise.js (cache)' },
|
|
108
|
+
{ name: 'exploit-bundles.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'engine.js (_safeWriteState)' },
|
|
109
|
+
{ name: 'cve-alerts-state.json', kind: 'file', classification: 'generated', source: 'posture/cve-alert-daemon.js' },
|
|
110
|
+
{ name: 'compliance-evidence.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'posture/compliance-policy.js' },
|
|
111
|
+
{ name: 'compliance-evidence.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'posture/compliance-policy.js' },
|
|
112
|
+
{ name: 'ATTRIBUTIONS.md', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/license-attributions.js' },
|
|
113
|
+
{ name: 'NOTICE', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/license-attributions.js' },
|
|
114
|
+
{ name: 'accepted.json', kind: 'file', classification: 'generated', source: 'posture/suppressions.js (soft-accept save path)', note: 'already self-managing per-entry expiry (FR-1004-adjacent) — no additional class-level TTL' },
|
|
115
|
+
{ name: 'triage.json', kind: 'file', classification: 'generated', source: 'posture/triage.js (_save)' },
|
|
116
|
+
{ name: 'pqc-migration-plan.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/pqc-migration-plan.js' },
|
|
117
|
+
{ name: 'pqc-migration-plan.md', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/pqc-migration-plan.js' },
|
|
118
|
+
// controls.json lives under compliance/<framework>/ — registering the
|
|
119
|
+
// parent directory covers it and any other per-framework artifact.
|
|
120
|
+
{ name: 'compliance', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'posture/auditor-walkthrough.js' },
|
|
121
|
+
{ name: 'attestations', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'posture/evidence-bundle.js' },
|
|
122
|
+
{ name: 'auditor-walkthroughs', kind: 'dir', classification: 'generated', retentionClass: 'evidence' },
|
|
123
|
+
{ name: 'incremental', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'dataflow/incremental.js (cache)' },
|
|
124
|
+
{ name: 'model-rescan', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
|
|
125
|
+
{ name: 'sca-upgrade-history', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
|
|
126
|
+
{ name: 'scan-baselines', kind: 'dir', classification: 'generated', retentionClass: 'scan', source: 'posture/pr-augment.js' },
|
|
127
|
+
{ name: 'agent-scratchpad', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'mcp/tools.js (append_scratchpad)' },
|
|
128
|
+
{ name: 'AGENTS.md', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
|
|
129
|
+
{ name: 'AGENTS.md.archive', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
|
|
130
|
+
{ name: 'baseline.json', kind: 'file', classification: 'generated', source: 'bin/agentic-security.js (--set-baseline)', note: 'operator-set intent, functionally closer to operator-config than scan output — no auto-expiry' },
|
|
131
|
+
// These two were found by the completeness guard (test/artifact-registry-
|
|
132
|
+
// completeness.test.js) to have a read call site (leaderboard.js,
|
|
133
|
+
// posture/findings-memory.js respectively) but NO writer anywhere in src/
|
|
134
|
+
// or bin/ — likely dead/aspirational read paths from a scan-history
|
|
135
|
+
// storage scheme that was refactored away. Registered as 'generated'
|
|
136
|
+
// rather than left unclassified: nothing about a per-scan history log/
|
|
137
|
+
// directory suggests hand-authored config, so if a future change starts
|
|
138
|
+
// writing either, the safe default (delete on reset, like scan-history.json)
|
|
139
|
+
// is already in place rather than accidentally falling to operator-config.
|
|
140
|
+
{ name: 'scan-history.jsonl', kind: 'file', classification: 'generated', retentionClass: 'scan', note: 'no current writer found — see completeness-guard test comment' },
|
|
141
|
+
{ name: 'scan-history', kind: 'dir', classification: 'generated', retentionClass: 'scan', note: 'no current writer found — see completeness-guard test comment' },
|
|
142
|
+
|
|
143
|
+
// FR-706: the last-action proof artifacts `reset` and `export` write —
|
|
144
|
+
// see posture/state-lifecycle-report.js's header for why each is a single
|
|
145
|
+
// overwritten "last action" file rather than an ever-growing log.
|
|
146
|
+
{ name: 'deletion-report.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/state-lifecycle-report.js (via bin/agentic-security.js cmdReset)' },
|
|
147
|
+
{ name: 'export-report.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/state-lifecycle-report.js (via bin/agentic-security.js cmdExport)' },
|
|
148
|
+
|
|
149
|
+
// ── Operator-config: hand-authored (or agent-authored) input, never wiped ──
|
|
150
|
+
{ name: 'rules.yml', kind: 'file', classification: 'operator-config' },
|
|
151
|
+
{ name: 'rules', kind: 'dir', classification: 'operator-config' },
|
|
152
|
+
{ name: 'rules-proposed', kind: 'dir', classification: 'operator-config', note: 'proposed rules awaiting human review — not yet approved config, but not scanner-regenerable either' },
|
|
153
|
+
{ name: 'license-policy.yml', kind: 'file', classification: 'operator-config' },
|
|
154
|
+
{ name: 'trusted-keys.json', kind: 'file', classification: 'operator-config' },
|
|
155
|
+
{ name: 'ruleset-version.json', kind: 'file', classification: 'operator-config', note: 'pinning intent, hand-set' },
|
|
156
|
+
{ name: 'risk-config.yml', kind: 'file', classification: 'operator-config' },
|
|
157
|
+
{ name: 'egress-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-601 egress policy (mode: allow/deny/local-only, allowedProviders/deniedProviders) — read by egress/policy.js, never written by the scanner' },
|
|
158
|
+
{ name: 'integrations.yml', kind: 'file', classification: 'operator-config' },
|
|
159
|
+
{ name: 'profile.yml', kind: 'file', classification: 'operator-config' },
|
|
160
|
+
{ name: 'sca-policy.yml', kind: 'file', classification: 'operator-config' },
|
|
161
|
+
{ name: 'suppressions.yml', kind: 'file', classification: 'operator-config', note: 'audit-tier suppression config; only ever loaded, never saved, by posture/suppressions.js' },
|
|
162
|
+
{ name: 'network-policy.json', kind: 'file', classification: 'operator-config', note: 'documented as an Inputs-section artifact in posture/network-policy-import.js, not a scanner-written digest' },
|
|
163
|
+
{ name: 'privacy-taxonomy.json', kind: 'file', classification: 'operator-config', note: 'FR-402 privacy data-classification taxonomy overrides/additions — read by dataflow/privacy-taxonomy.js, never written by the scanner' },
|
|
164
|
+
{ name: 'privacy-policy.json', kind: 'file', classification: 'operator-config', note: 'FR-404 privacy sink policy (which class-to-sink flows are explicitly permitted) — read by dataflow/privacy-sink-policy.js, never written by the scanner' },
|
|
165
|
+
{ name: 'privacy-governance.json', kind: 'file', classification: 'operator-config', note: 'FR-407 DPIA/RoPA governance field overrides (purpose, lawful basis, retention, etc.) — read by dataflow/privacy-governance.js, never written by the scanner' },
|
|
166
|
+
{ name: 'compliance-severity-policy.json', kind: 'file', classification: 'operator-config', note: 'FR-502 per-framework/default open-finding severity threshold override — read by posture/auditor-walkthrough.js, never written by the scanner' },
|
|
167
|
+
{ name: 'authorized-approvers.json', kind: 'file', classification: 'operator-config', note: 'FR-1002 identity/role registry for high-impact fix approvals — read by fix/approver-registry.js, never written by the scanner' },
|
|
168
|
+
{ name: 'policy-bundles', kind: 'dir', classification: 'operator-config', note: 'FR-1001 signed organization/repository/environment policy bundles (organization.json/repository.json/environment.json) — distributed by an org and placed by the operator, read by posture/policy-bundle.js, never written by the scanner' },
|
|
169
|
+
{ name: 'policy-bundle-public-key.pem', kind: 'file', classification: 'operator-config', note: 'FR-1001 public key an operator installs to verify org-distributed policy bundles — read by posture/policy-bundle.js, never written by the scanner' },
|
|
170
|
+
{ name: 'retention-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-702 per-retention-class TTL overrides (clamped to a built-in per-class maximum) — read by posture/retention-policy.js, never written by the scanner' },
|
|
171
|
+
{ name: 'legal-holds.json', kind: 'file', classification: 'operator-config', note: 'FR-707 legal holds ({artifact, owner, reason, expires_at}) — read by posture/retention-policy.js and bin/agentic-security.js cmdReset; WRITTEN by the CLI (legal-hold add/remove), but classified operator-config (not generated) deliberately: a plain `reset` must never be able to delete the very record protecting other artifacts from deletion' },
|
|
172
|
+
{ name: 'calibration-feedback.jsonl', kind: 'file', classification: 'operator-config', note: 'FR-806 opt-in calibration ground truth ({at, findingId, outcome: accept-risk|realized-incident, predicted*, note}) — WRITTEN by the CLI (calibration-feedback record), but classified operator-config like exploit-history.jsonl: real, hard-to-recreate customer-reported ground truth, never scanner-regenerable, so a routine reset must never delete it' },
|
|
173
|
+
{ name: 'encryption-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-705 encryption provider/required opt-in policy ({provider: local-key, required: true|false}) — read by posture/encryption-provider.js, never written by the scanner' },
|
|
174
|
+
{ name: 'logic-claims.json', kind: 'file', classification: 'operator-config', note: 'authored by an external reviewing agent; engine.js only ever reads it (fs.readFileSync, never written)' },
|
|
175
|
+
{ name: 'current-intent.md', kind: 'file', classification: 'operator-config', note: 'developer-authored; no writer exists anywhere in src/ or bin/' },
|
|
176
|
+
{ name: 'exploit-history.jsonl', kind: 'file', classification: 'operator-config', note: 'own header comment: "operator-curated record of past confirmed exploits"' },
|
|
177
|
+
{ name: 'cve-alerts.json', kind: 'file', classification: 'operator-config', note: 'own header comment: "Configuration is read from"; state lives in the separate cve-alerts-state.json, which IS generated' },
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
export function listGeneratedArtifacts() {
|
|
181
|
+
return ARTIFACT_REGISTRY.filter(a => a.classification === 'generated');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function listOperatorConfigArtifacts() {
|
|
185
|
+
return ARTIFACT_REGISTRY.filter(a => a.classification === 'operator-config');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function isRegisteredArtifact(name) {
|
|
189
|
+
return ARTIFACT_REGISTRY.some(a => a.name === name);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function classificationOf(name) {
|
|
193
|
+
return ARTIFACT_REGISTRY.find(a => a.name === name)?.classification ?? null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// FR-705: is this artifact marked as containing sensitive content an
|
|
197
|
+
// operator may want encrypted at rest? Deliberately a SEPARATE flag from
|
|
198
|
+
// classification/retentionClass — confidentiality is about content
|
|
199
|
+
// sensitivity, not about who writes it or how long it lives. See
|
|
200
|
+
// encryption-provider.js for the enforcement side (the fail-closed gate
|
|
201
|
+
// this flag feeds) and its own header for which artifacts are marked here
|
|
202
|
+
// in this first, deliberately conservative pass, and why others are not.
|
|
203
|
+
export function confidentialOf(name) {
|
|
204
|
+
return ARTIFACT_REGISTRY.find(a => a.name === name)?.confidential === true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// FR-702: which retention class (if any) governs this artifact's TTL. Only
|
|
208
|
+
// 'generated' artifacts can carry one — an 'operator-config' entry is never
|
|
209
|
+
// auto-expired regardless of what this returns (retention-policy.js's own
|
|
210
|
+
// caller enforces that ordering, not this function).
|
|
211
|
+
export function retentionClassOf(name) {
|
|
212
|
+
return ARTIFACT_REGISTRY.find(a => a.name === name)?.retentionClass ?? null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function listArtifactsWithRetentionClass() {
|
|
216
|
+
return ARTIFACT_REGISTRY.filter(a => a.classification === 'generated' && a.retentionClass);
|
|
217
|
+
}
|
|
@@ -27,14 +27,18 @@
|
|
|
27
27
|
// User supplies their own control mapping in the same shape as the
|
|
28
28
|
// bundled ones. The auditor-walkthrough renders evidence against it.
|
|
29
29
|
//
|
|
30
|
-
// Disclaimer: this module organizes scanner evidence into a narrative.
|
|
31
|
-
//
|
|
32
|
-
//
|
|
30
|
+
// Disclaimer: this module organizes scanner evidence into a narrative. It
|
|
31
|
+
// does not certify compliance. See evidence-grade-wording.js for why the
|
|
32
|
+
// emitted disclaimer names all three assurance tiers explicitly (this
|
|
33
|
+
// module's own is one of the ones that used to get the terminology
|
|
34
|
+
// backwards — "a licensed assessor is responsible for the final
|
|
35
|
+
// attestation" describes independent certification, not attestation).
|
|
33
36
|
|
|
34
37
|
import * as fs from 'node:fs';
|
|
35
38
|
import * as path from 'node:path';
|
|
36
39
|
|
|
37
40
|
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
41
|
+
import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
|
|
38
42
|
import { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys } from './family-resolve.js';
|
|
39
43
|
import { strengthOfControl as _strengthOfControl } from './coverage-strength.js';
|
|
40
44
|
|
|
@@ -187,7 +191,64 @@ export const COMPLIANCE_FAMILY_GAPS = {
|
|
|
187
191
|
// works, which is the mirror image of the bug this prevents.
|
|
188
192
|
};
|
|
189
193
|
|
|
194
|
+
// FR-501/FR-502 (assurance-hardening PRD, A-07): a `family:` mapping used to
|
|
195
|
+
// only count 'critical'/'high' findings as "open" — a control with 50 open
|
|
196
|
+
// MEDIUM findings on its mapped family rendered as
|
|
197
|
+
// "✓ no open critical/high findings", identical to a genuinely clean
|
|
198
|
+
// control. posture/privacy-framework.js already solved the analogous problem
|
|
199
|
+
// for its own four-bucket model (its header calls this out directly: a
|
|
200
|
+
// vacuous pass is "the same false assurance... arriving by a different
|
|
201
|
+
// route"); this raises the floor here to match rather than leaving two
|
|
202
|
+
// different standards for what counts as "open" across compliance surfaces.
|
|
203
|
+
// A named, ordered rank (not a hardcoded pair of string literals) so a
|
|
204
|
+
// future per-framework/per-policy threshold (FR-502's fuller scope) is a
|
|
205
|
+
// one-line change here rather than another hunt through the evaluator.
|
|
206
|
+
const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
207
|
+
const OPEN_FINDING_MIN_SEVERITY = 'medium';
|
|
208
|
+
|
|
209
|
+
// FR-502's fuller scope, delivered: "policy-specific rather than globally
|
|
210
|
+
// high/critical." An operator can lower (or raise) the open-finding floor
|
|
211
|
+
// per framework via .agentic-security/compliance-severity-policy.json:
|
|
212
|
+
// { "default": "medium", "byFramework": { "gdpr": "low" } }
|
|
213
|
+
// `default` overrides OPEN_FINDING_MIN_SEVERITY for every framework that
|
|
214
|
+
// has no more specific `byFramework` entry; a framework entry wins over
|
|
215
|
+
// `default`. Never inferred — an operator decision, same as
|
|
216
|
+
// dataflow/privacy-taxonomy.js's (FR-402) taxonomy customization and
|
|
217
|
+
// egress/policy.js's (FR-602) config-file precedent. A missing file, a
|
|
218
|
+
// malformed one, or a value that is not one of SEVERITY_RANK's five known
|
|
219
|
+
// keys all degrade to the built-in 'medium' floor — falling back to a rank
|
|
220
|
+
// of `undefined` would make the `>=` comparison always false, silently
|
|
221
|
+
// treating EVERY finding as "not open" (the exact vacuous-pass bug this
|
|
222
|
+
// threshold exists to prevent), so an invalid override must never reach
|
|
223
|
+
// the comparison at all.
|
|
224
|
+
const SEVERITY_POLICY_FILE = 'compliance-severity-policy.json';
|
|
225
|
+
|
|
226
|
+
function _resolveOpenFindingMinSeverity(scanRoot, frameworkId) {
|
|
227
|
+
if (!scanRoot) return OPEN_FINDING_MIN_SEVERITY;
|
|
228
|
+
let raw;
|
|
229
|
+
try {
|
|
230
|
+
raw = fs.readFileSync(statePath(scanRoot, SEVERITY_POLICY_FILE), 'utf8');
|
|
231
|
+
} catch {
|
|
232
|
+
return OPEN_FINDING_MIN_SEVERITY; // ENOENT (the common case) or any other read failure
|
|
233
|
+
}
|
|
234
|
+
let doc;
|
|
235
|
+
try {
|
|
236
|
+
doc = JSON.parse(raw);
|
|
237
|
+
} catch {
|
|
238
|
+
return OPEN_FINDING_MIN_SEVERITY; // malformed config — never throws, never blocks evaluation
|
|
239
|
+
}
|
|
240
|
+
if (!doc || typeof doc !== 'object') return OPEN_FINDING_MIN_SEVERITY;
|
|
241
|
+
const byFramework = (doc.byFramework && typeof doc.byFramework === 'object') ? doc.byFramework : {};
|
|
242
|
+
const candidate = (frameworkId && typeof byFramework[frameworkId] === 'string')
|
|
243
|
+
? byFramework[frameworkId]
|
|
244
|
+
: (typeof doc.default === 'string' ? doc.default : null);
|
|
245
|
+
return (candidate && Object.prototype.hasOwnProperty.call(SEVERITY_RANK, candidate))
|
|
246
|
+
? candidate
|
|
247
|
+
: OPEN_FINDING_MIN_SEVERITY;
|
|
248
|
+
}
|
|
249
|
+
|
|
190
250
|
export function evaluateFramework(scanRoot, fw, scan) {
|
|
251
|
+
const minSeverity = _resolveOpenFindingMinSeverity(scanRoot, fw && fw.id);
|
|
191
252
|
// CMP-2: last-scan.json (what this is actually handed in production) carries
|
|
192
253
|
// findings across four separate channels — SAST (`findings`), secrets,
|
|
193
254
|
// business-logic, and SCA (`supplyChain`) — because report/index.js's
|
|
@@ -291,12 +352,13 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
291
352
|
const candidates = resolveFamilyKeys(fam, families.keys())
|
|
292
353
|
.flatMap(k => families.get(k) || []);
|
|
293
354
|
const scoped = subfam ? candidates.filter(f => !f.subfamily || f.subfamily === subfam) : candidates;
|
|
294
|
-
const
|
|
355
|
+
const minRank = SEVERITY_RANK[minSeverity];
|
|
356
|
+
const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
|
|
295
357
|
if (open.length) {
|
|
296
358
|
allCleared = false;
|
|
297
|
-
obs.push(`${open.length} open ${fam} finding(s) at
|
|
359
|
+
obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
|
|
298
360
|
} else {
|
|
299
|
-
obs.push(`✓ ${fam}: no open
|
|
361
|
+
obs.push(`✓ ${fam}: no open ${minSeverity}+ findings.`);
|
|
300
362
|
anyCleared = true;
|
|
301
363
|
}
|
|
302
364
|
anySignal = true;
|
|
@@ -420,7 +482,7 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
|
|
|
420
482
|
lines.push(`> License: ${fw.license}`);
|
|
421
483
|
if (fw.url) lines.push(`> Source: ${fw.url}`);
|
|
422
484
|
lines.push('');
|
|
423
|
-
lines.push(
|
|
485
|
+
lines.push(`> **This walkthrough organizes scanner evidence into a narrative for an external auditor.** ${EVIDENCE_GRADE_DISCLAIMER_SHORT}`);
|
|
424
486
|
lines.push('');
|
|
425
487
|
|
|
426
488
|
const present = evaluation.filter(e => e.status === 'present').length;
|
|
@@ -475,4 +537,4 @@ export function persistWalkthrough(scanRoot, fw, body) {
|
|
|
475
537
|
return fp;
|
|
476
538
|
}
|
|
477
539
|
|
|
478
|
-
export const _internals = { _readJson };
|
|
540
|
+
export const _internals = { _readJson, _resolveOpenFindingMinSeverity, SEVERITY_POLICY_FILE };
|
|
@@ -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);
|