@clear-capabilities/agentic-security-scanner 0.140.0 → 0.142.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 +283 -0
- package/dist/113.index.js +79 -3
- package/dist/178.index.js +1 -1
- package/dist/238.index.js +77 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +12 -0
- package/dist/526.index.js +79 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +34 -7
- package/dist/compliance-frameworks/eu-ai-act.json +65 -14
- package/dist/compliance-frameworks/gdpr.json +56 -12
- package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
- package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
- package/dist/compliance-frameworks/nist-csf-2.json +78 -16
- package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
- package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
- package/package.json +19 -5
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog.js +61 -0
- package/src/dataflow/engine.js +95 -0
- package/src/dataflow/sanitizer-gate.js +61 -0
- package/src/engine.js +353 -31
- package/src/mcp/tools.js +12 -0
- package/src/posture/accuracy-scorecard.js +57 -0
- package/src/posture/aibom.js +110 -1
- package/src/posture/auditor-walkthrough.js +56 -17
- package/src/posture/compliance-frameworks/ccpa.json +34 -7
- package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
- package/src/posture/compliance-frameworks/gdpr.json +56 -12
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
- package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
- package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
- package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
- package/src/posture/concurrency-checker.js +3 -3
- package/src/posture/coverage-strength.js +182 -0
- package/src/posture/epss.js +17 -1
- package/src/posture/family-registry.js +103 -0
- package/src/posture/family-resolve.js +47 -0
- package/src/posture/fix-coverage.js +113 -0
- package/src/posture/fix-metrics.js +76 -0
- package/src/posture/mcp-rug-pull.js +144 -0
- package/src/posture/poc-inprocess.js +217 -1
- package/src/posture/proof-coverage.js +162 -0
- package/src/posture/reachability-filter.js +44 -0
- package/src/posture/sbom.js +12 -3
- package/src/runScan.js +56 -5
- package/src/sast/CLAUDE.md +2 -2
- package/src/sast/claude-md-prompt-injection.js +47 -3
- package/src/sast/cloud-iam.js +23 -0
- package/src/sast/convention-deviation.js +66 -3
- package/src/sast/crypto-protocol.js +23 -0
- package/src/sast/dapp-frontend.js +20 -0
- package/src/sast/iac-cloud-templates.js +346 -0
- package/src/sast/k8s-admission.js +27 -0
- package/src/sast/ml-supply-chain.js +22 -0
- package/src/sast/ruby.js +132 -0
- package/src/sast/web3-advanced.js +26 -0
- package/src/sca/CLAUDE.md +21 -4
- package/src/sca/container.js +18 -1
- package/src/sca/dep-confusion.js +69 -3
|
@@ -4,66 +4,135 @@
|
|
|
4
4
|
"publisher": "OWASP Foundation",
|
|
5
5
|
"license": "Creative Commons Attribution-ShareAlike 4.0",
|
|
6
6
|
"url": "https://owasp.org/www-project-top-10-for-large-language-model-applications/",
|
|
7
|
+
"scope": "FULL TOP-10. All ten categories are present. Each category is a broad class rather than a single testable requirement, so a mapping indicates a detector exists for part of the class, not that the class is fully covered.",
|
|
8
|
+
"controlsDigest": "0a0351a9536790e4",
|
|
9
|
+
"controlCount": 10,
|
|
7
10
|
"controls": [
|
|
8
11
|
{
|
|
9
12
|
"id": "LLM01",
|
|
10
13
|
"summary": "Prompt Injection — controls preventing injected instructions from overriding the system prompt.",
|
|
11
|
-
"
|
|
12
|
-
"
|
|
14
|
+
"codeTestable": "yes",
|
|
15
|
+
"evidence": [
|
|
16
|
+
"Zero open findings in family prompt-injection / llm-app-security.",
|
|
17
|
+
"Prompt-template integrity check passing."
|
|
18
|
+
],
|
|
19
|
+
"mapsTo": [
|
|
20
|
+
"family:prompt-injection",
|
|
21
|
+
"family:llm-app-security",
|
|
22
|
+
"family:prompt-integrity"
|
|
23
|
+
]
|
|
13
24
|
},
|
|
14
25
|
{
|
|
15
26
|
"id": "LLM02",
|
|
16
27
|
"summary": "Insecure Output Handling — LLM output is not directly piped to dangerous sinks (eval/shell/SQL/HTML).",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
28
|
+
"codeTestable": "yes",
|
|
29
|
+
"evidence": [
|
|
30
|
+
"Zero open findings in subfamily llm-output-untrusted-sink."
|
|
31
|
+
],
|
|
32
|
+
"mapsTo": [
|
|
33
|
+
"family:llm-app-security:llm-output-untrusted-sink"
|
|
34
|
+
]
|
|
19
35
|
},
|
|
20
36
|
{
|
|
21
37
|
"id": "LLM03",
|
|
22
38
|
"summary": "Training Data Poisoning — provenance and integrity controls on training/fine-tuning data.",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
39
|
+
"codeTestable": "yes",
|
|
40
|
+
"evidence": [
|
|
41
|
+
"No streaming-dataset-url findings.",
|
|
42
|
+
"datasets.load_dataset uses pinned revisions."
|
|
43
|
+
],
|
|
44
|
+
"mapsTo": [
|
|
45
|
+
"family:streaming-dataset-url",
|
|
46
|
+
"family:hf-datasets-rce"
|
|
47
|
+
]
|
|
25
48
|
},
|
|
26
49
|
{
|
|
27
50
|
"id": "LLM04",
|
|
28
51
|
"summary": "Model Denial of Service — rate-limit / token-cap controls.",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
52
|
+
"codeTestable": "no",
|
|
53
|
+
"evidence": [
|
|
54
|
+
"LLM API calls in source carry an explicit max_tokens cap (bodyguard rule no-max-tokens)."
|
|
55
|
+
],
|
|
56
|
+
"mapsTo": [
|
|
57
|
+
"rule:no-max-tokens"
|
|
58
|
+
]
|
|
31
59
|
},
|
|
32
60
|
{
|
|
33
61
|
"id": "LLM05",
|
|
34
62
|
"summary": "Supply Chain — model & dataset supply-chain integrity.",
|
|
35
|
-
"
|
|
36
|
-
"
|
|
63
|
+
"codeTestable": "yes",
|
|
64
|
+
"evidence": [
|
|
65
|
+
"No mlflow-untrusted-uri / model-format / hf-endpoint-override findings.",
|
|
66
|
+
"Sigstore provenance verification configured if opt-in."
|
|
67
|
+
],
|
|
68
|
+
"mapsTo": [
|
|
69
|
+
"family:mlflow-untrusted-uri",
|
|
70
|
+
"family:model-format",
|
|
71
|
+
"family:hf-endpoint-override"
|
|
72
|
+
]
|
|
37
73
|
},
|
|
38
74
|
{
|
|
39
75
|
"id": "LLM06",
|
|
40
76
|
"summary": "Sensitive Information Disclosure — output filters prevent leakage.",
|
|
41
|
-
"
|
|
42
|
-
"
|
|
77
|
+
"codeTestable": "yes",
|
|
78
|
+
"evidence": [
|
|
79
|
+
"Zero findings in subfamily llm-credential-in-prompt.",
|
|
80
|
+
"Redact module covers prompt + response."
|
|
81
|
+
],
|
|
82
|
+
"mapsTo": [
|
|
83
|
+
"family:llm-app-security:llm-credential-in-prompt"
|
|
84
|
+
]
|
|
43
85
|
},
|
|
44
86
|
{
|
|
45
87
|
"id": "LLM07",
|
|
46
88
|
"summary": "Insecure Plugin / Tool Design — tools the LLM can invoke are narrowly scoped.",
|
|
47
|
-
"
|
|
48
|
-
"
|
|
89
|
+
"codeTestable": "yes",
|
|
90
|
+
"evidence": [
|
|
91
|
+
"Zero llm-app-security:llm-tool-exec findings.",
|
|
92
|
+
"Agent tool definitions reviewed for excessive agency."
|
|
93
|
+
],
|
|
94
|
+
"mapsTo": [
|
|
95
|
+
"family:agent-tool-exec",
|
|
96
|
+
"family:llm-app-security:llm-tool-exec"
|
|
97
|
+
]
|
|
49
98
|
},
|
|
50
99
|
{
|
|
51
100
|
"id": "LLM08",
|
|
52
101
|
"summary": "Excessive Agency — agent autonomy is bounded by explicit guards.",
|
|
53
|
-
"
|
|
54
|
-
"
|
|
102
|
+
"codeTestable": "partial",
|
|
103
|
+
"evidence": [
|
|
104
|
+
"MCP server config limits write-tools to confirm:true + reserved-write-path refusal.",
|
|
105
|
+
"Apply-fix path requires HMAC-verified scan."
|
|
106
|
+
],
|
|
107
|
+
"mapsTo": [
|
|
108
|
+
"module:mcp-tools",
|
|
109
|
+
"module:apply-fix"
|
|
110
|
+
]
|
|
55
111
|
},
|
|
56
112
|
{
|
|
57
113
|
"id": "LLM09",
|
|
58
114
|
"summary": "Overreliance — human review checkpoints exist.",
|
|
59
|
-
"
|
|
60
|
-
"
|
|
115
|
+
"codeTestable": "partial",
|
|
116
|
+
"evidence": [
|
|
117
|
+
"security-fixer agent has Stop-on-verify-fail loop.",
|
|
118
|
+
"PreToolUse bodyguard exists."
|
|
119
|
+
],
|
|
120
|
+
"mapsTo": [
|
|
121
|
+
"module:security-fixer",
|
|
122
|
+
"module:pre-edit-bodyguard"
|
|
123
|
+
]
|
|
61
124
|
},
|
|
62
125
|
{
|
|
63
126
|
"id": "LLM10",
|
|
64
127
|
"summary": "Model Theft — model access controls.",
|
|
65
|
-
"
|
|
66
|
-
"
|
|
128
|
+
"codeTestable": "yes",
|
|
129
|
+
"evidence": [
|
|
130
|
+
"No private-key-in-frontend or rpc-key-inline findings in client code."
|
|
131
|
+
],
|
|
132
|
+
"mapsTo": [
|
|
133
|
+
"family:private-key-in-frontend",
|
|
134
|
+
"family:rpc-key-inline"
|
|
135
|
+
]
|
|
67
136
|
}
|
|
68
137
|
]
|
|
69
138
|
}
|
|
@@ -264,7 +264,7 @@ export function scanConcurrency(fileContents) {
|
|
|
264
264
|
// label. Found while auditing the wrong-CWE bucket: 57% of findings
|
|
265
265
|
// on a Go/Ruby sample had no cwe at all.
|
|
266
266
|
cwe: 'CWE-667',
|
|
267
|
-
family: 'concurrency-bug',
|
|
267
|
+
family: 'concurrency-bug', parser: 'CONCURRENCY',
|
|
268
268
|
confidence: 0.5,
|
|
269
269
|
remediation: bug.remediation || 'Release the lock on every exit path (defer / try-finally / context manager).',
|
|
270
270
|
});
|
|
@@ -277,7 +277,7 @@ export function scanConcurrency(fileContents) {
|
|
|
277
277
|
vuln: `Concurrency: fire-and-forget async call in ${fn.name}() — result not awaited`,
|
|
278
278
|
severity: 'low',
|
|
279
279
|
cwe: 'CWE-703', // Improper Check or Handling of Exceptional Conditions
|
|
280
|
-
family: 'concurrency-bug',
|
|
280
|
+
family: 'concurrency-bug', parser: 'CONCURRENCY',
|
|
281
281
|
confidence: 0.4,
|
|
282
282
|
remediation: 'Await the promise / call .get() on the future / use asyncio.gather.',
|
|
283
283
|
});
|
|
@@ -292,7 +292,7 @@ export function scanConcurrency(fileContents) {
|
|
|
292
292
|
vuln: `Concurrency: potential deadlock — ${bug.order}`,
|
|
293
293
|
severity: 'high',
|
|
294
294
|
cwe: 'CWE-833', // Deadlock
|
|
295
|
-
family: 'concurrency-bug',
|
|
295
|
+
family: 'concurrency-bug', parser: 'CONCURRENCY',
|
|
296
296
|
confidence: 0.4,
|
|
297
297
|
remediation: 'Acquire locks in a consistent global order across all call sites.',
|
|
298
298
|
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Measured evidence strength for a compliance control (PRD F10.2).
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM THIS EXISTS FOR
|
|
4
|
+
// ---------------------------
|
|
5
|
+
// A control mapped to a detector with 5% recall is COVERED in the coverage map
|
|
6
|
+
// and UNCOVERED in reality. The map said "family:sql-injection → checked" and
|
|
7
|
+
// stopped there, so a reader could not tell a control backed by a detector that
|
|
8
|
+
// finds nearly everything from one backed by a detector that finds almost
|
|
9
|
+
// nothing. The PRD calls closing that its highest-integrity change, and this is
|
|
10
|
+
// the closing.
|
|
11
|
+
//
|
|
12
|
+
// THE JOIN
|
|
13
|
+
// --------
|
|
14
|
+
// control → mapped family
|
|
15
|
+
// → producing detector (posture/family-registry.js)
|
|
16
|
+
// → the CWEs that family carries (bench/family-producers/OBSERVED.json)
|
|
17
|
+
// → measured recall for those CWEs (bench/family-producers/RECALL.json)
|
|
18
|
+
//
|
|
19
|
+
// The recall numbers come from bench/independent: advisories mined from public
|
|
20
|
+
// sources, NOT authored here. That matters more than the numbers themselves —
|
|
21
|
+
// a control cannot look well-evidenced because the engine graded its own
|
|
22
|
+
// homework.
|
|
23
|
+
//
|
|
24
|
+
// WHAT "UNMEASURED" MEANS, AND WHY IT IS NOT "FINE"
|
|
25
|
+
// ------------------------------------------------
|
|
26
|
+
// A CWE the independent corpus never exercised has NO recall figure. That is
|
|
27
|
+
// unmeasured, and unmeasured is reported as unmeasured — never silently treated
|
|
28
|
+
// as passing, and never given a default number. Roughly half the observed
|
|
29
|
+
// families are in that state today. Saying so is the honest output; inventing a
|
|
30
|
+
// figure for them would be the failure this module exists to prevent.
|
|
31
|
+
//
|
|
32
|
+
// Every rate is carried as {n, d} and rendered through the caller's formatter,
|
|
33
|
+
// following posture/accuracy-scorecard.js: a percentage must never appear
|
|
34
|
+
// without its denominator, because "50% recall" over two samples is noise.
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { producersOf } from './family-registry.js';
|
|
39
|
+
import { resolveFamilyKeys } from './family-resolve.js';
|
|
40
|
+
|
|
41
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const BENCH = path.join(HERE, '..', '..', '..', 'bench', 'family-producers');
|
|
43
|
+
|
|
44
|
+
// Thresholds. Deliberately coarse — the underlying denominators are small, and a
|
|
45
|
+
// finer scale would imply a precision the measurement does not have.
|
|
46
|
+
const WEAK_BELOW = 0.25;
|
|
47
|
+
const PARTIAL_BELOW = 0.60;
|
|
48
|
+
|
|
49
|
+
// Below this many scored entries a rate is reported but flagged as indicative,
|
|
50
|
+
// mirroring accuracy-scorecard.js's `reliable:false` treatment.
|
|
51
|
+
const RELIABLE_MIN_D = 5;
|
|
52
|
+
|
|
53
|
+
let _cache = null;
|
|
54
|
+
|
|
55
|
+
function _load() {
|
|
56
|
+
if (_cache) return _cache;
|
|
57
|
+
const read = (f, fallback) => {
|
|
58
|
+
try { return JSON.parse(fs.readFileSync(path.join(BENCH, f), 'utf8')); }
|
|
59
|
+
catch { return fallback; }
|
|
60
|
+
};
|
|
61
|
+
// A missing artifact must degrade to "unmeasured", never to a default rate —
|
|
62
|
+
// same reasoning as the unmeasured tier itself.
|
|
63
|
+
const observed = read('OBSERVED.json', { families: {} });
|
|
64
|
+
const recall = read('RECALL.json', { byCwe: {} });
|
|
65
|
+
_cache = { families: observed.families || {}, byCwe: recall.byCwe || {}, population: recall.population || null };
|
|
66
|
+
return _cache;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reset the memo. Tests only. */
|
|
70
|
+
export function _resetCache() { _cache = null; }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The CWEs a family mapping was observed to carry, most frequent first.
|
|
74
|
+
*
|
|
75
|
+
* Resolves the mapping the SAME way the evaluator does — exact, alias, or
|
|
76
|
+
* `<base>-<rule-slug>` suffix — via the shared resolver. Looking `family` up as
|
|
77
|
+
* a literal key reported "unmeasured" for every aliased mapping: ASVS V5.1 maps
|
|
78
|
+
* to `family:sqli`, nothing emits `sqli`, and the real family `sql-injection` IS
|
|
79
|
+
* measured. That would be a false "no evidence" verdict on a control that has
|
|
80
|
+
* evidence.
|
|
81
|
+
*/
|
|
82
|
+
export function cwesFor(family) {
|
|
83
|
+
const { families } = _load();
|
|
84
|
+
const keys = resolveFamilyKeys(family, Object.keys(families));
|
|
85
|
+
const counts = new Map();
|
|
86
|
+
for (const k of keys) {
|
|
87
|
+
for (const [cwe, n] of Object.entries((families[k] || {}).cwes || {})) {
|
|
88
|
+
counts.set(cwe, (counts.get(cwe) || 0) + n);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Measured strength of the detector(s) behind `family`.
|
|
96
|
+
*
|
|
97
|
+
* Returns { tier, recall:{n,d}|null, cwes, measuredCwes, reliable, producers, reason }.
|
|
98
|
+
* `tier` ∈ 'measured' | 'partial' | 'weak' | 'unmeasured'.
|
|
99
|
+
*/
|
|
100
|
+
export function strengthOf(family) {
|
|
101
|
+
const { byCwe } = _load();
|
|
102
|
+
const cwes = cwesFor(family);
|
|
103
|
+
const producers = producersOf(family);
|
|
104
|
+
|
|
105
|
+
// Aggregate across every CWE this family carries that the independent corpus
|
|
106
|
+
// actually exercised. Summing {n,d} weights by how much evidence each CWE has,
|
|
107
|
+
// which is what we want — averaging the percentages would let a 1-of-1 CWE
|
|
108
|
+
// outvote a 40-of-100 one.
|
|
109
|
+
let n = 0, d = 0;
|
|
110
|
+
const measuredCwes = [];
|
|
111
|
+
for (const c of cwes) {
|
|
112
|
+
const m = byCwe[c];
|
|
113
|
+
if (!m || !m.recall || !m.recall.d) continue;
|
|
114
|
+
n += m.recall.n; d += m.recall.d;
|
|
115
|
+
measuredCwes.push(c);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!d) {
|
|
119
|
+
return {
|
|
120
|
+
tier: 'unmeasured',
|
|
121
|
+
recall: null,
|
|
122
|
+
cwes, measuredCwes: [], reliable: false, producers,
|
|
123
|
+
reason: cwes.length
|
|
124
|
+
? 'no CWE this family carries was exercised by the independent corpus'
|
|
125
|
+
: 'no CWE observed for this family — nothing to measure against',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const rate = n / d;
|
|
130
|
+
const tier = rate < WEAK_BELOW ? 'weak' : rate < PARTIAL_BELOW ? 'partial' : 'measured';
|
|
131
|
+
return {
|
|
132
|
+
tier,
|
|
133
|
+
recall: { n, d },
|
|
134
|
+
cwes, measuredCwes,
|
|
135
|
+
reliable: d >= RELIABLE_MIN_D,
|
|
136
|
+
producers,
|
|
137
|
+
reason: `measured over ${d} independent advisor${d === 1 ? 'y' : 'ies'}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Strength for a control, taken across every family it maps to.
|
|
143
|
+
*
|
|
144
|
+
* The WEAKEST backing family wins. A control is only as evidenced as its
|
|
145
|
+
* flimsiest leg: claiming the average would let a strong mapping hide a
|
|
146
|
+
* mapping that finds nothing.
|
|
147
|
+
*/
|
|
148
|
+
export function strengthOfControl(control) {
|
|
149
|
+
const families = (control && Array.isArray(control.mapsTo) ? control.mapsTo : [])
|
|
150
|
+
.filter(m => typeof m === 'string' && m.startsWith('family:'))
|
|
151
|
+
.map(m => m.slice('family:'.length).split(':')[0]);
|
|
152
|
+
|
|
153
|
+
if (!families.length) return { tier: 'unmeasured', families: [], legs: [], reason: 'control has no family: mapping' };
|
|
154
|
+
|
|
155
|
+
const legs = families.map(f => ({ family: f, ...strengthOf(f) }));
|
|
156
|
+
const ORDER = { unmeasured: 0, weak: 1, partial: 2, measured: 3 };
|
|
157
|
+
const weakest = legs.reduce((a, b) => (ORDER[b.tier] < ORDER[a.tier] ? b : a));
|
|
158
|
+
return {
|
|
159
|
+
tier: weakest.tier,
|
|
160
|
+
families, legs,
|
|
161
|
+
reason: `weakest backing family "${weakest.family}": ${weakest.reason}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* True when a control should NOT be presented as cleanly evidenced, because the
|
|
167
|
+
* detector behind it is weak or was never measured. This is the flag the
|
|
168
|
+
* coverage map carries.
|
|
169
|
+
*/
|
|
170
|
+
export function isPartiallyEvidenced(control) {
|
|
171
|
+
const s = strengthOfControl(control);
|
|
172
|
+
return s.tier === 'weak' || s.tier === 'unmeasured';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Human-readable, denominator-carrying label for a report. */
|
|
176
|
+
export function formatStrength(s) {
|
|
177
|
+
if (!s || s.tier === 'unmeasured') return 'unmeasured (no independent evidence)';
|
|
178
|
+
const { n, d } = s.recall || (s.legs && s.legs.length ? s.legs[0].recall : null) || {};
|
|
179
|
+
if (n == null) return `${s.tier} (no rate)`;
|
|
180
|
+
const pct = Math.round((n / d) * 100);
|
|
181
|
+
return `${s.tier} — recall ${n}/${d} (${pct}%)${s.reliable === false ? ', indicative only' : ''}`;
|
|
182
|
+
}
|
package/src/posture/epss.js
CHANGED
|
@@ -38,12 +38,28 @@ function cachePath(cveListKey) {
|
|
|
38
38
|
return path.join(CACHE_DIR, h + '.json');
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// PRD F3.4 — record how old the EPSS data actually is.
|
|
42
|
+
//
|
|
43
|
+
// EPSS scores decay in relevance: a probability computed months ago describes a
|
|
44
|
+
// threat landscape that has moved. Unlike KEV, a stale EPSS score can err in
|
|
45
|
+
// EITHER direction, so the age is reported rather than the value suppressed.
|
|
46
|
+
let _epssMeta = { source: 'not-loaded', ageDays: null, stale: null };
|
|
47
|
+
const EPSS_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
48
|
+
export function epssCacheMeta() { return { ..._epssMeta }; }
|
|
49
|
+
|
|
41
50
|
function readCache(key) {
|
|
42
51
|
const fp = cachePath(key);
|
|
43
52
|
if (!fs.existsSync(fp)) return null;
|
|
44
53
|
try {
|
|
45
54
|
const stat = fs.statSync(fp);
|
|
46
|
-
|
|
55
|
+
const age = Date.now() - stat.mtimeMs;
|
|
56
|
+
if (age > TTL_MS) return null;
|
|
57
|
+
_epssMeta = {
|
|
58
|
+
source: 'cache',
|
|
59
|
+
ageDays: Math.floor(age / 86400000),
|
|
60
|
+
stale: age > EPSS_STALE_AFTER_MS,
|
|
61
|
+
meaning: 'EPSS is a decaying probability; an old score describes a threat landscape that has moved.',
|
|
62
|
+
};
|
|
47
63
|
return JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
48
64
|
} catch { return null; }
|
|
49
65
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Producer registry (PRD F10.2) — which detector emits which finding family.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// ---------------
|
|
5
|
+
// Compliance controls map to families (`family:sql-injection`). Answering "is
|
|
6
|
+
// this control backed by a real detector, and how good is that detector?"
|
|
7
|
+
// requires knowing the family vocabulary. Nothing could answer that:
|
|
8
|
+
//
|
|
9
|
+
// - A textual search cannot. Detectors set `family` positionally
|
|
10
|
+
// (`_shape(file, line, ruleId, vuln, fam, …)`) or from a rule table
|
|
11
|
+
// (`family: rule.family`), so grepping `family:` misses them entirely. A
|
|
12
|
+
// search-derived list was used once to declare four families "unevidenceable";
|
|
13
|
+
// all four had producers, and 15 working controls were suppressed as a result.
|
|
14
|
+
// - A corpus sweep cannot. It reports only what some fixture triggered — a
|
|
15
|
+
// LOWER BOUND. `k8s-pod-security-privileged` was absent from a 116-root sweep
|
|
16
|
+
// and present in a 331-root one; nothing about the engine changed.
|
|
17
|
+
//
|
|
18
|
+
// So the detector declares, in the file that emits: `export const EMITS = [...]`.
|
|
19
|
+
// Colocation is the point — the rule and its declaration are edited together, and
|
|
20
|
+
// the gate below catches the case where they are not.
|
|
21
|
+
//
|
|
22
|
+
// SCOPE, STATED HONESTLY
|
|
23
|
+
// ----------------------
|
|
24
|
+
// Registration is per-module and incremental. 287 detector modules exist; the
|
|
25
|
+
// ones listed here are registered so far, chosen because they set `family`
|
|
26
|
+
// positionally (invisible to every other method) and back compliance mappings.
|
|
27
|
+
//
|
|
28
|
+
// A module that is registered is held to COMPLETENESS: a scan producing a family
|
|
29
|
+
// it did not declare fails the gate. A module that is not registered is not
|
|
30
|
+
// silently passing — it is absent, and `test/family-registry.test.js` reports the
|
|
31
|
+
// undeclared remainder as a ratchet that must not grow. That is the difference
|
|
32
|
+
// between a gate that expands over time and one that gets switched off in week
|
|
33
|
+
// two for failing on day one.
|
|
34
|
+
import { EMITS as CLOUD_IAM } from '../sast/cloud-iam.js';
|
|
35
|
+
import { EMITS as CRYPTO_PROTOCOL } from '../sast/crypto-protocol.js';
|
|
36
|
+
import { EMITS as DAPP_FRONTEND } from '../sast/dapp-frontend.js';
|
|
37
|
+
import { EMITS as K8S_ADMISSION } from '../sast/k8s-admission.js';
|
|
38
|
+
import { EMITS as ML_SUPPLY_CHAIN } from '../sast/ml-supply-chain.js';
|
|
39
|
+
import { EMITS as WEB3_ADVANCED } from '../sast/web3-advanced.js';
|
|
40
|
+
|
|
41
|
+
// module path -> declared families. Path is the identity a human greps for.
|
|
42
|
+
export const REGISTERED = Object.freeze({
|
|
43
|
+
'sast/cloud-iam.js': CLOUD_IAM,
|
|
44
|
+
'sast/crypto-protocol.js': CRYPTO_PROTOCOL,
|
|
45
|
+
'sast/dapp-frontend.js': DAPP_FRONTEND,
|
|
46
|
+
'sast/k8s-admission.js': K8S_ADMISSION,
|
|
47
|
+
'sast/ml-supply-chain.js': ML_SUPPLY_CHAIN,
|
|
48
|
+
'sast/web3-advanced.js': WEB3_ADVANCED,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// The `parser` field each registered module stamps on its findings. This is how a
|
|
52
|
+
// finding is attributed back to its module at runtime.
|
|
53
|
+
//
|
|
54
|
+
// NOT every parser identifies a module: `REGEX` alone covers 80 distinct
|
|
55
|
+
// families, and `STRUCTURAL` covers 23. Those are shared labels, so a module
|
|
56
|
+
// using one cannot be registered for completeness until it stamps something
|
|
57
|
+
// specific. Only module-specific parsers appear here, and the gate checks
|
|
58
|
+
// attribution only for these.
|
|
59
|
+
export const PARSER_OF = Object.freeze({
|
|
60
|
+
'sast/cloud-iam.js': 'CLOUD-IAM',
|
|
61
|
+
'sast/crypto-protocol.js': 'CRYPTO-PROTO',
|
|
62
|
+
'sast/dapp-frontend.js': 'DAPP-FRONTEND',
|
|
63
|
+
'sast/k8s-admission.js': 'K8S-ADM',
|
|
64
|
+
'sast/ml-supply-chain.js': 'ML-SUPPLY',
|
|
65
|
+
'sast/web3-advanced.js': 'WEB3-ADV',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Every family any registered module declares. */
|
|
69
|
+
export function declaredFamilies() {
|
|
70
|
+
const out = new Set();
|
|
71
|
+
for (const list of Object.values(REGISTERED)) for (const f of list) out.add(f);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Module paths declaring `family`, or [] if none does. */
|
|
76
|
+
export function producersOf(family) {
|
|
77
|
+
return Object.entries(REGISTERED)
|
|
78
|
+
.filter(([, list]) => list.includes(family))
|
|
79
|
+
.map(([mod]) => mod);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The module registered for `parser`, or null when the parser is not module-specific. */
|
|
83
|
+
export function moduleForParser(parser) {
|
|
84
|
+
const hit = Object.entries(PARSER_OF).find(([, p]) => p === parser);
|
|
85
|
+
return hit ? hit[0] : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Families a registered module emitted but never declared — the drift this whole
|
|
90
|
+
* mechanism exists to catch. `findings` is any array carrying {family, parser}.
|
|
91
|
+
*/
|
|
92
|
+
export function undeclaredFrom(findings) {
|
|
93
|
+
const out = new Map(); // module -> Set(family)
|
|
94
|
+
for (const f of findings || []) {
|
|
95
|
+
if (!f || !f.family || !f.parser) continue;
|
|
96
|
+
const mod = moduleForParser(f.parser);
|
|
97
|
+
if (!mod) continue; // parser is not module-specific
|
|
98
|
+
if (REGISTERED[mod].includes(f.family)) continue;
|
|
99
|
+
if (!out.has(mod)) out.set(mod, new Set());
|
|
100
|
+
out.get(mod).add(f.family);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Shared family-name resolution for compliance mappings.
|
|
2
|
+
//
|
|
3
|
+
// Extracted into its own module to break an import CYCLE: the evaluator
|
|
4
|
+
// (auditor-walkthrough.js) needs the resolver to count findings, and
|
|
5
|
+
// coverage-strength.js needs the SAME resolver to measure detector strength. When
|
|
6
|
+
// the resolver lived in the evaluator and the evaluator imported strength back,
|
|
7
|
+
// ESM handed one side an undefined binding and a defensive try/catch turned that
|
|
8
|
+
// into a silently missing `evidence` field — a wrong answer wearing the costume
|
|
9
|
+
// of a working one.
|
|
10
|
+
//
|
|
11
|
+
// One definition, no cycle, both callers import from here.
|
|
12
|
+
|
|
13
|
+
export const COMPLIANCE_FAMILY_ALIAS = {
|
|
14
|
+
// ASVS spells it `sqli`; every detector emits `sql-injection` (or a
|
|
15
|
+
// language-prefixed variant, which the suffix rule below does NOT cover
|
|
16
|
+
// because the prefix is on the wrong end).
|
|
17
|
+
'sqli': ['sql-injection', 'dart-sql-injection', 'laravel-sql-injection'],
|
|
18
|
+
'auth-missing': ['broken-access-control', 'fastapi-missing-auth', 'springboot-missing-authz', 'laravel-missing-auth', 'quarkus-missing-authz'],
|
|
19
|
+
'authz': ['broken-access-control', 'idor', 'springboot-missing-authz', 'quarkus-missing-authz'],
|
|
20
|
+
'k8s-pod-security-privileged': ['k8s-pod-privileged'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The emitted families a `family:X` mapping resolves to, given the families a
|
|
25
|
+
* scan actually produced.
|
|
26
|
+
*
|
|
27
|
+
* Exported because TWO callers must agree: this file's evaluator (does the
|
|
28
|
+
* control have open findings?) and posture/coverage-strength.js (how good is the
|
|
29
|
+
* detector behind it?). When only the evaluator knew the rule, the strength
|
|
30
|
+
* module reported "unmeasured" for every aliased or suffixed mapping — a false
|
|
31
|
+
* "no evidence" verdict on controls that are in fact measured, which is the
|
|
32
|
+
* mirror image of the vacuous-pass bug this file already fixes. One rule, one
|
|
33
|
+
* definition.
|
|
34
|
+
*
|
|
35
|
+
* Resolution is exact match, alias, or `<base>-<rule-slug>` suffix. The `-`
|
|
36
|
+
* separator is load-bearing: without it `nosql-injection` would satisfy a
|
|
37
|
+
* `sql-injection` mapping.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveFamilyKeys(fam, availableKeys) {
|
|
40
|
+
const bases = [fam, ...(COMPLIANCE_FAMILY_ALIAS[fam] || [])];
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const key of availableKeys) {
|
|
43
|
+
if (bases.some(b => key === b || key.startsWith(`${b}-`))) out.push(key);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// PRD F6.5 — publish the proportion of findings this engine DECLINES to fix.
|
|
2
|
+
//
|
|
3
|
+
// A remediation feature that silently attempts everything is less trustworthy
|
|
4
|
+
// than one that declines 40% and says so. Before this, the only visible number
|
|
5
|
+
// was about fixes that were attempted; a finding for which synthesis was never
|
|
6
|
+
// even tried simply did not appear, so the denominator quietly excluded every
|
|
7
|
+
// hard case.
|
|
8
|
+
//
|
|
9
|
+
// THE BUCKETS
|
|
10
|
+
//
|
|
11
|
+
// deterministic — a context-independent literal swap exists (md5 -> sha256,
|
|
12
|
+
// TLS verify off -> on). Highest confidence: the patch does
|
|
13
|
+
// not depend on reading intent.
|
|
14
|
+
// model — no deterministic patch, but the finding is a shape a model
|
|
15
|
+
// can be asked to fix. Counted as ATTEMPTABLE, not as fixed:
|
|
16
|
+
// whether the attempt succeeds is fix-metrics.js's question.
|
|
17
|
+
// declined — synthesis refuses, with a reason. Not a failure; a limit
|
|
18
|
+
// stated up front.
|
|
19
|
+
//
|
|
20
|
+
// `declined` and `model` are kept apart for the same reason proof-coverage
|
|
21
|
+
// separates `indeterminate` from `unclassified`: "we will not try" and "we will
|
|
22
|
+
// try and might fail" are different promises, and merging them lets the weaker
|
|
23
|
+
// one borrow the stronger one's credibility.
|
|
24
|
+
import { synthesizeDeterministicPatch } from './deterministic-fix.js';
|
|
25
|
+
|
|
26
|
+
// Families where a patch cannot be synthesised from the finding alone, with the
|
|
27
|
+
// reason. Stated as DATA so a report can print why, rather than leaving a reader
|
|
28
|
+
// to assume the engine simply has not got round to it.
|
|
29
|
+
export const DECLINED_TO_FIX = Object.freeze({
|
|
30
|
+
'broken-access-control': 'the correct authorisation rule is a product decision — a scanner that invents one is guessing at intent, and a wrong authz patch fails open.',
|
|
31
|
+
'idor': 'same as broken-access-control: which identity may read which record is not recoverable from the code.',
|
|
32
|
+
'broken-authz': 'the rule that was checked wrongly is a product decision; patching it from the code alone guesses at which roles may do what, and guessing fails open.',
|
|
33
|
+
'business-logic': 'by definition the defect is a mismatch with intent, and intent is not in the file.',
|
|
34
|
+
'concurrency-bug': 'the correct lock discipline depends on the whole call graph; a local patch can deadlock rather than fix.',
|
|
35
|
+
'license-graph': 'a licence conflict is resolved by a policy or a dependency decision, not by editing code.',
|
|
36
|
+
'vulnerable-dep': 'resolved by an upgrade, which is apply_sca_upgrade\'s job, not a source patch.',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Bucket a finding: 'deterministic' | 'model' | 'declined'. */
|
|
40
|
+
export function fixBucketOf(finding, fileContent) {
|
|
41
|
+
const fam = (finding && finding.family) || '';
|
|
42
|
+
for (const key of Object.keys(DECLINED_TO_FIX)) {
|
|
43
|
+
if (fam === key || fam.startsWith(`${key}-`)) return 'declined';
|
|
44
|
+
}
|
|
45
|
+
if (typeof fileContent === 'string' && fileContent) {
|
|
46
|
+
try {
|
|
47
|
+
const p = synthesizeDeterministicPatch(finding, fileContent);
|
|
48
|
+
if (p && p.ok !== false && (p.patch || p.replacement)) return 'deterministic';
|
|
49
|
+
} catch { /* fall through — an erroring synthesiser is not a fix */ }
|
|
50
|
+
}
|
|
51
|
+
return 'model';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fix coverage over a finding set.
|
|
56
|
+
*
|
|
57
|
+
* Every share carries {n, d}. `fileContents` is optional: without it the
|
|
58
|
+
* deterministic check cannot run, and rather than guessing, those findings fall
|
|
59
|
+
* to `model` and `deterministicChecked` reports false so a reader knows the
|
|
60
|
+
* split is a lower bound on deterministic coverage.
|
|
61
|
+
*/
|
|
62
|
+
export function fixCoverage(findings, fileContents = null) {
|
|
63
|
+
const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
|
|
64
|
+
const d = list.length;
|
|
65
|
+
const buckets = { deterministic: [], model: [], declined: [] };
|
|
66
|
+
for (const f of list) {
|
|
67
|
+
const src = fileContents && f.file ? fileContents[f.file] : null;
|
|
68
|
+
buckets[fixBucketOf(f, src)].push(f);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const declinedByFamily = {};
|
|
72
|
+
for (const f of buckets.declined) {
|
|
73
|
+
const fam = f.family || '(none)';
|
|
74
|
+
const key = Object.keys(DECLINED_TO_FIX).find(k => fam === k || fam.startsWith(`${k}-`)) || fam;
|
|
75
|
+
if (!declinedByFamily[key]) declinedByFamily[key] = { n: 0, reason: DECLINED_TO_FIX[key] || 'declined' };
|
|
76
|
+
declinedByFamily[key].n += 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
total: d,
|
|
81
|
+
deterministic: { n: buckets.deterministic.length, d },
|
|
82
|
+
model: { n: buckets.model.length, d },
|
|
83
|
+
declined: { n: buckets.declined.length, d, byFamily: declinedByFamily },
|
|
84
|
+
deterministicChecked: !!fileContents,
|
|
85
|
+
meaning: 'deterministic = a context-independent patch exists; model = attemptable by a model, NOT known to succeed; declined = synthesis refuses with a stated reason.',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Markdown for the scorecard. Denominators always attached. */
|
|
90
|
+
export function renderFixCoverage(cov) {
|
|
91
|
+
if (!cov || !cov.total) return '_No findings to report fix coverage over._\n';
|
|
92
|
+
const pct = (n) => `${n}/${cov.total} (${Math.round((n / cov.total) * 100)}%)`;
|
|
93
|
+
const lines = [
|
|
94
|
+
'| Bucket | Share | Meaning |',
|
|
95
|
+
'|---|---|---|',
|
|
96
|
+
`| Deterministic patch | ${pct(cov.deterministic.n)} | context-independent literal swap |`,
|
|
97
|
+
`| Model-attemptable | ${pct(cov.model.n)} | can be attempted; success not claimed here |`,
|
|
98
|
+
`| Declined | ${pct(cov.declined.n)} | synthesis refuses — reasons below |`,
|
|
99
|
+
'',
|
|
100
|
+
];
|
|
101
|
+
if (!cov.deterministicChecked) {
|
|
102
|
+
lines.push('_Source was not supplied, so the deterministic check could not run: the'
|
|
103
|
+
+ ' deterministic share is a LOWER bound and those findings are counted as'
|
|
104
|
+
+ ' model-attemptable._', '');
|
|
105
|
+
}
|
|
106
|
+
const entries = Object.entries(cov.declined.byFamily).sort((a, b) => b[1].n - a[1].n);
|
|
107
|
+
if (entries.length) {
|
|
108
|
+
lines.push('**Why each family is declined**', '');
|
|
109
|
+
for (const [fam, { n, reason }] of entries) lines.push(`- \`${fam}\` (${n}): ${reason}`);
|
|
110
|
+
lines.push('');
|
|
111
|
+
}
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|