@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,48 @@
|
|
|
1
|
+
// Producer collector (assurance-hardening PRD, Milestone 1, FR-102).
|
|
2
|
+
//
|
|
3
|
+
// The one place a "late producer" (see producer-registry.js's header for
|
|
4
|
+
// exactly which ones — the cross-language/business-logic/privacy group
|
|
5
|
+
// A-03 evidenced) is allowed to append to the shared finding array. Before
|
|
6
|
+
// this, each of these ~12 call sites in engine.js did its own
|
|
7
|
+
// `try { ...; if (x?.length) finalFindings.push(...x); } catch(_) {}` —
|
|
8
|
+
// functionally fine for not crashing the scan, but each one silently
|
|
9
|
+
// swallowed a producer exception with no diagnostic (a producer could be
|
|
10
|
+
// broken for months and nothing would ever say so) and there was no single
|
|
11
|
+
// place enforcing "only a REGISTERED producer may append here."
|
|
12
|
+
//
|
|
13
|
+
// This does not change engine.js's ~90 core SAST/SCA/secrets detector call
|
|
14
|
+
// sites (see producer-registry.js's header for why that is out of scope).
|
|
15
|
+
|
|
16
|
+
import { isRegisteredProducer } from './producer-registry.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object[]} finalFindings - mutated in place (push only)
|
|
20
|
+
* @param {Array<{phase:string, err:string}>} diagnostics - mutated in place (push only), same
|
|
21
|
+
* shape as engine.js's _annotatorErrors so both surfaces read as one list
|
|
22
|
+
* @param {string} producerId - MUST be registered via producer-registry.js's registerProducer()
|
|
23
|
+
* @param {() => (Array|{findings:Array}|null|undefined)} thunk - runs the actual producer;
|
|
24
|
+
* may return a bare findings array (matches every one of these producers' current shape) or
|
|
25
|
+
* an object with a .findings array (forward-compatible with a future AnalyzerResult shape)
|
|
26
|
+
* @returns {{status:'completed'|'failed'|'unregistered', count:number}}
|
|
27
|
+
*/
|
|
28
|
+
export function collectProducerResult(finalFindings, diagnostics, producerId, thunk) {
|
|
29
|
+
if (!isRegisteredProducer(producerId)) {
|
|
30
|
+
const msg = `producer "${producerId}" is not registered — refusing to collect its output`;
|
|
31
|
+
diagnostics.push({ phase: `producer:${producerId}`, err: msg });
|
|
32
|
+
return { status: 'unregistered', count: 0 };
|
|
33
|
+
}
|
|
34
|
+
let result;
|
|
35
|
+
try {
|
|
36
|
+
result = thunk();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
diagnostics.push({ phase: `producer:${producerId}`, err: String((e && e.message) || e) });
|
|
39
|
+
return { status: 'failed', count: 0 };
|
|
40
|
+
}
|
|
41
|
+
const findings = Array.isArray(result) ? result : (Array.isArray(result?.findings) ? result.findings : null);
|
|
42
|
+
if (!findings || !findings.length) return { status: 'completed', count: 0 };
|
|
43
|
+
for (const f of findings) {
|
|
44
|
+
if (f && typeof f === 'object' && f.producerId === undefined) f.producerId = producerId;
|
|
45
|
+
}
|
|
46
|
+
finalFindings.push(...findings);
|
|
47
|
+
return { status: 'completed', count: findings.length };
|
|
48
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Producer registry (assurance-hardening PRD, Milestone 1, FR-101).
|
|
2
|
+
//
|
|
3
|
+
// engine.js's core ~90 SAST/SCA/secrets detectors are called synchronously
|
|
4
|
+
// inside one large per-file loop and are NOT converted to registry entries
|
|
5
|
+
// by this change — that is a much larger, separate undertaking (every one
|
|
6
|
+
// of those call sites would need to change shape) and is explicitly out of
|
|
7
|
+
// scope here. What this registry DOES cover: the "late producers" that
|
|
8
|
+
// append findings AFTER the main per-file loop and AFTER the primary
|
|
9
|
+
// enrichment chain has already run once — cross-language taint (5
|
|
10
|
+
// boundary types), IaC-reachability, IAM-policy, container-runtime,
|
|
11
|
+
// business-logic-v2, specification-drift, concurrency, and privacy-taint.
|
|
12
|
+
// These are exactly the producers the PRD's A-03 evidence cites (findings
|
|
13
|
+
// that bypass stableId/confidence/calibration because they arrive after
|
|
14
|
+
// engine.js's enrichment chain already finished). See
|
|
15
|
+
// pipeline/producer-collector.js for how registration is enforced at the
|
|
16
|
+
// point findings are actually collected, and pipeline/enrichment-completion.js
|
|
17
|
+
// for how any finding that still lacks the standard fields (regardless of
|
|
18
|
+
// producer) gets them before the collection is frozen.
|
|
19
|
+
|
|
20
|
+
const KNOWN_PHASES = Object.freeze(['sast', 'sca', 'secrets', 'cross-language', 'business-logic', 'privacy']);
|
|
21
|
+
|
|
22
|
+
const _registry = new Map();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} def
|
|
26
|
+
* @param {string} def.id - unique producer id
|
|
27
|
+
* @param {string} def.version - producer version string (semver-ish, not enforced)
|
|
28
|
+
* @param {string} def.phase - one of KNOWN_PHASES
|
|
29
|
+
* @param {string[]} [def.languages] - languages this producer applies to, or [] for language-agnostic
|
|
30
|
+
* @param {string[]} [def.dependsOn] - ids of producers that must be registered (and are logically upstream)
|
|
31
|
+
* @throws on duplicate id, unknown phase, or a dependency cycle
|
|
32
|
+
*/
|
|
33
|
+
export function registerProducer(def) {
|
|
34
|
+
if (!def || typeof def !== 'object') throw new Error('registerProducer: definition required');
|
|
35
|
+
const { id, version, phase, languages = [], dependsOn = [] } = def;
|
|
36
|
+
if (typeof id !== 'string' || !id) throw new Error('registerProducer: id required');
|
|
37
|
+
if (_registry.has(id)) throw new Error(`registerProducer: duplicate producer id "${id}"`);
|
|
38
|
+
if (typeof version !== 'string' || !version) throw new Error(`registerProducer: "${id}" missing version`);
|
|
39
|
+
if (!KNOWN_PHASES.includes(phase)) {
|
|
40
|
+
throw new Error(`registerProducer: "${id}" has unknown phase "${phase}" — must be one of ${KNOWN_PHASES.join(', ')}`);
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(dependsOn)) throw new Error(`registerProducer: "${id}" dependsOn must be an array`);
|
|
43
|
+
|
|
44
|
+
_registry.set(id, { id, version, phase, languages: [...languages], dependsOn: [...dependsOn] });
|
|
45
|
+
const cycle = _findCycle();
|
|
46
|
+
if (cycle) {
|
|
47
|
+
_registry.delete(id); // don't leave the registry in a broken state
|
|
48
|
+
throw new Error(`registerProducer: dependency cycle detected: ${cycle.join(' -> ')}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Topological cycle check over the current registry. Returns the cycle path, or null. */
|
|
53
|
+
function _findCycle() {
|
|
54
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
55
|
+
const color = new Map([..._registry.keys()].map(id => [id, WHITE]));
|
|
56
|
+
const path = [];
|
|
57
|
+
function visit(id) {
|
|
58
|
+
color.set(id, GRAY);
|
|
59
|
+
path.push(id);
|
|
60
|
+
const def = _registry.get(id);
|
|
61
|
+
for (const dep of def?.dependsOn || []) {
|
|
62
|
+
if (!_registry.has(dep)) continue; // an unregistered dependency is reported separately, not a cycle
|
|
63
|
+
const c = color.get(dep);
|
|
64
|
+
if (c === GRAY) return [...path.slice(path.indexOf(dep)), dep];
|
|
65
|
+
if (c === WHITE) {
|
|
66
|
+
const found = visit(dep);
|
|
67
|
+
if (found) return found;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
path.pop();
|
|
71
|
+
color.set(id, BLACK);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
for (const id of _registry.keys()) {
|
|
75
|
+
if (color.get(id) === WHITE) {
|
|
76
|
+
const found = visit(id);
|
|
77
|
+
if (found) return found;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isRegisteredProducer(id) {
|
|
84
|
+
return _registry.has(id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getProducer(id) {
|
|
88
|
+
return _registry.get(id) || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function listProducers() {
|
|
92
|
+
return [..._registry.values()];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Every dependsOn id must itself be a registered producer. For tests/CI, not called at runtime. */
|
|
96
|
+
export function validateNoUnregisteredDependencies() {
|
|
97
|
+
const missing = [];
|
|
98
|
+
for (const def of _registry.values()) {
|
|
99
|
+
for (const dep of def.dependsOn) {
|
|
100
|
+
if (!_registry.has(dep)) missing.push(`${def.id} depends on unregistered producer "${dep}"`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return missing;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Test-only: reset the module-level registry between test files/cases.
|
|
107
|
+
// Never called from production code paths.
|
|
108
|
+
export function _resetForTests() {
|
|
109
|
+
_registry.clear();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const KNOWN_PHASES_FOR_TESTS = KNOWN_PHASES;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Scan health (assurance-hardening PRD, Milestone 0, FR-206).
|
|
2
|
+
//
|
|
3
|
+
// PRD principle: "Separate 'no findings' from 'analysis complete'." Before
|
|
4
|
+
// this module, a scan that hit an annotator exception, timed out on files, or
|
|
5
|
+
// silently downgraded deep-analysis mode reported the same shape as a clean
|
|
6
|
+
// complete scan — the only signals (`annotatorErrors`, `_scanMeta.filesTimedOut`)
|
|
7
|
+
// existed on the result object already, but nothing summarized them into a
|
|
8
|
+
// single status a caller could gate on or a human could read as a headline.
|
|
9
|
+
//
|
|
10
|
+
// This is additive-only (Milestone 0 scope): computeScanHealth() is a pure
|
|
11
|
+
// function over signals the engine already collects, and its output is a new
|
|
12
|
+
// field on the scan result. No existing gate, exit code, or CLI behavior
|
|
13
|
+
// changes when this field is added.
|
|
14
|
+
//
|
|
15
|
+
// `analyzers` (PRD section 10.3) went from a hardcoded `null` to a real,
|
|
16
|
+
// computed summary once FR-201 (every detector call site isolated,
|
|
17
|
+
// {file,analyzer,err} captured), FR-202 (real preemptive deadlines, a
|
|
18
|
+
// _timeout:true marker per killed file), and FR-203 (pipeline/
|
|
19
|
+
// coverage-ledger.js, the actual per-file x per-analyzer status
|
|
20
|
+
// computation) all existed for it to be computed FROM — reporting it
|
|
21
|
+
// before then would have looked more precise than the underlying analysis
|
|
22
|
+
// supported, which is exactly the failure mode this module exists to
|
|
23
|
+
// prevent (see this file's own header history in git blame for that
|
|
24
|
+
// reasoning, kept as a decision record even though the gap it names is
|
|
25
|
+
// now closed).
|
|
26
|
+
|
|
27
|
+
export const SCAN_HEALTH_SCHEMA_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} input
|
|
31
|
+
* @param {object|null} input.scanMeta - engine's `_scanMeta` (filesScanned, filesSkipped,
|
|
32
|
+
* filesDenseSkipped, filesTimedOut, checkpoint, ...).
|
|
33
|
+
* @param {Array<{phase:string, err:string}>} input.annotatorErrors
|
|
34
|
+
* @param {object} [input.engineErrors] - e.g. { cppDataflowParseErrors }
|
|
35
|
+
* @param {object} [input.deepStatus] - { requested, enabled, inCi, ciOverrideAllowed, reason, failure }
|
|
36
|
+
* @param {object} [input.analyzerCoverage] - coverage-ledger.js's
|
|
37
|
+
* summarizeCoverageForScanHealth() output: {expected, completed, failed,
|
|
38
|
+
* timedOut, skippedByPolicy}. Omitted (not just empty) is treated the
|
|
39
|
+
* same as the old `null` — a caller that hasn't wired FR-203's ledger in
|
|
40
|
+
* yet (e.g. a hand-built scan object in a test) gets `analyzers: null`,
|
|
41
|
+
* never a fabricated all-zero summary.
|
|
42
|
+
* @returns {object} scanHealth per PRD §10.3, additive fields only.
|
|
43
|
+
*/
|
|
44
|
+
export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engineErrors = null, deepStatus = null, analyzerCoverage = null } = {}) {
|
|
45
|
+
const conditions = [];
|
|
46
|
+
const safeAnnotatorErrors = Array.isArray(annotatorErrors) ? annotatorErrors : [];
|
|
47
|
+
const filesTimedOut = Number(scanMeta?.filesTimedOut) || 0;
|
|
48
|
+
|
|
49
|
+
if (safeAnnotatorErrors.length > 0) {
|
|
50
|
+
conditions.push(`${safeAnnotatorErrors.length} annotator(s) threw and were skipped: ${
|
|
51
|
+
[...new Set(safeAnnotatorErrors.map(e => e.phase))].join(', ')
|
|
52
|
+
}`);
|
|
53
|
+
}
|
|
54
|
+
if (filesTimedOut > 0) {
|
|
55
|
+
conditions.push(`${filesTimedOut} file(s) exceeded the per-file analysis timeout`);
|
|
56
|
+
}
|
|
57
|
+
if (engineErrors?.cppDataflowParseErrors > 0) {
|
|
58
|
+
conditions.push(`${engineErrors.cppDataflowParseErrors} C/C++ dataflow parse error(s)`);
|
|
59
|
+
}
|
|
60
|
+
if (deepStatus?.failure) {
|
|
61
|
+
conditions.push(`deep analysis (IR-taint) threw and fell back to pattern-only results: ${deepStatus.failure}`);
|
|
62
|
+
}
|
|
63
|
+
if (deepStatus?.requested && !deepStatus.enabled) {
|
|
64
|
+
conditions.push(`deep analysis was requested but did not run: ${deepStatus.reason || 'unknown reason'}`);
|
|
65
|
+
}
|
|
66
|
+
// FR-203: a detector that threw on at least one file (captured via
|
|
67
|
+
// FR-201's runDetector isolation) is a real analysis gap distinct from
|
|
68
|
+
// an ANNOTATOR error above — annotators run post-detection over the
|
|
69
|
+
// whole finding set; detectors run per-file and produce the findings
|
|
70
|
+
// themselves, so a failed detector can mean a real vulnerability was
|
|
71
|
+
// never even looked for. This condition did not exist before FR-203's
|
|
72
|
+
// ledger made "which analyzer, how many files" computable.
|
|
73
|
+
if (analyzerCoverage && analyzerCoverage.failed > 0) {
|
|
74
|
+
conditions.push(`${analyzerCoverage.failed} analyzer(s) threw on at least one file`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const status = conditions.length > 0 ? 'partial' : 'complete';
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
schemaVersion: SCAN_HEALTH_SCHEMA_VERSION,
|
|
81
|
+
status,
|
|
82
|
+
files: {
|
|
83
|
+
expected: scanMeta?.checkpoint?.total ?? null,
|
|
84
|
+
scanned: scanMeta?.filesScanned ?? null,
|
|
85
|
+
skipped: (Number(scanMeta?.filesSkipped) || 0) + (Number(scanMeta?.filesDenseSkipped) || 0),
|
|
86
|
+
timedOut: filesTimedOut,
|
|
87
|
+
},
|
|
88
|
+
analyzers: analyzerCoverage || null,
|
|
89
|
+
deepAnalysis: deepStatus
|
|
90
|
+
? {
|
|
91
|
+
requested: !!deepStatus.requested,
|
|
92
|
+
enabled: !!deepStatus.enabled,
|
|
93
|
+
inCi: !!deepStatus.inCi,
|
|
94
|
+
ciOverrideAllowed: !!deepStatus.ciOverrideAllowed,
|
|
95
|
+
reason: deepStatus.reason ?? null,
|
|
96
|
+
failure: deepStatus.failure ?? null,
|
|
97
|
+
}
|
|
98
|
+
: null,
|
|
99
|
+
annotatorErrorCount: safeAnnotatorErrors.length,
|
|
100
|
+
freshness: null,
|
|
101
|
+
conditions,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// FR-207 ("Add freshness checks for vulnerability feeds, calibration data,
|
|
106
|
+
// rulesets, and policies — stale dependencies are visible and can fail
|
|
107
|
+
// strict policy"). Deliberately NOT folded into computeScanHealth() itself:
|
|
108
|
+
// the five freshness signals (KEV, EPSS, calibration, custom rules,
|
|
109
|
+
// compliance evidence) are computed in two different places at two
|
|
110
|
+
// different times relative to computeScanHealth's own call site —
|
|
111
|
+
// engine.js has kev/epss/calibration/compliance available before it builds
|
|
112
|
+
// scanHealth, but the custom-rules pattern-DSL only runs in
|
|
113
|
+
// bin/agentic-security.js, AFTER scanHealth already exists on the scan
|
|
114
|
+
// object. A single merge/condition/status function usable from both call
|
|
115
|
+
// sites (once up front, once as a later patch) is simpler and less
|
|
116
|
+
// duplicative than threading a partial value through computeScanHealth
|
|
117
|
+
// twice. Each leg is additive: a leg not present in `freshnessPartial`
|
|
118
|
+
// leaves scanHealth's existing `freshness` object and conditions
|
|
119
|
+
// untouched, exactly like every other optional input this module accepts.
|
|
120
|
+
const FRESHNESS_CONDITION_BUILDERS = {
|
|
121
|
+
kev: (f) => (f?.stale === true) ? `KEV catalog is stale (${f.ageDays} day(s) old) — recently-added CVEs may not be reflected` : null,
|
|
122
|
+
epss: (f) => (f?.stale === true) ? `EPSS exploit-probability data is stale (${f.ageDays} day(s) old)` : null,
|
|
123
|
+
calibration: (f) => (f?.stale === true) ? `calibration data is stale (${f.ageDays} day(s) old${f.generatedAt ? `, last generated ${f.generatedAt}` : ''})` : null,
|
|
124
|
+
customRules: (f) => (f?.stale === true) ? `${f.staleFiles.length} custom rule file(s) exceed their configured review interval` : null,
|
|
125
|
+
compliance: (f) => (Number(f?.stale) > 0) ? `${f.stale} compliance control(s) have stale evidence` : null,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export function applyFreshness(scanHealth, freshnessPartial) {
|
|
129
|
+
if (!scanHealth || !freshnessPartial) return scanHealth;
|
|
130
|
+
const freshness = { ...(scanHealth.freshness || {}), ...freshnessPartial };
|
|
131
|
+
const newConditions = [];
|
|
132
|
+
for (const [key, value] of Object.entries(freshnessPartial)) {
|
|
133
|
+
const build = FRESHNESS_CONDITION_BUILDERS[key];
|
|
134
|
+
const msg = build ? build(value) : null;
|
|
135
|
+
if (msg) newConditions.push(msg);
|
|
136
|
+
}
|
|
137
|
+
if (!newConditions.length) return { ...scanHealth, freshness };
|
|
138
|
+
return {
|
|
139
|
+
...scanHealth,
|
|
140
|
+
freshness,
|
|
141
|
+
conditions: [...scanHealth.conditions, ...newConditions],
|
|
142
|
+
status: scanHealth.status === 'complete' ? 'partial' : scanHealth.status,
|
|
143
|
+
};
|
|
144
|
+
}
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -349,6 +349,129 @@ output), and an entry a participant could not run is **unscored**, never counted
|
|
|
349
349
|
as a miss. Matching is CWE-only so nobody is scored on this engine's vocabulary.
|
|
350
350
|
Driver: `scripts/comparison.mjs`, over the CVE-replay corpus.
|
|
351
351
|
|
|
352
|
+
**State artifact registry (assurance-hardening PRD FR-701/FR-703)** — `artifact-registry.js`. The registry `cmdReset` (bin/agentic-security.js) now iterates instead of two hardcoded WIPE/WIPE_DIRS Sets. Every known `.agentic-security/` artifact is classified `generated` (scanner-written, safe to delete on reset) or `operator-config` (hand- or agent-authored input, never deleted) — built from an audit of every `statePath()`/`stateDir()` call site, not guessed from filenames; several looked generated by name but turned out to be inputs (`.agentic-security/logic-claims.json`, `.agentic-security/exploit-history.jsonl`, `.agentic-security/cve-alerts.json`, `.agentic-security/network-policy.json`, `.agentic-security/current-intent.md` — see the module's own header for the evidence behind each). Guarded by a completeness test (`test/artifact-registry-completeness.test.js`) that scans for every `statePath()`/`stateDir()` literal and fails if one isn't registered — a `no-dead-modules.test.js`-style drift guard, not a snapshot.
|
|
353
|
+
|
|
354
|
+
## Finding provenance — `provenance/` (20 modules)
|
|
355
|
+
|
|
356
|
+
The only SUBDIRECTORY under `posture/`, because it is a pipeline rather than an
|
|
357
|
+
annotator: twenty small modules that together answer "which commit introduced
|
|
358
|
+
this finding, and how sure are we?" Everything outside the subdirectory sees one
|
|
359
|
+
function, `annotateGitProvenance(findings, ctx)` from `coordinator.js`, wired in
|
|
360
|
+
`engine.js` after every finding has been appended.
|
|
361
|
+
|
|
362
|
+
**Read the naming rule before you touch anything here.** The exported function is
|
|
363
|
+
`annotateGitProvenance` — NOT `annotateProvenance` (taken by
|
|
364
|
+
`sca/sigstore-verify.js`, build attestations) and NOT `annotateFindingProvenance`
|
|
365
|
+
(taken by `posture/provenance.js`, parser-corroboration signals). `engine.js`
|
|
366
|
+
imports all three; either alternative name is a duplicate binding, and the second
|
|
367
|
+
takes a findings array as its first argument exactly like this one, so a wrong
|
|
368
|
+
import would RUN rather than fail. The field is `finding.findingProvenance`,
|
|
369
|
+
never bare `.provenance` — `finding.provenance` and `supplyChainEntry.provenance`
|
|
370
|
+
are both pre-existing unrelated fields.
|
|
371
|
+
|
|
372
|
+
**The pipeline**, in call order — all LIVE-WIRED into `engine.js`'s scan unless noted:
|
|
373
|
+
|
|
374
|
+
| Module | Answers |
|
|
375
|
+
|---|---|
|
|
376
|
+
| `coordinator.js` | the integration point — budget, cache, per-finding dispatch, the terminal-status guarantee |
|
|
377
|
+
| `git-evidence.js` | the only Git wrapper (`getRepoState`, `blameLine`, `candidateCommitsForLine`, `getBlobAtCommit`, `commitMeta`) |
|
|
378
|
+
| `origin-resolver.js` | which commit introduced a SAST finding |
|
|
379
|
+
| `dag-walk.js` | (M3 §3.1) non-first-parent DAG walk + revert/cherry-pick detection for `--provenance deep` |
|
|
380
|
+
| `predicate-replay.js` | was this finding's condition true at commit X (calls `runFullScan` on that commit's blobs) |
|
|
381
|
+
| `sca-origin.js` | which commit moved a directly-declared dependency version into an advisory's vulnerable range |
|
|
382
|
+
| `transitive-sca.js` | (M3 §3.2) the same question for a TRANSITIVE dependency, re-deriving lockfile ancestry per historical commit |
|
|
383
|
+
| `branch-entry.js` | which branch/PR merge brought the origin commit into the current branch |
|
|
384
|
+
| `evidence-attribution.js` | the path:line:commit triples for source / sink / manifest |
|
|
385
|
+
| `confidence.js` | HIGH / MEDIUM / LOW plus the reasons behind it |
|
|
386
|
+
| `lifecycle.js` | the introduce / remediate / reintroduce ledger |
|
|
387
|
+
| `cache.js` | per-(HEAD, stableId, ruleset, boundary, mode) memo under its own top-level `.agentic-security/provenance-cache/` (split out from `provenance/` so it can carry a `'cache'` retentionClass the permanent lifecycle ledger must not get — see artifact-registry.js) |
|
|
388
|
+
| `schema.js` | the status/method/role/confidence enums, `emptyProvenance`, `redactFindingProvenance`, `isProvenanceHealthy` |
|
|
389
|
+
| `validate.js` | shape assertion for tests |
|
|
390
|
+
| `missing-control-resolver.js` | (M3 §3.3, FR-PROV-017) when a previously-observed safeguard disappeared — **wired into `coordinator.js`**: `resolveMissingControlOrigin` calls `resolveMissingControl` for any finding with `missingControlCandidate:true` (today, `sast/rate-limit.js`'s findings) |
|
|
391
|
+
| `providers/config.js`, `providers/github.js`, `providers/gitlab.js` | (M3 §3.4, FR-PROV-022) GitHub/GitLab PR-metadata + CODEOWNERS fetch, config resolved from `.agentic-security/provenance-providers.yml` / token env vars — **wired into `coordinator.js`**: `resolveProviderConfig` is resolved once per scan in `annotateGitProvenance`, and `fetchPRMetadata`/`fetchCodeowners` are called per `complete`-status finding (capped, see `MAX_PROVIDER_ENRICHMENTS_PER_SCAN`), landing on `findingProvenance.providerEnrichment` |
|
|
392
|
+
| `repo-lineage.js` | (M4 §4.2) loads + fully verifies an operator-declared `.agentic-security/repo-lineage.json` cross-repo link (local clones only, no remote fetch) — used by `origin-resolver.js`'s root-commit case, not a standalone-unwired module |
|
|
393
|
+
| `ai-authorship.js` | (M4 §4.3) extensible AI-authorship verifier registry (`registerAIAuthorshipVerifier`/`resolveAIAuthorship`), defaults to `{status:'unknown', verifier:null}` with nothing registered (today's real state) — wired into `origin-resolver.js`'s `originFrom`, so every SAST `findingOrigin` carries `aiAuthorship`; scoped to SAST only, not direct/transitive SCA origins |
|
|
394
|
+
|
|
395
|
+
**Four invariants, each with a test that fails if you relax it:**
|
|
396
|
+
|
|
397
|
+
- **Terminal status, always.** After `annotateGitProvenance` returns, every
|
|
398
|
+
finding carries a `findingProvenance` with one of `complete` / `partial` /
|
|
399
|
+
`uncommitted` / `not_available` / `budget_exhausted` / `error`. There is no
|
|
400
|
+
path — missing git binary, malformed finding, downstream throw — that leaves
|
|
401
|
+
the field absent. `engine.js` additionally backstops every channel OUTSIDE
|
|
402
|
+
the `_runAnnotator` wrapper, because that wrapper swallows throws —
|
|
403
|
+
`findings` and `supplyChain` with a full not_available/error catch-all as
|
|
404
|
+
before; since Task 11, `secrets` and blameable `logicVulns` go through REAL
|
|
405
|
+
resolution (real stableIds backfilled, real `annotateGitProvenance` calls
|
|
406
|
+
made), so their outside-the-wrapper coverage narrowed to a defensive
|
|
407
|
+
catch-all for whatever the real call somehow didn't reach, plus the 3
|
|
408
|
+
synthetic-line `logicVulns` producers (`license-policy:`/`deploy-platform:`/
|
|
409
|
+
`stack-playbook:`), which stay on a permanent, principled not_available —
|
|
410
|
+
never routed through `resolveOrigin` at all, not merely deferred.
|
|
411
|
+
- **Never false certainty.** A shallow clone cannot reach `complete`; an
|
|
412
|
+
unverifiable parent boundary degrades to `partial` with its reason carried
|
|
413
|
+
through. `origin-resolver.js` decides this on the `shallow` flag of the
|
|
414
|
+
repoState object, and it must come from the REAL `getRepoState()` — pass it a
|
|
415
|
+
stub and the guarantee is gone.
|
|
416
|
+
- **The lifecycle ledger only closes findings on a COMPLETE scan.** `applyScan`'s
|
|
417
|
+
remediation pass turns absence into the claim "this was fixed," which is sound
|
|
418
|
+
only if the scan looked everywhere. `runScan.js` computes `completeScan` (false
|
|
419
|
+
for `--changed-since`/`--pr` and for caller-supplied `fileContents`) and threads
|
|
420
|
+
it through `runFullScan` to `updateLifecycle`. `updateLifecycle` is also gated on
|
|
421
|
+
the `scanRoot` being **a directory that exists** — not merely truthy.
|
|
422
|
+
`resolveProjectRoot` honours a caller-supplied scanRoot only when it resolves to
|
|
423
|
+
a real directory; for `null`, for a typo'd path, or for a file, it falls back to
|
|
424
|
+
walking up from the PROCESS CWD. Both doors led to the same corruption: a scan
|
|
425
|
+
that never looked at your project writing your project's ledger, and then —
|
|
426
|
+
finding nothing while still claiming `completeScan` — remediating every open
|
|
427
|
+
finding in it. `agentic-security scan ./typo` is the reachable form. This repo's
|
|
428
|
+
own checkout accumulated a 1.1 MB ledger of spurious events that way.
|
|
429
|
+
- **One budget for the whole scan.** `engine.js` computes ONE `deadlineAt` and
|
|
430
|
+
passes it to all five of its `annotateGitProvenance` calls (SAST findings,
|
|
431
|
+
direct SCA deps, transitive SCA deps per Task 7, then secrets and blameable
|
|
432
|
+
logicVulns per Task 11); a caller-supplied `deadlineAt`/`perFindingBudgetMs`
|
|
433
|
+
wins over the coordinator's own computation. Inside, each finding gets
|
|
434
|
+
`max(2s, remaining/count)` so one deep-history finding cannot starve the rest.
|
|
435
|
+
`budget_exhausted` is the one result that is **never cached** — it is a property
|
|
436
|
+
of the run, not the repository, and caching it would pin a timeout in place
|
|
437
|
+
until HEAD moved.
|
|
438
|
+
|
|
439
|
+
**Re-entrancy brake.** `predicate-replay.js` calls `runFullScan` back on historical
|
|
440
|
+
blobs, so every internal re-scan must pass `provenance:false` or the pass recurses
|
|
441
|
+
without bound. Present callers: `history-scan.js` (×3), `pr-delta.js`,
|
|
442
|
+
`fix-verify.js`, `compare.js`; `lsp/server.js` uses the wider
|
|
443
|
+
`withStateWritesDisabled`.
|
|
444
|
+
|
|
445
|
+
**Privacy.** Author emails are collected but redacted by `redactFindingProvenance`
|
|
446
|
+
at every output boundary (`report/index.js`, `mcp/tools.js`) unless
|
|
447
|
+
`AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL=1` / `--include-author-email`. Separately,
|
|
448
|
+
`AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1` / `--pseudonymize-authors` (PRD Section 8)
|
|
449
|
+
replaces `authorName` with a stable `Contributor-XXXXXXXX` pseudonym instead of
|
|
450
|
+
withholding it — `redactFindingProvenance` applies the same treatment to
|
|
451
|
+
`providerEnrichment.reviewers`/`codeowners` (FR-PROV-022's PR-reviewer logins and
|
|
452
|
+
raw CODEOWNERS lines), not just `findingOrigin`. Both `report/index.js` and
|
|
453
|
+
`mcp/tools.js` read the env var per call to build the redaction options
|
|
454
|
+
(`mcp/tools.js` deliberately never reads `AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL`
|
|
455
|
+
itself — an agent caller gets no raw email regardless of that flag); the
|
|
456
|
+
`auditor-walkthrough.js` narrative reads it too, for the one `earliestOrigin`
|
|
457
|
+
field that bypasses `redactFindingProvenance` entirely (see that module's own
|
|
458
|
+
comment on why).
|
|
459
|
+
|
|
460
|
+
**At rest, `provenance/cache.js` stores the UNREDACTED record, on purpose.**
|
|
461
|
+
Redaction is a read-time/output-time concern — the same cached record gets
|
|
462
|
+
replayed back out through `redactFindingProvenance` differently per output
|
|
463
|
+
call (default vs. `--include-author-email` vs. `--pseudonymize-authors`), which
|
|
464
|
+
only works if the cache holds one raw, policy-independent copy. Pre-redacting
|
|
465
|
+
at write time would freeze whichever policy was active when the entry was
|
|
466
|
+
cached, breaking that per-call flexibility for every later reader (second
|
|
467
|
+
independent Finding Provenance PRD audit). The accepted mitigation is a
|
|
468
|
+
permissions floor, not encryption: every `cacheSet` chmods the entry file to
|
|
469
|
+
`0600` and the `provenance-cache/` directory to `0700` (same posture as
|
|
470
|
+
`integrity.js`'s per-install HMAC key). This defeats other local users/processes
|
|
471
|
+
reading the cache; it does not defeat root or the same OS user. See
|
|
472
|
+
`cache.js`'s own header for the full tradeoff writeup, including why
|
|
473
|
+
encryption-at-rest was considered and deferred.
|
|
474
|
+
|
|
352
475
|
## Gotchas
|
|
353
476
|
|
|
354
477
|
- The seed `calibration-seed.json` is small (n < 30 for several families). Don't treat it as a held-out set — that's `holdout-eval.js`'s job, against an externally-supplied JSONL.
|
|
@@ -113,12 +113,28 @@ export function aggregateCorpus(detail) {
|
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
115
|
* Build the machine-readable scorecard model. `inputs`:
|
|
116
|
-
* provenance { engineVersion, bundleSha256, commit, nodeVersion, generatedAt
|
|
116
|
+
* provenance { engineVersion, bundleSha256, commit, nodeVersion, generatedAt,
|
|
117
|
+
* corpusVersion?, scope? } — the last two are FR-901's
|
|
118
|
+
* "corpus version" and "scope" fields; optional so a
|
|
119
|
+
* caller with no corpus baseline to hash still gets a
|
|
120
|
+
* valid scorecard rather than a thrown error.
|
|
117
121
|
* corpusDetail per-entry records from a corpus run performed THIS run
|
|
118
122
|
* selfScan { targets: {name:{total,byFile}}, polyglot: {total,byLanguage} }
|
|
119
123
|
* — measured THIS run
|
|
120
124
|
* committed { corpusBaseline, proofCorpus } — read from committed files,
|
|
121
125
|
* labelled as such in the output, never used to derive a rate
|
|
126
|
+
* scan optional — a scan-shaped object (`{findings, secrets,
|
|
127
|
+
* supplyChain}`, trimmed to just those arrays) from a run over
|
|
128
|
+
* a full (non-shallow) Git clone, used ONLY to compute
|
|
129
|
+
* provenanceCoverage below. `scripts/scorecard.mjs` passes
|
|
130
|
+
* `selfScan.provenanceScan` — the self-scan harness
|
|
131
|
+
* (bench/self-scan/measure.mjs) already runs a real
|
|
132
|
+
* `runScan()` over this project's own full git clone with
|
|
133
|
+
* provenance resolution on by default, so this reuses that
|
|
134
|
+
* run's already-computed `findingProvenance` rather than
|
|
135
|
+
* performing a second scan. Still optional: a caller with no
|
|
136
|
+
* such scan renders "not measured this run" rather than a
|
|
137
|
+
* fabricated rate. See PRD Success Metrics.
|
|
122
138
|
*/
|
|
123
139
|
export function buildScorecard(inputs) {
|
|
124
140
|
const corpus = aggregateCorpus(inputs.corpusDetail);
|
|
@@ -159,6 +175,14 @@ export function buildScorecard(inputs) {
|
|
|
159
175
|
byTier: corpus.byTier,
|
|
160
176
|
},
|
|
161
177
|
selfScan: { measuredThisRun: true, targets, polyglot: selfScan.polyglot || { total: 0, byLanguage: {} } },
|
|
178
|
+
// PRD Success Metrics: "Provenance coverage >=95% complete or uncommitted
|
|
179
|
+
// for P0-supported findings in full Git clones." `inputs.scan` is
|
|
180
|
+
// optional (see the JSDoc above) — absent when no caller yet supplies a
|
|
181
|
+
// real scan, in which case this reports "not measured" rather than a
|
|
182
|
+
// fabricated 0/0.
|
|
183
|
+
provenanceCoverage: inputs.scan
|
|
184
|
+
? { measuredThisRun: true, ...computeProvenanceCoverage(inputs.scan) }
|
|
185
|
+
: { measuredThisRun: false },
|
|
162
186
|
taintRecall: (() => {
|
|
163
187
|
const lr = inputs.layerRecall;
|
|
164
188
|
if (!lr) {
|
|
@@ -190,6 +214,38 @@ export function buildScorecard(inputs) {
|
|
|
190
214
|
overall: committed.independent.overall || null,
|
|
191
215
|
wide: committed.independent.wide || null,
|
|
192
216
|
byLanguage: committed.independent.byLanguage || null,
|
|
217
|
+
// FR-904: "rule authors cannot optimize against the full scored
|
|
218
|
+
// population" — bench/independent/runner.mjs's T0.7 already
|
|
219
|
+
// computes a deterministic (id-hashed) held-out slice, scored
|
|
220
|
+
// separately and never tuned against, but the published scorecard
|
|
221
|
+
// used to report only the merged `overall` figures, so the one
|
|
222
|
+
// number this requirement is actually about never reached a
|
|
223
|
+
// release artifact anyone reads. Passed through unmodified —
|
|
224
|
+
// absent (null) on any committed RESULT.json predating T0.7.
|
|
225
|
+
heldOut: committed.independent.heldOut || null,
|
|
226
|
+
development: committed.independent.development || null,
|
|
227
|
+
}
|
|
228
|
+
: null,
|
|
229
|
+
// FR-905: "publish false-positive adjudication and coverage
|
|
230
|
+
// methodology." Three of the four named categories were already
|
|
231
|
+
// published (unsupported cases via population.unscored above; the
|
|
232
|
+
// qualitative FP/unlabeled-output methodology in
|
|
233
|
+
// bench/independent/README.md's "Honest limits" section) — this is
|
|
234
|
+
// the fourth: WHY a false negative is a false negative, broken down
|
|
235
|
+
// by mechanism (bench/independent/why-missed.mjs). Read from a
|
|
236
|
+
// committed file for the same reason `independent` above is — the
|
|
237
|
+
// full population's diagnostic run is measured in minutes, far too
|
|
238
|
+
// long to sit inside `npm run scorecard`. Absent (null) until
|
|
239
|
+
// why-missed.mjs has been run at least once and its summary
|
|
240
|
+
// committed.
|
|
241
|
+
whyMissed: committed.whyMissed
|
|
242
|
+
? {
|
|
243
|
+
source: 'bench/independent/why-missed-summary.json',
|
|
244
|
+
measuredAt: committed.whyMissed.measuredAt || null,
|
|
245
|
+
scope: committed.whyMissed.scope || null,
|
|
246
|
+
total: committed.whyMissed.total ?? null,
|
|
247
|
+
skipped: committed.whyMissed.skipped ?? null,
|
|
248
|
+
byBucket: committed.whyMissed.byBucket || null,
|
|
193
249
|
}
|
|
194
250
|
: null,
|
|
195
251
|
proofCorpus: proof
|
|
@@ -213,6 +269,28 @@ export function buildScorecard(inputs) {
|
|
|
213
269
|
};
|
|
214
270
|
}
|
|
215
271
|
|
|
272
|
+
// PRD Success Metrics: "Provenance coverage >=95% complete or uncommitted
|
|
273
|
+
// for P0-supported findings in full Git clones." P0-supported scope per
|
|
274
|
+
// the PRD's own Release Scope table: code (SAST), secrets, IaC/config,
|
|
275
|
+
// direct dependency findings. Secrets now get real origin resolution
|
|
276
|
+
// (Task 11 -- `engine.js` calls `annotateGitProvenance` on `scan.secrets`
|
|
277
|
+
// with a real per-pattern-backfilled stableId, the same as SAST findings),
|
|
278
|
+
// so this metric no longer has a structural reason to read lower for the
|
|
279
|
+
// secrets share of the denominator than for any other P0-scoped channel.
|
|
280
|
+
export function computeProvenanceCoverage(scan) {
|
|
281
|
+
const p0Findings = [
|
|
282
|
+
...(scan.findings || []),
|
|
283
|
+
...(scan.secrets || []),
|
|
284
|
+
...(scan.supplyChain || []).filter((s) => s.type === 'vulnerable_dep' && s.isDirect),
|
|
285
|
+
];
|
|
286
|
+
const d = p0Findings.length;
|
|
287
|
+
const n = p0Findings.filter((f) => {
|
|
288
|
+
const status = f.findingProvenance?.status;
|
|
289
|
+
return status === 'complete' || status === 'uncommitted';
|
|
290
|
+
}).length;
|
|
291
|
+
return { n, d };
|
|
292
|
+
}
|
|
293
|
+
|
|
216
294
|
function rateRow(r) {
|
|
217
295
|
return `| ${r.key} | ${r.entries} | ${formatRate(r.detection.n, r.detection.d)} | ${formatRate(r.silence.n, r.silence.d)} |`;
|
|
218
296
|
}
|
|
@@ -241,6 +319,12 @@ export function renderScorecardMarkdown(m) {
|
|
|
241
319
|
}
|
|
242
320
|
L.push(`| Node | ${p.nodeVersion || 'unknown'} |`);
|
|
243
321
|
L.push(`| Corpus entries | ${c.totalEntries} (${c.scoredEntries} scored) |`);
|
|
322
|
+
// FR-901: "Published results identify engine version, corpus version,
|
|
323
|
+
// commit, scope, and date" — corpusVersion (a content hash, independent
|
|
324
|
+
// of the engine's own commit) and scope (what was actually measured)
|
|
325
|
+
// close the two named fields the rows above didn't already cover.
|
|
326
|
+
if (p.corpusVersion) L.push(`| Corpus version | \`${p.corpusVersion}\` |`);
|
|
327
|
+
if (p.scope) L.push(`| Scope | ${p.scope} |`);
|
|
244
328
|
L.push(`| ${TIMESTAMP_MARKER} | ${p.generatedAt || 'unknown'} |`);
|
|
245
329
|
L.push('');
|
|
246
330
|
L.push('## What these numbers are, and what they are not');
|
|
@@ -428,6 +512,24 @@ export function renderScorecardMarkdown(m) {
|
|
|
428
512
|
}
|
|
429
513
|
L.push('Per-file counts are in `docs/scorecard.json`.');
|
|
430
514
|
L.push('');
|
|
515
|
+
if (m.provenanceCoverage && m.provenanceCoverage.measuredThisRun) {
|
|
516
|
+
L.push('## Provenance coverage');
|
|
517
|
+
L.push('');
|
|
518
|
+
L.push('PRD Success Metric: **>=95% of P0-scoped findings (SAST + secrets + direct**');
|
|
519
|
+
L.push('**dependency findings) resolve to `complete` or `uncommitted` git provenance**');
|
|
520
|
+
L.push('in a full (non-shallow) clone. Transitive dependency findings are excluded —');
|
|
521
|
+
L.push('the PRD\'s Release Scope table names direct dependency findings only.');
|
|
522
|
+
L.push('');
|
|
523
|
+
L.push('| P0-scoped findings — complete/uncommitted provenance |');
|
|
524
|
+
L.push('| --- |');
|
|
525
|
+
L.push(`| ${formatRate(m.provenanceCoverage.n, m.provenanceCoverage.d)} |`);
|
|
526
|
+
L.push('');
|
|
527
|
+
L.push('Secrets, SAST, and direct-dependency findings all resolve through the same');
|
|
528
|
+
L.push('git-origin resolution pipeline, so a gap in this rate reflects the clone');
|
|
529
|
+
L.push('itself (shallow history, uncommitted lines the pipeline could not blame) —');
|
|
530
|
+
L.push('not a channel this measurement structurally cannot yet cover.');
|
|
531
|
+
L.push('');
|
|
532
|
+
}
|
|
431
533
|
// PRD F12.6 — the honest scorecard publishes the LIMITS too, not only the
|
|
432
534
|
// rates. Three claims this project makes are only meaningful with their
|
|
433
535
|
// caveat attached, and each caveat was invisible before this section:
|
|
@@ -532,6 +634,57 @@ export function renderScorecardMarkdown(m) {
|
|
|
532
634
|
L.push('in this document**, and publishing it is the point of the exercise. The figure');
|
|
533
635
|
L.push('went DOWN when the benchmark was corrected, and is published that way.');
|
|
534
636
|
L.push('');
|
|
637
|
+
// FR-904 (assurance-hardening PRD): "rule authors cannot optimize
|
|
638
|
+
// against the full scored population." T0.7's held-out slice is a
|
|
639
|
+
// no-op section (silently omitted) on a RESULT.json predating it —
|
|
640
|
+
// never a fabricated 0/0 row pretending to be data.
|
|
641
|
+
if (ind.heldOut && ind.development) {
|
|
642
|
+
L.push('### Held-out slice — never tuned against');
|
|
643
|
+
L.push('');
|
|
644
|
+
L.push('`bench/independent/runner.mjs` splits the population by a deterministic hash of');
|
|
645
|
+
L.push('each entry\'s id (T0.7) — a fixed 20% held-out slice, stable across runs and');
|
|
646
|
+
L.push('population growth, that detector development never sees scored results for.');
|
|
647
|
+
L.push('This is the number that answers whether the figures above reflect genuine');
|
|
648
|
+
L.push('accuracy or tuning against the population being measured.');
|
|
649
|
+
L.push('');
|
|
650
|
+
L.push('| | Held-out (never tuned against) | Development |');
|
|
651
|
+
L.push('| --- | --- | --- |');
|
|
652
|
+
L.push(`| Entries | ${ind.heldOut.entries} | ${ind.development.entries} |`);
|
|
653
|
+
L.push(`| Precision | ${formatRate(ind.heldOut.localized?.precision?.n, ind.heldOut.localized?.precision?.d)} | ${formatRate(ind.development.localized?.precision?.n, ind.development.localized?.precision?.d)} |`);
|
|
654
|
+
L.push(`| Recall | ${formatRate(ind.heldOut.localized?.recall?.n, ind.heldOut.localized?.recall?.d)} | ${formatRate(ind.development.localized?.recall?.n, ind.development.localized?.recall?.d)} |`);
|
|
655
|
+
const heldF1 = ind.heldOut.localized?.f1;
|
|
656
|
+
const devF1 = ind.development.localized?.f1;
|
|
657
|
+
L.push(`| F1 | ${heldF1 === null || heldF1 === undefined ? 'n/a' : heldF1.toFixed(3)} | ${devF1 === null || devF1 === undefined ? 'n/a' : devF1.toFixed(3)} |`);
|
|
658
|
+
L.push('');
|
|
659
|
+
}
|
|
660
|
+
// FR-905: the 4th named category ("missed findings" methodology) —
|
|
661
|
+
// WHY a false negative is a false negative, broken down by mechanism.
|
|
662
|
+
// Omitted entirely (not a fabricated zero row) until why-missed.mjs has
|
|
663
|
+
// been run and its summary committed.
|
|
664
|
+
const wm = m.committedInputs.whyMissed;
|
|
665
|
+
if (wm) {
|
|
666
|
+
L.push('### Missed findings — why, not just how many');
|
|
667
|
+
L.push('');
|
|
668
|
+
L.push(`**Measured ${wm.measuredAt}** (*committed artifact*, \`${wm.source}\`) — ` +
|
|
669
|
+
`${wm.total} false negative(s) diagnosed${wm.skipped ? `, ${wm.skipped} skipped (not fetched)` : ''}.`);
|
|
670
|
+
L.push('');
|
|
671
|
+
L.push('Each is classified into exactly one mechanism: does something fire and get');
|
|
672
|
+
L.push('suppressed (by an ignore pragma, a sanitizer, a custom rule, or the');
|
|
673
|
+
L.push('guard-recognition window), does a finding land on the wrong file or CWE, or');
|
|
674
|
+
L.push('does nothing fire at all. This is the difference the raw recall number above');
|
|
675
|
+
L.push('cannot show by itself — "this shape does not occur in these real advisories"');
|
|
676
|
+
L.push('and "a real detection was masked downstream" look identical as one number and');
|
|
677
|
+
L.push('very different once broken down this way.');
|
|
678
|
+
L.push('');
|
|
679
|
+
if (wm.byBucket && Object.keys(wm.byBucket).length) {
|
|
680
|
+
L.push('| Mechanism | Count |');
|
|
681
|
+
L.push('| --- | --- |');
|
|
682
|
+
for (const [bucket, count] of Object.entries(wm.byBucket).sort((a, b) => b[1] - a[1])) {
|
|
683
|
+
L.push(`| ${bucket} | ${count} |`);
|
|
684
|
+
}
|
|
685
|
+
L.push('');
|
|
686
|
+
}
|
|
687
|
+
}
|
|
535
688
|
}
|
|
536
689
|
L.push('## Committed artifacts referenced (not re-run by this command)');
|
|
537
690
|
L.push('');
|
|
@@ -565,6 +718,8 @@ export function renderScorecardMarkdown(m) {
|
|
|
565
718
|
L.push('| Corpus drift gate | `npm run bench:cve-replay:check` |');
|
|
566
719
|
L.push('| Self-scan counts | `node bench/self-scan/measure.mjs --json` |');
|
|
567
720
|
L.push('| Self-scan drift gate | `npm run bench:self-scan:check` |');
|
|
721
|
+
L.push('| Independent population (read, not re-run — ~32 minutes) | `npm run bench:independent` |');
|
|
722
|
+
L.push('| Missed-findings mechanism breakdown (read, not re-run) | `npm run bench:independent:why-missed -- --all` |');
|
|
568
723
|
L.push('| This whole document | `npm run scorecard` |');
|
|
569
724
|
L.push('');
|
|
570
725
|
L.push('Running `npm run scorecard` twice on an unchanged tree produces an');
|