@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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Finding schema (assurance-hardening PRD, Milestone 0, FR-105).
|
|
2
|
+
//
|
|
3
|
+
// The PRD's §10.1 envelope (schemaVersion/stableId/producerId/... in ten
|
|
4
|
+
// named groups) describes an IDEAL shape. This engine's actual canonical
|
|
5
|
+
// shape already exists — it is report/index.js's `normalizeFindings()`,
|
|
6
|
+
// which every JSON/SARIF/HTML/CSV/JUnit/OSCAL output format calls (verified
|
|
7
|
+
// by grepping every call site in report/index.js, oscal.js, and
|
|
8
|
+
// bin/agentic-security.js before writing this module — see
|
|
9
|
+
// docs/implementation/assurance-hardening-decisions.md D-0003's rule: read
|
|
10
|
+
// the existing mechanism before building a parallel one). `normalizeFindings`
|
|
11
|
+
// already enforces an explicit allowlist with an honest `null` default for
|
|
12
|
+
// every optional field, which is most of what FR-105 asks for; it was simply
|
|
13
|
+
// never versioned or independently callable as "does this finding satisfy
|
|
14
|
+
// the contract".
|
|
15
|
+
//
|
|
16
|
+
// This module does two things, both additive (Milestone 0 scope — no gate
|
|
17
|
+
// wiring, no new required fields, nothing gets rejected):
|
|
18
|
+
// 1. Names FINDING_SCHEMA_VERSION so future breaking changes to the
|
|
19
|
+
// normalizeFindings shape have something to bump (FR-105 acceptance:
|
|
20
|
+
// "select a supported output contract during migration" per §12.1's
|
|
21
|
+
// `--schema-version` flag, not yet wired to a CLI flag in this cycle).
|
|
22
|
+
// 2. Provides describeFindingCompleteness(), a pure, non-throwing function
|
|
23
|
+
// that reports which of the CURRENT canonical fields a given finding
|
|
24
|
+
// actually has non-null values for — observability, not enforcement.
|
|
25
|
+
// Full enforcement (every producer's output must pass validation before
|
|
26
|
+
// it can reach a reporter) is Milestone 1's FR-103/FR-104, which
|
|
27
|
+
// requires producer isolation (FR-201) to exist first so a rejected
|
|
28
|
+
// finding can be attributed to the producer that emitted it.
|
|
29
|
+
//
|
|
30
|
+
// One MCP correctness bug was found and fixed alongside this (not scope
|
|
31
|
+
// creep — it's the exact FR-105 acceptance criterion "...and MCP outputs
|
|
32
|
+
// derive from the same validated object"): mcp/tools.js's scan_diff hand-
|
|
33
|
+
// rolled a 3-of-4-channel finding merge instead of calling
|
|
34
|
+
// normalizeFindings(), which both risked field-mapping drift and silently
|
|
35
|
+
// excluded the supplyChain channel. Fixed in mcp/tools.js; see that file's
|
|
36
|
+
// comment for the residual limitation (scan_diff still never populates
|
|
37
|
+
// depFileContents, so supply-chain detection does not reach it today for an
|
|
38
|
+
// unrelated, deeper reason this schema module does not address).
|
|
39
|
+
|
|
40
|
+
export const FINDING_SCHEMA_VERSION = 1;
|
|
41
|
+
|
|
42
|
+
// Field groups mirror the PRD's §10.1 structure, using the CURRENT field
|
|
43
|
+
// names normalizeFindings() actually produces (see decision D-0003: extend
|
|
44
|
+
// what exists, don't invent a parallel vocabulary no real finding uses).
|
|
45
|
+
// `required` fields are ones every finding channel (sast/secret/logic/sca)
|
|
46
|
+
// sets to a real (non-null) value today; `optional` fields are legitimately
|
|
47
|
+
// null on many findings (e.g. a finding no annotator has enriched yet).
|
|
48
|
+
export const FINDING_FIELD_GROUPS = {
|
|
49
|
+
identity: { required: ['id', 'kind', 'vuln'], optional: ['stableId'] },
|
|
50
|
+
location: { required: ['file', 'line'], optional: ['snippet'] },
|
|
51
|
+
classification: { required: ['severity'], optional: ['cwe', 'owaspLlm', 'family', 'parser', 'tags', 'description'] },
|
|
52
|
+
confidence: { required: [], optional: ['confidence', 'confidenceTier', 'calibrated_confidence', 'calibration_reason'] },
|
|
53
|
+
evidence: { required: [], optional: ['proof', 'falsification', 'verification', 'chain', 'sources', 'corroboration'] },
|
|
54
|
+
privacy: { required: [], optional: ['dataClasses'] },
|
|
55
|
+
compliance: { required: [], optional: [] }, // no per-finding controlRefs today — compliance mapping is scan-level (FR-501/E5)
|
|
56
|
+
remediation: { required: [], optional: ['fix', 'remediation'] },
|
|
57
|
+
risk: { required: [], optional: ['exploitability', 'exploitabilityTier', 'compositeRisk', 'compositeRiskTier', 'crownJewelScore', 'riskDollars'] },
|
|
58
|
+
lifecycle: { required: [], optional: ['triage', 'quarantined', 'unreachable'] },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reports, for one normalized finding (i.e. an item from
|
|
63
|
+
* report/index.js#normalizeFindings' output — this function does NOT accept
|
|
64
|
+
* a raw pre-normalization finding), which canonical groups have their
|
|
65
|
+
* required fields present and which optional fields are populated.
|
|
66
|
+
*
|
|
67
|
+
* Pure, non-throwing, additive-only: does not mutate the finding, does not
|
|
68
|
+
* gate anything, and is not called from engine.js's default pipeline. It
|
|
69
|
+
* exists to be run over a real scan's output for observability (a future
|
|
70
|
+
* `--explain-health`-style report, per PRD §12.1) — see this module's
|
|
71
|
+
* header comment for what is deliberately NOT yet true of it.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} finding - one entry from normalizeFindings(scan)
|
|
74
|
+
* @returns {{schemaVersion:number, missingRequiredFields:string[], populatedOptionalFields:string[], missingOptionalFields:string[], isComplete:boolean}}
|
|
75
|
+
*/
|
|
76
|
+
export function describeFindingCompleteness(finding) {
|
|
77
|
+
const f = finding && typeof finding === 'object' ? finding : {};
|
|
78
|
+
const missingRequiredFields = [];
|
|
79
|
+
const populatedOptionalFields = [];
|
|
80
|
+
const missingOptionalFields = [];
|
|
81
|
+
|
|
82
|
+
const isPopulated = (v) => v !== null && v !== undefined && !(Array.isArray(v) && v.length === 0);
|
|
83
|
+
|
|
84
|
+
for (const group of Object.values(FINDING_FIELD_GROUPS)) {
|
|
85
|
+
for (const field of group.required) {
|
|
86
|
+
if (!isPopulated(f[field])) missingRequiredFields.push(field);
|
|
87
|
+
}
|
|
88
|
+
for (const field of group.optional) {
|
|
89
|
+
if (isPopulated(f[field])) populatedOptionalFields.push(field);
|
|
90
|
+
else missingOptionalFields.push(field);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
schemaVersion: FINDING_SCHEMA_VERSION,
|
|
96
|
+
missingRequiredFields,
|
|
97
|
+
populatedOptionalFields,
|
|
98
|
+
missingOptionalFields,
|
|
99
|
+
isComplete: missingRequiredFields.length === 0,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Legacy field compatibility adapter (assurance-hardening PRD, Milestone 1,
|
|
2
|
+
// FR-108: "Publish a compatibility adapter for legacy finding consumers.
|
|
3
|
+
// Existing integrations receive a deprecation warning and equivalent legacy
|
|
4
|
+
// fields for one documented transition period.")
|
|
5
|
+
//
|
|
6
|
+
// This codebase's own stated convention (repeated three times across
|
|
7
|
+
// CHANGELOG.md: "No breaking changes. All new capabilities are additive")
|
|
8
|
+
// means a finding field should almost never be renamed or removed out from
|
|
9
|
+
// under an existing consumer. But it can happen for a good reason — this
|
|
10
|
+
// same session's FR-804 renamed `riskDollars.confidenceFloor` to
|
|
11
|
+
// `confidenceWeight` because the old name was actively misleading (the
|
|
12
|
+
// field stopped being a floor once the bug it named was fixed) — and when
|
|
13
|
+
// it does, a consumer reading the old field name should not silently start
|
|
14
|
+
// getting `undefined`. That rename is the concrete, real case this adapter
|
|
15
|
+
// exists for, not a hypothetical one.
|
|
16
|
+
//
|
|
17
|
+
// LEGACY_FIELD_ALIASES names every known rename. applyLegacyCompat(finding)
|
|
18
|
+
// backfills the OLD dotted path from the NEW one's current value whenever
|
|
19
|
+
// the new value is present and nothing already occupies the old path — an
|
|
20
|
+
// existing integration reading the deprecated name keeps working, unchanged,
|
|
21
|
+
// for the documented transition period. legacyFieldDeprecationNotice()
|
|
22
|
+
// turns that into the single, honest, report-level notice FR-108 asks for,
|
|
23
|
+
// rather than silently reintroducing the old shape forever with no signal
|
|
24
|
+
// that it is scheduled for removal.
|
|
25
|
+
|
|
26
|
+
export const LEGACY_FIELD_ALIASES = Object.freeze([
|
|
27
|
+
Object.freeze({
|
|
28
|
+
oldPath: 'riskDollars.confidenceFloor',
|
|
29
|
+
newPath: 'riskDollars.confidenceWeight',
|
|
30
|
+
deprecatedInVersion: '0.143.0', // FR-804, same session as this adapter
|
|
31
|
+
sunsetAfter: '2027-02-24', // ~6 months from this adapter's introduction
|
|
32
|
+
reason: "renamed because the field stopped being a floor — Math.max(0.4, f.confidence || 0.8)'s unconditional floor and 0-as-falsy inflation were removed as part of FR-804's fix",
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function _getPath(obj, dottedPath) {
|
|
37
|
+
return dottedPath.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _setPath(obj, dottedPath, value) {
|
|
41
|
+
const parts = dottedPath.split('.');
|
|
42
|
+
let o = obj;
|
|
43
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
44
|
+
// Only backfill onto a parent object that already exists — if
|
|
45
|
+
// riskDollars itself is null/absent, there is nothing to attach a
|
|
46
|
+
// legacy alias to, and creating a fake `{confidenceFloor: ...}` shell
|
|
47
|
+
// would be actively misleading (it would look like a real annotation
|
|
48
|
+
// that never ran).
|
|
49
|
+
if (o[parts[i]] == null || typeof o[parts[i]] !== 'object') return false;
|
|
50
|
+
o = o[parts[i]];
|
|
51
|
+
}
|
|
52
|
+
o[parts[parts.length - 1]] = value;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Backfill deprecated field names on ONE finding from their current
|
|
58
|
+
* replacements. Mutates the finding in place (matches every other
|
|
59
|
+
* annotator's convention in this codebase). Additive only — never
|
|
60
|
+
* overwrites a value already present at the old path, so a caller that set
|
|
61
|
+
* the legacy field explicitly (e.g. a hand-built test fixture) is never
|
|
62
|
+
* clobbered.
|
|
63
|
+
*
|
|
64
|
+
* @returns {string[]} which legacy field paths were actually backfilled —
|
|
65
|
+
* empty for the common, eventual steady state where nothing legacy fired.
|
|
66
|
+
*/
|
|
67
|
+
export function applyLegacyCompat(finding) {
|
|
68
|
+
if (!finding || typeof finding !== 'object') return [];
|
|
69
|
+
const applied = [];
|
|
70
|
+
for (const alias of LEGACY_FIELD_ALIASES) {
|
|
71
|
+
const newValue = _getPath(finding, alias.newPath);
|
|
72
|
+
if (newValue === undefined) continue; // nothing to backfill from
|
|
73
|
+
if (_getPath(finding, alias.oldPath) !== undefined) continue; // already set — don't clobber
|
|
74
|
+
if (_setPath(finding, alias.oldPath, newValue)) applied.push(alias.oldPath);
|
|
75
|
+
}
|
|
76
|
+
if (applied.length) finding._legacyFields = applied;
|
|
77
|
+
return applied;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A single, report-level deprecation notice summarizing every legacy alias
|
|
82
|
+
* actually used across a finding set. Returns null when none fired.
|
|
83
|
+
*/
|
|
84
|
+
export function legacyFieldDeprecationNotice(findings) {
|
|
85
|
+
if (!Array.isArray(findings)) return null;
|
|
86
|
+
const used = new Set();
|
|
87
|
+
for (const f of findings) {
|
|
88
|
+
if (Array.isArray(f?._legacyFields)) for (const p of f._legacyFields) used.add(p);
|
|
89
|
+
}
|
|
90
|
+
if (used.size === 0) return null;
|
|
91
|
+
const fields = [...used].sort().map(oldPath => {
|
|
92
|
+
const alias = LEGACY_FIELD_ALIASES.find(a => a.oldPath === oldPath);
|
|
93
|
+
return alias
|
|
94
|
+
? { oldPath, newPath: alias.newPath, deprecatedInVersion: alias.deprecatedInVersion, sunsetAfter: alias.sunsetAfter, reason: alias.reason }
|
|
95
|
+
: { oldPath, newPath: null, deprecatedInVersion: null, sunsetAfter: null, reason: null };
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
message: 'This report includes deprecated legacy field name(s), backfilled from their current replacements for backward compatibility. Update integrations to read the new field name(s) before the documented sunset date.',
|
|
99
|
+
fields,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -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,8 @@ 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
|
+
|
|
352
354
|
## Gotchas
|
|
353
355
|
|
|
354
356
|
- 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,7 +113,11 @@ 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
|
|
@@ -190,6 +194,38 @@ export function buildScorecard(inputs) {
|
|
|
190
194
|
overall: committed.independent.overall || null,
|
|
191
195
|
wide: committed.independent.wide || null,
|
|
192
196
|
byLanguage: committed.independent.byLanguage || null,
|
|
197
|
+
// FR-904: "rule authors cannot optimize against the full scored
|
|
198
|
+
// population" — bench/independent/runner.mjs's T0.7 already
|
|
199
|
+
// computes a deterministic (id-hashed) held-out slice, scored
|
|
200
|
+
// separately and never tuned against, but the published scorecard
|
|
201
|
+
// used to report only the merged `overall` figures, so the one
|
|
202
|
+
// number this requirement is actually about never reached a
|
|
203
|
+
// release artifact anyone reads. Passed through unmodified —
|
|
204
|
+
// absent (null) on any committed RESULT.json predating T0.7.
|
|
205
|
+
heldOut: committed.independent.heldOut || null,
|
|
206
|
+
development: committed.independent.development || null,
|
|
207
|
+
}
|
|
208
|
+
: null,
|
|
209
|
+
// FR-905: "publish false-positive adjudication and coverage
|
|
210
|
+
// methodology." Three of the four named categories were already
|
|
211
|
+
// published (unsupported cases via population.unscored above; the
|
|
212
|
+
// qualitative FP/unlabeled-output methodology in
|
|
213
|
+
// bench/independent/README.md's "Honest limits" section) — this is
|
|
214
|
+
// the fourth: WHY a false negative is a false negative, broken down
|
|
215
|
+
// by mechanism (bench/independent/why-missed.mjs). Read from a
|
|
216
|
+
// committed file for the same reason `independent` above is — the
|
|
217
|
+
// full population's diagnostic run is measured in minutes, far too
|
|
218
|
+
// long to sit inside `npm run scorecard`. Absent (null) until
|
|
219
|
+
// why-missed.mjs has been run at least once and its summary
|
|
220
|
+
// committed.
|
|
221
|
+
whyMissed: committed.whyMissed
|
|
222
|
+
? {
|
|
223
|
+
source: 'bench/independent/why-missed-summary.json',
|
|
224
|
+
measuredAt: committed.whyMissed.measuredAt || null,
|
|
225
|
+
scope: committed.whyMissed.scope || null,
|
|
226
|
+
total: committed.whyMissed.total ?? null,
|
|
227
|
+
skipped: committed.whyMissed.skipped ?? null,
|
|
228
|
+
byBucket: committed.whyMissed.byBucket || null,
|
|
193
229
|
}
|
|
194
230
|
: null,
|
|
195
231
|
proofCorpus: proof
|
|
@@ -241,6 +277,12 @@ export function renderScorecardMarkdown(m) {
|
|
|
241
277
|
}
|
|
242
278
|
L.push(`| Node | ${p.nodeVersion || 'unknown'} |`);
|
|
243
279
|
L.push(`| Corpus entries | ${c.totalEntries} (${c.scoredEntries} scored) |`);
|
|
280
|
+
// FR-901: "Published results identify engine version, corpus version,
|
|
281
|
+
// commit, scope, and date" — corpusVersion (a content hash, independent
|
|
282
|
+
// of the engine's own commit) and scope (what was actually measured)
|
|
283
|
+
// close the two named fields the rows above didn't already cover.
|
|
284
|
+
if (p.corpusVersion) L.push(`| Corpus version | \`${p.corpusVersion}\` |`);
|
|
285
|
+
if (p.scope) L.push(`| Scope | ${p.scope} |`);
|
|
244
286
|
L.push(`| ${TIMESTAMP_MARKER} | ${p.generatedAt || 'unknown'} |`);
|
|
245
287
|
L.push('');
|
|
246
288
|
L.push('## What these numbers are, and what they are not');
|
|
@@ -532,6 +574,57 @@ export function renderScorecardMarkdown(m) {
|
|
|
532
574
|
L.push('in this document**, and publishing it is the point of the exercise. The figure');
|
|
533
575
|
L.push('went DOWN when the benchmark was corrected, and is published that way.');
|
|
534
576
|
L.push('');
|
|
577
|
+
// FR-904 (assurance-hardening PRD): "rule authors cannot optimize
|
|
578
|
+
// against the full scored population." T0.7's held-out slice is a
|
|
579
|
+
// no-op section (silently omitted) on a RESULT.json predating it —
|
|
580
|
+
// never a fabricated 0/0 row pretending to be data.
|
|
581
|
+
if (ind.heldOut && ind.development) {
|
|
582
|
+
L.push('### Held-out slice — never tuned against');
|
|
583
|
+
L.push('');
|
|
584
|
+
L.push('`bench/independent/runner.mjs` splits the population by a deterministic hash of');
|
|
585
|
+
L.push('each entry\'s id (T0.7) — a fixed 20% held-out slice, stable across runs and');
|
|
586
|
+
L.push('population growth, that detector development never sees scored results for.');
|
|
587
|
+
L.push('This is the number that answers whether the figures above reflect genuine');
|
|
588
|
+
L.push('accuracy or tuning against the population being measured.');
|
|
589
|
+
L.push('');
|
|
590
|
+
L.push('| | Held-out (never tuned against) | Development |');
|
|
591
|
+
L.push('| --- | --- | --- |');
|
|
592
|
+
L.push(`| Entries | ${ind.heldOut.entries} | ${ind.development.entries} |`);
|
|
593
|
+
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)} |`);
|
|
594
|
+
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)} |`);
|
|
595
|
+
const heldF1 = ind.heldOut.localized?.f1;
|
|
596
|
+
const devF1 = ind.development.localized?.f1;
|
|
597
|
+
L.push(`| F1 | ${heldF1 === null || heldF1 === undefined ? 'n/a' : heldF1.toFixed(3)} | ${devF1 === null || devF1 === undefined ? 'n/a' : devF1.toFixed(3)} |`);
|
|
598
|
+
L.push('');
|
|
599
|
+
}
|
|
600
|
+
// FR-905: the 4th named category ("missed findings" methodology) —
|
|
601
|
+
// WHY a false negative is a false negative, broken down by mechanism.
|
|
602
|
+
// Omitted entirely (not a fabricated zero row) until why-missed.mjs has
|
|
603
|
+
// been run and its summary committed.
|
|
604
|
+
const wm = m.committedInputs.whyMissed;
|
|
605
|
+
if (wm) {
|
|
606
|
+
L.push('### Missed findings — why, not just how many');
|
|
607
|
+
L.push('');
|
|
608
|
+
L.push(`**Measured ${wm.measuredAt}** (*committed artifact*, \`${wm.source}\`) — ` +
|
|
609
|
+
`${wm.total} false negative(s) diagnosed${wm.skipped ? `, ${wm.skipped} skipped (not fetched)` : ''}.`);
|
|
610
|
+
L.push('');
|
|
611
|
+
L.push('Each is classified into exactly one mechanism: does something fire and get');
|
|
612
|
+
L.push('suppressed (by an ignore pragma, a sanitizer, a custom rule, or the');
|
|
613
|
+
L.push('guard-recognition window), does a finding land on the wrong file or CWE, or');
|
|
614
|
+
L.push('does nothing fire at all. This is the difference the raw recall number above');
|
|
615
|
+
L.push('cannot show by itself — "this shape does not occur in these real advisories"');
|
|
616
|
+
L.push('and "a real detection was masked downstream" look identical as one number and');
|
|
617
|
+
L.push('very different once broken down this way.');
|
|
618
|
+
L.push('');
|
|
619
|
+
if (wm.byBucket && Object.keys(wm.byBucket).length) {
|
|
620
|
+
L.push('| Mechanism | Count |');
|
|
621
|
+
L.push('| --- | --- |');
|
|
622
|
+
for (const [bucket, count] of Object.entries(wm.byBucket).sort((a, b) => b[1] - a[1])) {
|
|
623
|
+
L.push(`| ${bucket} | ${count} |`);
|
|
624
|
+
}
|
|
625
|
+
L.push('');
|
|
626
|
+
}
|
|
627
|
+
}
|
|
535
628
|
}
|
|
536
629
|
L.push('## Committed artifacts referenced (not re-run by this command)');
|
|
537
630
|
L.push('');
|
|
@@ -565,6 +658,8 @@ export function renderScorecardMarkdown(m) {
|
|
|
565
658
|
L.push('| Corpus drift gate | `npm run bench:cve-replay:check` |');
|
|
566
659
|
L.push('| Self-scan counts | `node bench/self-scan/measure.mjs --json` |');
|
|
567
660
|
L.push('| Self-scan drift gate | `npm run bench:self-scan:check` |');
|
|
661
|
+
L.push('| Independent population (read, not re-run — ~32 minutes) | `npm run bench:independent` |');
|
|
662
|
+
L.push('| Missed-findings mechanism breakdown (read, not re-run) | `npm run bench:independent:why-missed -- --all` |');
|
|
568
663
|
L.push('| This whole document | `npm run scorecard` |');
|
|
569
664
|
L.push('');
|
|
570
665
|
L.push('Running `npm run scorecard` twice on an unchanged tree produces an');
|