@clear-capabilities/agentic-security-scanner 0.139.1 → 0.141.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 +221 -0
- package/bin/agentic-security.js +40 -11
- 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 +16 -5
- package/src/dataflow/catalog.js +61 -0
- package/src/engine.js +281 -23
- 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 +137 -21
- 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 +42 -5
- 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/integrity.js +59 -8
- package/src/posture/mcp-rug-pull.js +144 -0
- package/src/posture/poc-generator.js +17 -1
- 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 +50 -7
- 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 +337 -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
|
}
|
|
@@ -106,6 +106,13 @@ function grabBody(text, openBraceIdx) {
|
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Lock names come from a `\w+` capture so they cannot currently carry regex
|
|
110
|
+
// metacharacters, but the guards below interpolate them into a pattern — escape
|
|
111
|
+
// so that stays true if a capture ever widens.
|
|
112
|
+
function escapeRe(s) {
|
|
113
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
114
|
+
}
|
|
115
|
+
|
|
109
116
|
function findMissedUnlocks(fn, lang) {
|
|
110
117
|
const out = [];
|
|
111
118
|
const p = PATTERNS[lang];
|
|
@@ -128,10 +135,32 @@ function findMissedUnlocks(fn, lang) {
|
|
|
128
135
|
// Lock+unlock both present, but check that the function has a defer/
|
|
129
136
|
// try-finally guarantee. Go: `defer`; Java/Py: try/finally; otherwise
|
|
130
137
|
// early-return-before-unlock is a risk.
|
|
138
|
+
//
|
|
139
|
+
// The guard must accept a QUALIFIED receiver (`defer s.mu.Unlock()`,
|
|
140
|
+
// `with self.lock:`), not just a bare one. A mutex is usually a struct
|
|
141
|
+
// field or an instance attribute, so the qualified form is the common
|
|
142
|
+
// shape in real code — and the acquire patterns already matched it
|
|
143
|
+
// (`\b(\w+)\.Lock\(\)` captures `mu` from `s.mu.Lock()`). While the two
|
|
144
|
+
// halves disagreed, correct idiomatic code was the most likely to be
|
|
145
|
+
// reported: on a 12-entry Go sample this was the bulk of the module's
|
|
146
|
+
// output.
|
|
147
|
+
//
|
|
148
|
+
// It is also matched PER LOCK NAME rather than body-wide. A function
|
|
149
|
+
// holding two mutexes must not have its second lock cleared just because
|
|
150
|
+
// the first one is deferred.
|
|
151
|
+
// The receiver prefix is `[\w$.]*` — a single character class — rather
|
|
152
|
+
// than the more obvious `(?:[\w$]+\.)*`, which nests a quantifier inside
|
|
153
|
+
// a quantifier and is exactly the catastrophic-backtracking shape this
|
|
154
|
+
// project's own CWE-1333 detector flags (it caught this line in the
|
|
155
|
+
// self-scan). `\b` before the lock name is what keeps the flat form
|
|
156
|
+
// precise: without it, `[\w$.]*` would happily match `s.not` and let
|
|
157
|
+
// `defer s.notmu.Unlock()` guard a lock named `mu`.
|
|
158
|
+
const q = escapeRe(name);
|
|
131
159
|
const guarded =
|
|
132
|
-
(lang === 'go' &&
|
|
160
|
+
(lang === 'go' && new RegExp(`defer\\s+[\\w$.]*\\b${q}\\.Unlock\\(\\)`).test(fn.body)) ||
|
|
133
161
|
(lang === 'java' && /try\s*\{[\s\S]*finally\s*\{[\s\S]*\.unlock\(\)/m.test(fn.body)) ||
|
|
134
|
-
(lang === 'py' && (
|
|
162
|
+
(lang === 'py' && (new RegExp(`with\\s+[^:\\n]*\\b${q}\\b[^:\\n]*:`).test(fn.body)
|
|
163
|
+
|| /try\s*:[\s\S]*finally\s*:[\s\S]*\.release\(\)/m.test(fn.body)));
|
|
135
164
|
if (!guarded && /\breturn\b/.test(fn.body)) {
|
|
136
165
|
out.push({
|
|
137
166
|
kind: 'unguarded-lock',
|
|
@@ -229,7 +258,13 @@ export function scanConcurrency(fileContents) {
|
|
|
229
258
|
? `Concurrency: ${fn.name}() acquires ${bug.lock} but no matching unlock`
|
|
230
259
|
: `Concurrency: ${fn.name}() can return without releasing ${bug.lock}`,
|
|
231
260
|
severity: 'medium',
|
|
232
|
-
|
|
261
|
+
// CWE-667 Improper Locking. The findings schema in CLAUDE.md requires a
|
|
262
|
+
// cwe on every finding, and these carried none — so they arrived in
|
|
263
|
+
// every CWE-keyed report as null and could never match an advisory
|
|
264
|
+
// label. Found while auditing the wrong-CWE bucket: 57% of findings
|
|
265
|
+
// on a Go/Ruby sample had no cwe at all.
|
|
266
|
+
cwe: 'CWE-667',
|
|
267
|
+
family: 'concurrency-bug', parser: 'CONCURRENCY',
|
|
233
268
|
confidence: 0.5,
|
|
234
269
|
remediation: bug.remediation || 'Release the lock on every exit path (defer / try-finally / context manager).',
|
|
235
270
|
});
|
|
@@ -241,7 +276,8 @@ export function scanConcurrency(fileContents) {
|
|
|
241
276
|
line: bug.startLine,
|
|
242
277
|
vuln: `Concurrency: fire-and-forget async call in ${fn.name}() — result not awaited`,
|
|
243
278
|
severity: 'low',
|
|
244
|
-
|
|
279
|
+
cwe: 'CWE-703', // Improper Check or Handling of Exceptional Conditions
|
|
280
|
+
family: 'concurrency-bug', parser: 'CONCURRENCY',
|
|
245
281
|
confidence: 0.4,
|
|
246
282
|
remediation: 'Await the promise / call .get() on the future / use asyncio.gather.',
|
|
247
283
|
});
|
|
@@ -255,7 +291,8 @@ export function scanConcurrency(fileContents) {
|
|
|
255
291
|
line: bug.startLineA,
|
|
256
292
|
vuln: `Concurrency: potential deadlock — ${bug.order}`,
|
|
257
293
|
severity: 'high',
|
|
258
|
-
|
|
294
|
+
cwe: 'CWE-833', // Deadlock
|
|
295
|
+
family: 'concurrency-bug', parser: 'CONCURRENCY',
|
|
259
296
|
confidence: 0.4,
|
|
260
297
|
remediation: 'Acquire locks in a consistent global order across all call sites.',
|
|
261
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
|
+
|