@blamejs/exceptd-skills 0.19.31 → 0.19.33
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/ARCHITECTURE.md +32 -23
- package/CHANGELOG.md +28 -0
- package/CONTEXT.md +19 -11
- package/README.md +99 -18
- package/SECURITY.md +65 -45
- package/data/_indexes/_meta.json +9 -9
- package/data/_indexes/activity-feed.json +8 -8
- package/data/_indexes/catalog-summaries.json +8 -8
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +11117 -446
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8331 -2
- package/lib/cve-batch.js +27 -57
- package/lib/cve-curation.js +26 -1
- package/lib/cve-enrich.js +18 -43
- package/lib/refresh-external.js +100 -1
- package/lib/scoring.js +158 -372
- package/lib/validate-cve-catalog.js +11 -0
- package/manifest.json +53 -53
- package/package.json +2 -2
- package/sbom.cdx.json +47 -47
- package/sources/validators/cve-validator.js +40 -15
package/lib/scoring.js
CHANGED
|
@@ -1,55 +1,24 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* RWEP — Real-World Exploit Priority scoring engine
|
|
5
|
-
*
|
|
4
|
+
* RWEP — Real-World Exploit Priority scoring engine. Supplements CVSS with
|
|
5
|
+
* exploit availability, active exploitation and operational constraints.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* each surface has different requirements:
|
|
14
|
-
*
|
|
15
|
-
* cisa_kev: 0 OR +25 (post-weight contribution)
|
|
16
|
-
* poc_available: 0 OR +20 (post-weight contribution)
|
|
17
|
-
* ai_factor: 0 OR +15 (post-weight contribution)
|
|
18
|
-
* active_exploitation: 0 / 10 / 5 / 20 (post-weight contribution from ladder)
|
|
19
|
-
* blast_radius: 0..30 RAW (intentionally NOT post-weight —
|
|
20
|
-
* mirrors the weight ceiling so it
|
|
21
|
-
* reads as raw blast magnitude)
|
|
22
|
-
* patch_available: 0 OR -15 (post-weight contribution)
|
|
23
|
-
* live_patch_available: 0 OR -10 (post-weight contribution)
|
|
24
|
-
* reboot_required: 0 OR +5 (post-weight contribution)
|
|
25
|
-
*
|
|
26
|
-
* Operator-facing implication: summing `Object.values(rwep_factors)` produces
|
|
27
|
-
* the stored `rwep_score` for catalog entries because the blast weight is 30
|
|
28
|
-
* (matches the raw cap). This dual-shape is intentional but easy to misuse;
|
|
29
|
-
* direct boolean inputs should go through `scoreCustom()` instead.
|
|
30
|
-
*
|
|
31
|
-
* scoreCustom() input shape is DIFFERENT — it accepts BOOLEAN factors plus
|
|
32
|
-
* a numeric blast_radius and a string active_exploitation ladder value.
|
|
33
|
-
* `deriveRwepFromFactors()` is the shape-detecting bridge: if values look
|
|
34
|
-
* numeric (post-weighted), it sums; if values look boolean / string-ladder,
|
|
35
|
-
* it routes through scoreCustom.
|
|
36
|
-
*
|
|
37
|
-
* The semantic ambiguity is grandfathered. A clean rename (raw_factors vs
|
|
38
|
-
* weighted_contributions) is a minor-bump change and is deferred.
|
|
39
|
-
* ----------------------------------------------------------------------------
|
|
7
|
+
* `rwep_factors` carries two shapes at once. Every factor stores its POST-WEIGHT
|
|
8
|
+
* contribution except `blast_radius`, which stores its RAW 0..30 magnitude —
|
|
9
|
+
* summing the object still yields `rwep_score` only because the blast weight is
|
|
10
|
+
* also 30. Do not feed booleans in here; `scoreCustom()` takes those, plus a
|
|
11
|
+
* numeric blast_radius and a ladder string. `deriveRwepFromFactors()` detects
|
|
12
|
+
* which shape it was given and routes accordingly.
|
|
40
13
|
*/
|
|
41
14
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// live_patch_available => live_patch_tools implication is enforced
|
|
46
|
-
// separately below.)
|
|
15
|
+
// Loaded from the schema so the two cannot drift. live_patch_tools is
|
|
16
|
+
// deliberately absent: it is schema-optional, and the
|
|
17
|
+
// live_patch_available => live_patch_tools implication is enforced below.
|
|
47
18
|
const CVE_SCHEMA_REQUIRED = require('./schemas/cve-catalog.schema.json').required;
|
|
48
19
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// reboot_required applies whenever patch requires reboot, regardless of live-patch availability,
|
|
52
|
-
// because live-patch is a temporary workaround — full remediation window is extended.
|
|
20
|
+
// reboot_required applies even when a live patch exists, because a live patch
|
|
21
|
+
// is a workaround and the full remediation window stays open.
|
|
53
22
|
const RWEP_WEIGHTS = {
|
|
54
23
|
cisa_kev: 25,
|
|
55
24
|
poc_available: 20,
|
|
@@ -61,52 +30,33 @@ const RWEP_WEIGHTS = {
|
|
|
61
30
|
reboot_required: 5
|
|
62
31
|
};
|
|
63
32
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
33
|
+
// Must stay aligned with playbook-runner's _activeExploitationLadder so the
|
|
34
|
+
// catalog scorer and the runtime evaluator agree on the same string. 'unknown'
|
|
35
|
+
// scores a quarter rather than zero: an untriaged CVE is not a clean one.
|
|
36
|
+
// 'theoretical' is mapped explicitly at 0 — a published PoC carries its weight
|
|
37
|
+
// through poc_available, and an incidental `?? 0` fall-through here would be
|
|
38
|
+
// indistinguishable from an unrecognised value.
|
|
70
39
|
const ACTIVE_EXPLOITATION_LADDER = {
|
|
71
40
|
confirmed: 1.0,
|
|
72
41
|
suspected: 0.5,
|
|
73
42
|
unknown: 0.25,
|
|
74
|
-
// "theoretical" = a working PoC is published but no in-the-wild exploitation
|
|
75
|
-
// is observed (per the catalog's active_exploitation_vocabulary). The PoC
|
|
76
|
-
// itself carries weight via the separate poc_available factor; the
|
|
77
|
-
// active-exploitation dimension is 0 (no observed exploitation). Mapped
|
|
78
|
-
// explicitly so it's intentional, not an incidental `?? 0` fall-through —
|
|
79
|
-
// and so it does not perturb the stored RWEP of theoretical-status entries.
|
|
80
43
|
theoretical: 0,
|
|
81
44
|
none: 0,
|
|
82
45
|
};
|
|
83
46
|
|
|
84
47
|
/**
|
|
85
|
-
*
|
|
48
|
+
* Ladder multiplier for an active_exploitation value, as
|
|
49
|
+
* { multiplier, recognised, normalised }.
|
|
86
50
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* path must surface an error, not a silent default (same remedy as the
|
|
94
|
-
* playbook-runner condition-evaluator hyphen fix).
|
|
95
|
-
*
|
|
96
|
-
* - Case-normalises the lookup so 'Confirmed' / ' CONFIRMED ' resolve to the
|
|
97
|
-
* canonical ladder entry instead of zeroing.
|
|
98
|
-
* - null / undefined are the documented "treated as 'none'" default (mult 0,
|
|
99
|
-
* recognised) — these are not typos.
|
|
100
|
-
* - A non-empty string NOT in the ladder, or a non-string non-nullish value,
|
|
101
|
-
* is UNRECOGNISED: returns multiplier 0 AND emits a process warning so the
|
|
102
|
-
* zeroed factor is observable in the bare-number call path. The structured
|
|
103
|
-
* diagnostic for the collectWarnings path is produced by validateFactors().
|
|
104
|
-
*
|
|
105
|
-
* Returns { multiplier, recognised, normalised }.
|
|
51
|
+
* Case- and whitespace-normalised, so 'Confirmed' resolves rather than zeroing.
|
|
52
|
+
* null and undefined are the documented 'none' default and count as recognised.
|
|
53
|
+
* Anything else returns multiplier 0 AND emits a process warning: an
|
|
54
|
+
* out-of-vocabulary string would otherwise drop 20 points silently, and the
|
|
55
|
+
* no-match path has to be observable. validateFactors() carries the structured
|
|
56
|
+
* diagnostic for callers that collect warnings.
|
|
106
57
|
*/
|
|
107
58
|
function resolveActiveExploitation(active_exploitation) {
|
|
108
59
|
if (active_exploitation === undefined || active_exploitation === null) {
|
|
109
|
-
// documented default: absent active_exploitation is scored as 'none'.
|
|
110
60
|
return { multiplier: ACTIVE_EXPLOITATION_LADDER.none, recognised: true, normalised: 'none' };
|
|
111
61
|
}
|
|
112
62
|
if (typeof active_exploitation === 'string') {
|
|
@@ -138,29 +88,22 @@ function activeExploitationMultiplier(active_exploitation) {
|
|
|
138
88
|
return r.multiplier;
|
|
139
89
|
}
|
|
140
90
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
91
|
+
// Boolean-input (Shape-A) keys scoreCustom recognises; validateFactors flags
|
|
92
|
+
// anything else. The last two are the catalog's own field names, accepted as
|
|
93
|
+
// aliases so a factor bag built straight from an entry validates.
|
|
143
94
|
const RECOGNISED_FACTOR_KEYS = new Set([
|
|
144
95
|
'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
|
|
145
96
|
'active_exploitation', 'blast_radius', 'patch_available',
|
|
146
97
|
'live_patch_available', 'reboot_required',
|
|
147
|
-
// accepted aliases for the catalog field names: a factor bag built straight
|
|
148
|
-
// from a catalog entry carries `ai_assisted_weaponization` (the field the
|
|
149
|
-
// catalog declares) and `patch_required_reboot`, not the legacy short forms.
|
|
150
98
|
'ai_assisted_weaponization',
|
|
151
99
|
'patch_required_reboot',
|
|
152
100
|
]);
|
|
153
101
|
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// `ai_assisted_weaponization`), so the Shape-B allowlist must add it back — a
|
|
160
|
-
// plain `RECOGNISED_FACTOR_KEYS.has(k)` filter would silently drop the AI weight
|
|
161
|
-
// from every derivation. Any key NOT in this set is a typo or unknown field; it
|
|
162
|
-
// is excluded from the sum AND surfaced (see the Shape-B loop) rather than blindly
|
|
163
|
-
// added, so a sub-5 typo can't corrupt the derived score with no diagnostic.
|
|
102
|
+
// Post-weight (Shape-B) keys deriveRwepFromFactors may sum. `ai_factor` has to
|
|
103
|
+
// be added back: it is the +15 weight every Shape-B entry stores, and it is
|
|
104
|
+
// absent from the Shape-A set above, so filtering on that set alone would drop
|
|
105
|
+
// it from every derivation. A key outside this set is excluded from the sum and
|
|
106
|
+
// surfaced, so a typo cannot quietly change a score.
|
|
164
107
|
const RECOGNISED_POST_WEIGHT_KEYS = new Set([...RECOGNISED_FACTOR_KEYS, 'ai_factor']);
|
|
165
108
|
|
|
166
109
|
function score(cveId, catalog) {
|
|
@@ -170,18 +113,11 @@ function score(cveId, catalog) {
|
|
|
170
113
|
}
|
|
171
114
|
|
|
172
115
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* Range expectations:
|
|
179
|
-
* - cisa_kev, poc_available, ai_assisted_weapon, ai_discovered,
|
|
180
|
-
* patch_available, live_patch_available, reboot_required: boolean
|
|
181
|
-
* (or null, treated as false with a missing-field warning).
|
|
182
|
-
* - active_exploitation: 'none' | 'unknown' | 'suspected' | 'theoretical' | 'confirmed'.
|
|
183
|
-
* - blast_radius: integer in [0, 30] (clamped at the weight ceiling but
|
|
184
|
-
* flagged when out-of-range — out-of-range usually means a unit error).
|
|
116
|
+
* Warnings for a factor bag: missing-but-defaultable fields and out-of-range
|
|
117
|
+
* values. Never throws — a caller wanting enforcement treats a non-empty return
|
|
118
|
+
* as a failure. Booleans may be null (false, with a warning);
|
|
119
|
+
* active_exploitation must be a ladder value; blast_radius is an integer in
|
|
120
|
+
* [0, 30], flagged out of range because that usually means a unit error.
|
|
185
121
|
*/
|
|
186
122
|
function validateFactors(factors) {
|
|
187
123
|
const warnings = [];
|
|
@@ -191,10 +127,8 @@ function validateFactors(factors) {
|
|
|
191
127
|
const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
|
|
192
128
|
'patch_available', 'live_patch_available', 'reboot_required'];
|
|
193
129
|
for (const f of boolFields) {
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// scoreCustom/deriveRwepFromFactors honor, so validateFactors must accept a
|
|
197
|
-
// block that supplies only the alias instead of flagging it "missing".
|
|
130
|
+
// Honours the same aliasing as scoreCustom, so a bag supplying only the
|
|
131
|
+
// catalog field name is not flagged missing.
|
|
198
132
|
const present = (f === 'ai_assisted_weapon')
|
|
199
133
|
? (factors.ai_assisted_weapon ?? factors.ai_assisted_weaponization)
|
|
200
134
|
: (f === 'reboot_required')
|
|
@@ -211,25 +145,21 @@ function validateFactors(factors) {
|
|
|
211
145
|
if (aeRaw === undefined || aeRaw === null) {
|
|
212
146
|
warnings.push("active_exploitation: missing (treated as 'none')");
|
|
213
147
|
} else {
|
|
214
|
-
//
|
|
215
|
-
// accepts
|
|
216
|
-
// stray-cased 'Confirmed' / ' confirmed ' must not be flagged here while the
|
|
217
|
-
// scorer consumes it, or the two surfaces disagree.
|
|
148
|
+
// Normalised before the vocabulary check so this accepts exactly what the
|
|
149
|
+
// scorer accepts; otherwise 'Confirmed' is flagged here and consumed there.
|
|
218
150
|
const aeNorm = typeof aeRaw === 'string' ? aeRaw.trim().toLowerCase() : aeRaw;
|
|
219
151
|
if (!aeAllowed.includes(aeNorm)) {
|
|
220
152
|
warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(aeRaw)}`);
|
|
221
153
|
}
|
|
222
154
|
}
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
// and emits a useful message.
|
|
155
|
+
// Number.isFinite rather than a typeof check: `typeof NaN === 'number'` and
|
|
156
|
+
// `JSON.stringify(NaN) === 'null'`, which together produce the useless
|
|
157
|
+
// "expected number, got number (null)".
|
|
227
158
|
if (factors.blast_radius === undefined || factors.blast_radius === null) {
|
|
228
159
|
warnings.push('blast_radius: missing (treated as 0)');
|
|
229
160
|
} else if (typeof factors.blast_radius !== 'number') {
|
|
230
|
-
// scoreCustom coerces a numeric string
|
|
231
|
-
//
|
|
232
|
-
// rather than rejecting what the scorer will happily use.
|
|
161
|
+
// scoreCustom coerces a numeric string via Number(), so a finite one is
|
|
162
|
+
// noted rather than rejected — the scorer will use it either way.
|
|
233
163
|
if (typeof factors.blast_radius === 'string' && Number.isFinite(Number(factors.blast_radius)) && factors.blast_radius.trim() !== '') {
|
|
234
164
|
warnings.push(`blast_radius: numeric string "${factors.blast_radius}" accepted (coerced to ${Number(factors.blast_radius)}); prefer a JSON number`);
|
|
235
165
|
} else {
|
|
@@ -242,9 +172,8 @@ function validateFactors(factors) {
|
|
|
242
172
|
} else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
|
|
243
173
|
warnings.push(`blast_radius: ${factors.blast_radius} out of expected range [0, 30] (clamped to weight ceiling, but the value usually indicates a unit-of-measure mistake)`);
|
|
244
174
|
}
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
// with no diagnostic.
|
|
175
|
+
// An unknown key is surfaced rather than ignored: `patch_avilable` would
|
|
176
|
+
// otherwise default to false with no diagnostic.
|
|
248
177
|
for (const k of Object.keys(factors)) {
|
|
249
178
|
if (!RECOGNISED_FACTOR_KEYS.has(k)) {
|
|
250
179
|
warnings.push(`unknown factor: ${k} (ignored — not in the recognised key set)`);
|
|
@@ -254,24 +183,19 @@ function validateFactors(factors) {
|
|
|
254
183
|
}
|
|
255
184
|
|
|
256
185
|
/**
|
|
257
|
-
*
|
|
258
|
-
* (clamped to [0, 100]).
|
|
186
|
+
* RWEP for a factor bag, clamped to [0, 100].
|
|
259
187
|
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
* scoreCustom returns `{ score, _scoring_warnings }` instead of a bare
|
|
264
|
-
* number. Operators wanting validation without the score can call
|
|
265
|
-
* `validateFactors(factors)` directly.
|
|
188
|
+
* Returns a bare number, which callers depend on. With
|
|
189
|
+
* `opts.collectWarnings` it returns `{ score, _scoring_warnings }` instead;
|
|
190
|
+
* for validation without a score, call `validateFactors()` directly.
|
|
266
191
|
*/
|
|
267
192
|
function scoreCustom(factors, opts) {
|
|
268
193
|
const {
|
|
269
194
|
cisa_kev = false,
|
|
270
195
|
poc_available = false,
|
|
271
196
|
ai_assisted_weapon = false,
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
// factor instead of silently dropping the +15 weight.
|
|
197
|
+
// Catalog field name, accepted as an alias so an entry-derived bag still
|
|
198
|
+
// counts the +15 AI factor.
|
|
275
199
|
ai_assisted_weaponization = false,
|
|
276
200
|
ai_discovered = false,
|
|
277
201
|
active_exploitation = 'none',
|
|
@@ -279,11 +203,8 @@ function scoreCustom(factors, opts) {
|
|
|
279
203
|
patch_available = false,
|
|
280
204
|
live_patch_available = false,
|
|
281
205
|
reboot_required = false,
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
// already aliases at the call site; accept either spelling here so a
|
|
285
|
-
// direct caller passing the catalog entry doesn't silently lose the
|
|
286
|
-
// reboot factor.
|
|
206
|
+
// Likewise: the catalog spells this `patch_required_reboot`, so both
|
|
207
|
+
// spellings are accepted and a direct caller cannot lose the factor.
|
|
287
208
|
patch_required_reboot,
|
|
288
209
|
} = factors || {};
|
|
289
210
|
const rebootFactor = (reboot_required === true) || (patch_required_reboot === true);
|
|
@@ -292,28 +213,15 @@ function scoreCustom(factors, opts) {
|
|
|
292
213
|
score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
|
|
293
214
|
score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
|
|
294
215
|
score += (ai_assisted_weapon || ai_assisted_weaponization || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
// was a no-op for even weights (20/2 = 10) but would have silently
|
|
298
|
-
// truncated to asymmetric results if a future operator bumped the
|
|
299
|
-
// weight to 21. The ladder + multiplication preserves the contribution
|
|
300
|
-
// exactly, including the new `unknown → 0.25 × weight = 5` mapping that
|
|
301
|
-
// aligns the catalog scorer with playbook-runner._activeExploitationLadder.
|
|
216
|
+
// Multiplied through the ladder rather than branched: a weight bumped to an
|
|
217
|
+
// odd number would truncate under a `Math.floor(weight/2)` split.
|
|
302
218
|
const aeMultiplier = activeExploitationMultiplier(active_exploitation);
|
|
303
219
|
score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
// NaN, Infinity, undefined, stringified-number.
|
|
310
|
-
// Match validateFactors' contract exactly. Bare `Number()` coercion would
|
|
311
|
-
// turn `true` into 1 and a single-element array like `[7]` into 7 — both of
|
|
312
|
-
// which validateFactors rejects as "expected number" — so the scorer would
|
|
313
|
-
// silently add a blast contribution the validator says is invalid. Accept
|
|
314
|
-
// only a finite number or a trimmed-nonempty numeric string; everything
|
|
315
|
-
// else (boolean, array, object, NaN, Infinity, empty/whitespace string)
|
|
316
|
-
// contributes 0.
|
|
220
|
+
// Accepts only a finite number or a trimmed non-empty numeric string, which
|
|
221
|
+
// is validateFactors' contract exactly. Bare Number() would turn `true` into
|
|
222
|
+
// 1 and `[7]` into 7 — values the validator rejects — so the scorer would add
|
|
223
|
+
// a contribution the validator calls invalid. NaN also has
|
|
224
|
+
// `typeof === 'number'` and propagates through the final clamp.
|
|
317
225
|
let brRaw = 0;
|
|
318
226
|
if (typeof blast_radius === 'number' && Number.isFinite(blast_radius)) {
|
|
319
227
|
brRaw = blast_radius;
|
|
@@ -330,13 +238,10 @@ function scoreCustom(factors, opts) {
|
|
|
330
238
|
score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
|
|
331
239
|
score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
|
|
332
240
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
// fact that the entry had three mitigating factors).
|
|
241
|
+
// Kept pre-clamp so collectWarnings consumers can see deduction magnitude: a
|
|
242
|
+
// raw -25 collapsed to 0 hides that three mitigating factors applied.
|
|
336
243
|
const rawUnclamped = score;
|
|
337
244
|
|
|
338
|
-
// v0.12.15: defense-in-depth clamp against any unforeseen
|
|
339
|
-
// NaN production above (negative weight + Infinity + math edge case).
|
|
340
245
|
const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
|
|
341
246
|
if (opts && opts.collectWarnings) {
|
|
342
247
|
return {
|
|
@@ -349,75 +254,47 @@ function scoreCustom(factors, opts) {
|
|
|
349
254
|
}
|
|
350
255
|
|
|
351
256
|
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
257
|
+
* RWEP from a `rwep_factors` object of either shape, so the curation
|
|
258
|
+
* apply-path and the auto-discovery builder share one derivation.
|
|
354
259
|
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
* - SHAPE B (catalog post-weight): values are numeric contributions
|
|
359
|
-
* (0 / ±N) plus a numeric blast_radius. Sum the numeric values and
|
|
360
|
-
* clamp to [0, 100]. This is how catalog `rwep_factors` are stored.
|
|
361
|
-
*
|
|
362
|
-
* Heuristic: if every value is a number, treat as Shape B (sum). If any
|
|
363
|
-
* value is boolean or a recognised ladder string, treat as Shape A
|
|
364
|
-
* (scoreCustom). This lets the curation apply-path and the auto-discovery
|
|
365
|
-
* builder share one canonical derivation that handles either operator
|
|
366
|
-
* input style without duplicating the scoring formula.
|
|
260
|
+
* Shape A (booleans, a ladder string, a numeric blast_radius) routes through
|
|
261
|
+
* scoreCustom. Shape B (post-weight contributions, how the catalog stores them)
|
|
262
|
+
* is summed and clamped to [0, 100].
|
|
367
263
|
*/
|
|
368
264
|
function deriveRwepFromFactors(factors) {
|
|
369
265
|
if (!factors || typeof factors !== 'object') return 0;
|
|
370
266
|
const entries = Object.entries(factors);
|
|
371
267
|
if (entries.length === 0) return 0;
|
|
372
|
-
// A boolean
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
// check — that disambiguates them. Excluding it here under-scored an
|
|
379
|
-
// active-exploitation-ONLY raw bag (e.g. `{ active_exploitation: 'confirmed',
|
|
380
|
-
// blast_radius: 10 }`): hasBooleanOrLadder went false, the block fell through
|
|
381
|
-
// to the Shape-B sum, and the ladder string was skipped (10 vs scoreCustom 30).
|
|
268
|
+
// A boolean, or a ladder string, is Shape-A evidence. The ladder string
|
|
269
|
+
// legitimately appears in BOTH shapes — Shape B may carry it as a readable
|
|
270
|
+
// status beside its integers — so hasPostWeightInt below is what
|
|
271
|
+
// disambiguates, not excluding active_exploitation from this check. Excluding
|
|
272
|
+
// it here under-scores a ladder-only bag, which falls through to the sum and
|
|
273
|
+
// skips the string entirely.
|
|
382
274
|
const aeAllowed = new Set(['none', 'unknown', 'suspected', 'theoretical', 'confirmed']);
|
|
383
275
|
const hasBooleanOrLadder = entries.some(
|
|
384
276
|
([, v]) => (typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v.trim().toLowerCase()))),
|
|
385
277
|
);
|
|
386
|
-
// A boolean-named key carrying a post-weight integer (>=5) is
|
|
387
|
-
// Shape
|
|
388
|
-
// a string active_exploitation — route to the post-weight sum, not scoreCustom.
|
|
278
|
+
// A boolean-named key carrying a post-weight integer (>=5) is unambiguously
|
|
279
|
+
// Shape B, even alongside a ladder string.
|
|
389
280
|
const hasPostWeightInt = entries.some(
|
|
390
281
|
([k, v]) => k !== 'blast_radius' && typeof v === 'number' && Number.isFinite(v) && Math.abs(v) >= 5,
|
|
391
282
|
);
|
|
392
283
|
if (hasBooleanOrLadder && !hasPostWeightInt) {
|
|
393
284
|
return scoreCustom(factors);
|
|
394
285
|
}
|
|
395
|
-
// Shape B:
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
// before summing — exactly as scoreCustom does — so an out-of-range stored
|
|
401
|
-
// value (a unit error such as 300, or a negative) cannot silently inflate or
|
|
402
|
-
// zero the result by being absorbed only by the final aggregate clamp. Both
|
|
403
|
-
// paths now produce the same score for the same factors, so the validate()
|
|
404
|
-
// recompute-vs-stored divergence gate stays meaningful instead of flagging a
|
|
405
|
-
// self-inconsistency the two scorers introduced. Every other Shape B value is
|
|
406
|
-
// already a bounded post-weight contribution, so only blast_radius needs the
|
|
407
|
-
// per-factor clamp.
|
|
286
|
+
// Shape B: sum and clamp. blast_radius is the one field needing a per-factor
|
|
287
|
+
// clamp first — it is a raw 0..30 magnitude, not a post-weight contribution,
|
|
288
|
+
// so a unit error like 300 would otherwise reach only the aggregate clamp and
|
|
289
|
+
// make the two scorers disagree, which would show up as a false divergence in
|
|
290
|
+
// validate()'s recompute-vs-stored gate.
|
|
408
291
|
let sum = 0;
|
|
409
292
|
for (const [k, v] of Object.entries(factors)) {
|
|
410
293
|
if (typeof v !== 'number' || !Number.isFinite(v)) continue;
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
// the three scoring surfaces disagreed on what an unknown key means (a sub-5
|
|
416
|
-
// typo silently inflated the derived breakdown). Align them here. The
|
|
417
|
-
// warning mirrors the activeExploitationMultiplier precedent above — an
|
|
418
|
-
// observable diagnostic on the standard Node channel, not a silent skip, so
|
|
419
|
-
// the no-match path surfaces an error instead of defaulting (the file's own
|
|
420
|
-
// "out-of-vocab token -> must surface, not silent-default" rule).
|
|
294
|
+
// An unrecognised key is excluded AND surfaced, matching what scoreCustom
|
|
295
|
+
// and validateFactors do, so the three scoring surfaces agree on what a
|
|
296
|
+
// typo means. Summing it blindly let a sub-5 typo inflate the breakdown
|
|
297
|
+
// with no diagnostic.
|
|
421
298
|
if (!RECOGNISED_POST_WEIGHT_KEYS.has(k)) {
|
|
422
299
|
process.emitWarning(
|
|
423
300
|
`rwep_factors carries unrecognised key '${k}'; excluded from the derived sum`,
|
|
@@ -425,11 +302,8 @@ function deriveRwepFromFactors(factors) {
|
|
|
425
302
|
);
|
|
426
303
|
continue;
|
|
427
304
|
}
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
// both must count it once; summing both double-counts the reboot weight,
|
|
431
|
-
// inflating the derived RWEP past the formula AND past the stored score the
|
|
432
|
-
// validate() divergence gate compares against.
|
|
305
|
+
// Aliases for one contribution; a block carrying both must count it once or
|
|
306
|
+
// the derived score exceeds both the formula and the stored value.
|
|
433
307
|
if (k === 'patch_required_reboot' && Object.prototype.hasOwnProperty.call(factors, 'reboot_required')) continue;
|
|
434
308
|
if (k === 'blast_radius') {
|
|
435
309
|
sum += Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, v));
|
|
@@ -476,99 +350,68 @@ function compare(cveId, catalog, opts) {
|
|
|
476
350
|
const entry = catalog[cveId];
|
|
477
351
|
if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
|
|
478
352
|
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
// the divergence between stored vs. formula-derived scores.
|
|
353
|
+
// `recompute` ignores the stored score and re-derives from rwep_factors,
|
|
354
|
+
// which is how catalog drift against current weights is caught. Routed
|
|
355
|
+
// through the shape detector so hand-edited factors in either shape work.
|
|
483
356
|
const recompute = !!(opts && opts.recompute);
|
|
484
357
|
let rwep;
|
|
485
358
|
if (recompute) {
|
|
486
359
|
const factors = entry.rwep_factors || {};
|
|
487
|
-
// The catalog's rwep_factors shape is "post-weight" (Shape B). Route
|
|
488
|
-
// through the shape-detecting helper so a catalog whose factors were
|
|
489
|
-
// hand-edited in either shape still produces a usable score.
|
|
490
360
|
rwep = deriveRwepFromFactors(factors);
|
|
491
361
|
} else {
|
|
492
362
|
rwep = entry.rwep_score;
|
|
493
363
|
}
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
// entry would otherwise fall through to the "broadly aligned" arm and
|
|
498
|
-
// assert an alignment that was never computed. Treat absent CVSS as
|
|
499
|
-
// not-comparable instead.
|
|
364
|
+
// Absent or non-finite CVSS must not reach `cvss * 10`: NaN fails every band
|
|
365
|
+
// below and falls through to "broadly aligned", asserting an alignment never
|
|
366
|
+
// computed. Absent CVSS is not-comparable, not zero.
|
|
500
367
|
const cvss = (typeof entry.cvss_score === 'number' && Number.isFinite(entry.cvss_score))
|
|
501
368
|
? entry.cvss_score
|
|
502
369
|
: null;
|
|
503
370
|
const cvssAbsent = cvss == null;
|
|
504
371
|
const cvssEquivalent = cvssAbsent ? 0 : cvss * 10;
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
// that does not exist.
|
|
508
|
-
// Guard the RWEP side exactly like CVSS above: an absent or non-finite
|
|
509
|
-
// rwep_score must not flow into `rwep - cvssEquivalent` — NaN poisons the
|
|
510
|
-
// delta, fails every band, and falls through to a false "broadly aligned".
|
|
372
|
+
// Same guard on the RWEP side. delta is null rather than NaN so the result
|
|
373
|
+
// serializes cleanly and never claims a divergence that was not computed.
|
|
511
374
|
const rwepValid = (typeof rwep === 'number' && Number.isFinite(rwep));
|
|
512
375
|
const delta = (cvssAbsent || !rwepValid) ? null : rwep - cvssEquivalent;
|
|
513
376
|
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
// where the
|
|
517
|
-
//
|
|
518
|
-
// ordinary CVSS rounding noise as alignment.
|
|
377
|
+
// The "broadly aligned" band is ±10, the tightest that still reads ordinary
|
|
378
|
+
// CVSS rounding as alignment. A ±20 band swallows a delta of 12, which is
|
|
379
|
+
// exactly the case where the CVSS-calibrated SLA is the thing at issue.
|
|
380
|
+
// A zero-vs-zero entry reports no scoring signal rather than alignment.
|
|
519
381
|
let explanation = '';
|
|
520
|
-
// Surface the "no scoring signal" case distinctly from "broadly
|
|
521
|
-
// aligned". Pre-fix a CVE with rwep_score: 0 AND cvss_score: 0 (e.g.
|
|
522
|
-
// catalog entry created before scoring backfill) printed "broadly
|
|
523
|
-
// aligned" — coincidence-passing per the field-present-not-populated
|
|
524
|
-
// pitfall. Now the operator sees a specific signal pointing at the
|
|
525
|
-
// catalog gap rather than a false sense of alignment.
|
|
526
382
|
if (!rwepValid) {
|
|
527
383
|
explanation = 'RWEP score absent or non-numeric for this CVE — no usable RWEP signal to compare. Backfill rwep_score / rwep_factors in the catalog.';
|
|
528
384
|
} else if ((rwep == null || rwep === 0) && (cvss == null || cvss === 0)) {
|
|
529
385
|
explanation = 'No scoring signal — both RWEP and CVSS are zero/null. Investigate the catalog entry; this CVE has no usable risk score.';
|
|
530
386
|
} else if (cvssAbsent) {
|
|
531
|
-
// RWEP carries a real signal but there is no CVSS to compare it against.
|
|
532
|
-
// The two scores are not comparable, so do not assert alignment or a
|
|
533
|
-
// divergence direction — surface the missing CVSS instead.
|
|
534
387
|
explanation = 'CVSS absent — RWEP is the only usable score for this CVE; no CVSS comparison is possible. Backfill cvss_score in the catalog to enable the comparison.';
|
|
535
388
|
} else if (delta > 10) {
|
|
536
389
|
explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
// reason for the delta.
|
|
390
|
+
// Lists every factor scoreCustom counts, through the same aliases and
|
|
391
|
+
// normalization — otherwise an entry driven by ai_assisted_weaponization, a
|
|
392
|
+
// stray-cased 'Confirmed' or the patch_required_reboot alias shows a raised
|
|
393
|
+
// RWEP with no stated reason.
|
|
542
394
|
const driving = [];
|
|
543
395
|
if (entry.cisa_kev) driving.push('CISA KEV (+25)');
|
|
544
396
|
if (entry.poc_available) driving.push('public PoC (+20)');
|
|
545
397
|
if (entry.ai_discovered || entry.ai_assisted_weaponization) driving.push('AI-discovered (+15 weaponization)');
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
// suspected/unknown driver — so the enumerated factors summed to less than
|
|
550
|
-
// the delta, or (on a purely suspected/blast-driven entry) to nothing,
|
|
551
|
-
// leaving a dangling "driving delta: .".
|
|
398
|
+
// Through the same ladder, so suspected (+10) and unknown (+5) are listed
|
|
399
|
+
// with their real contribution. A confirmed-only test leaves the enumerated
|
|
400
|
+
// factors summing to less than the delta, or to nothing at all.
|
|
552
401
|
const ae = resolveActiveExploitation(entry.active_exploitation);
|
|
553
402
|
if (ae.multiplier > 0) {
|
|
554
403
|
driving.push(`${ae.normalised} exploitation (+${Math.round(RWEP_WEIGHTS.active_exploitation * ae.multiplier)})`);
|
|
555
404
|
}
|
|
556
|
-
// blast_radius contributes its raw
|
|
557
|
-
//
|
|
558
|
-
// stated cause. List the clamped contribution when positive.
|
|
405
|
+
// blast_radius contributes its raw 0..30 value, so a blast-driven delta
|
|
406
|
+
// needs it listed or the raised RWEP has no stated cause.
|
|
559
407
|
const blastRaw = Number((entry.rwep_factors || {}).blast_radius);
|
|
560
408
|
const blast = Number.isFinite(blastRaw) ? Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, blastRaw)) : 0;
|
|
561
409
|
if (blast > 0) driving.push(`blast radius (+${Math.round(blast)})`);
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
// — see the RWEP_WEIGHTS header note). Gating this driver on
|
|
566
|
-
// !live_patch_available made the enumerated factors sum to less than the
|
|
567
|
-
// delta on any entry that both requires a reboot AND has a live patch
|
|
568
|
-
// available, hiding a driver the score actually counted.
|
|
410
|
+
// Ungated by live_patch_available, mirroring scoreCustom's rebootFactor.
|
|
411
|
+
// Gating it here hides a driver the score counted on any entry that both
|
|
412
|
+
// needs a reboot and has a live patch.
|
|
569
413
|
if (entry.reboot_required || entry.patch_required_reboot) driving.push('reboot required (+5)');
|
|
570
|
-
//
|
|
571
|
-
// cause instead of a dangling "driving delta: .".
|
|
414
|
+
// Names the structural cause rather than trailing off after "driving delta:".
|
|
572
415
|
explanation += driving.length ? driving.join(', ') : 'blast magnitude / structural RWEP factors';
|
|
573
416
|
explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
|
|
574
417
|
} else if (delta < -10) {
|
|
@@ -578,8 +421,6 @@ function compare(cveId, catalog, opts) {
|
|
|
578
421
|
if (entry.live_patch_available) mitigating.push('live patch available (-10)');
|
|
579
422
|
if (!entry.poc_available) mitigating.push('no public PoC');
|
|
580
423
|
if (!entry.cisa_kev) mitigating.push('not CISA KEV');
|
|
581
|
-
// A negative delta with no enumerated mitigator still names the structural
|
|
582
|
-
// cause (high CVSS base vs. a modest RWEP) instead of a dangling list.
|
|
583
424
|
explanation += mitigating.length ? mitigating.join(', ') : 'high CVSS base vs. a modest RWEP (low blast radius / no exploitation signal)';
|
|
584
425
|
} else {
|
|
585
426
|
explanation = 'CVSS and RWEP are broadly aligned for this CVE.';
|
|
@@ -602,59 +443,35 @@ function compare(cveId, catalog, opts) {
|
|
|
602
443
|
}
|
|
603
444
|
|
|
604
445
|
/**
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
* Shape A (raw): `{ cisa_kev: true, blast_radius: 30, ... }`
|
|
609
|
-
* - booleans + integers in their natural form
|
|
610
|
-
* - score derives from `scoreCustom(factors)` which applies weights
|
|
611
|
-
*
|
|
612
|
-
* Shape B (post-weight): `{ cisa_kev: 25, blast_radius: 30, ... }`
|
|
613
|
-
* - integers in their post-weight contribution (cisa_kev: 25 not true)
|
|
614
|
-
* - score = sum of values; no second weight pass
|
|
446
|
+
* Which shape an `rwep_factors` block uses: 'A' raw, 'B' post-weight,
|
|
447
|
+
* 'unknown' for empty or ambiguous blocks, 'mixed' for the violating case.
|
|
615
448
|
*
|
|
616
|
-
* Mixing
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
* Until v0.13 nothing caught this; v0.13 adds shape detection that fires
|
|
620
|
-
* an error when the entry mixes booleans with non-trivial numeric weights.
|
|
621
|
-
*
|
|
622
|
-
* Returns 'A' for raw, 'B' for post-weight, 'unknown' for empty/edge
|
|
623
|
-
* cases, or 'mixed' for the violating case.
|
|
449
|
+
* Mixing them inside one entry breaks the sum invariant silently:
|
|
450
|
+
* `{ cisa_kev: true, blast_radius: 30 }` sums to 30 when the intended score is
|
|
451
|
+
* 55, because a raw boolean contributes nothing to a post-weight sum.
|
|
624
452
|
*/
|
|
625
453
|
function detectFactorShape(factors) {
|
|
626
454
|
if (!factors || typeof factors !== 'object') return 'unknown';
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
// schema requires on rwep_factors — ai_factor and reboot_required — so a
|
|
630
|
-
// post-weight integer on either canonical key registers as Shape B
|
|
631
|
-
// evidence instead of slipping past the mixed-shape detector.
|
|
455
|
+
// Both spellings per factor, so a post-weight integer on either canonical key
|
|
456
|
+
// registers as Shape-B evidence rather than slipping past the detector.
|
|
632
457
|
const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weaponization', 'ai_discovered', 'ai_factor', 'active_exploitation', 'patch_available', 'live_patch_available', 'patch_required_reboot', 'reboot_required'];
|
|
633
458
|
let sawBool = false;
|
|
634
459
|
let sawWeightedInt = false;
|
|
635
460
|
for (const [k, v] of Object.entries(factors)) {
|
|
636
461
|
if (k === 'blast_radius') continue; // always integer in both shapes
|
|
637
462
|
if (k === 'active_exploitation' && typeof v === 'string') {
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
// So a string active_exploitation is NOT Shape-A evidence; counting it as
|
|
642
|
-
// sawBool produced a spurious 'mixed' verdict (and a validate() error) on
|
|
643
|
-
// an otherwise-clean Shape B block. Its weight, when summed, is resolved
|
|
644
|
-
// via resolveActiveExploitation in the post-weight path, not here.
|
|
463
|
+
// Valid in both shapes, so it is not Shape-A evidence: counting it would
|
|
464
|
+
// return 'mixed' on a clean Shape-B block. Its weight is resolved in the
|
|
465
|
+
// post-weight path, not here.
|
|
645
466
|
continue;
|
|
646
467
|
}
|
|
647
468
|
if (typeof v === 'boolean' || v === null) {
|
|
648
469
|
sawBool = true;
|
|
649
470
|
} else if (typeof v === 'number' && Math.abs(v) >= 5 && boolFields.includes(k)) {
|
|
650
|
-
|
|
651
|
-
// 20, 15) — Shape B signature.
|
|
652
|
-
sawWeightedInt = true;
|
|
471
|
+
sawWeightedInt = true; // a boolean-named field carrying a weight
|
|
653
472
|
} else if (typeof v === 'number' && (v === 0 || v === 1) && boolFields.includes(k)) {
|
|
654
|
-
// 0/1
|
|
655
|
-
continue;
|
|
473
|
+
continue; // 0/1 fits either shape
|
|
656
474
|
} else if (typeof v === 'string' && boolFields.includes(k)) {
|
|
657
|
-
// String values on OTHER boolean-named fields are Shape A.
|
|
658
475
|
sawBool = true;
|
|
659
476
|
}
|
|
660
477
|
}
|
|
@@ -668,15 +485,11 @@ function validate(catalog) {
|
|
|
668
485
|
const errors = [];
|
|
669
486
|
for (const [cveId, entry] of Object.entries(catalog)) {
|
|
670
487
|
if (cveId.startsWith('_')) continue;
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
// separately via the `_auto_imported_meta.curation_needed` list and
|
|
677
|
-
// the strict catalog validator's draft-warning tier. Once curation
|
|
678
|
-
// promotes
|
|
679
|
-
// an entry, `_auto_imported` is cleared and full validation resumes.
|
|
488
|
+
// Drafts carry a conservative-default rwep_score beside null-until-curated
|
|
489
|
+
// factor fields, so the divergence check below would fire on every one of
|
|
490
|
+
// them. They are reviewed through `_auto_imported_meta.curation_needed` and
|
|
491
|
+
// the validator's draft tier instead; clearing `_auto_imported` at curation
|
|
492
|
+
// restores full validation.
|
|
680
493
|
if (entry && entry._auto_imported === true) continue;
|
|
681
494
|
for (const field of CVE_SCHEMA_REQUIRED) {
|
|
682
495
|
if (!(field in entry)) {
|
|
@@ -689,19 +502,14 @@ function validate(catalog) {
|
|
|
689
502
|
if (entry.live_patch_available && (!entry.live_patch_tools || entry.live_patch_tools.length === 0)) {
|
|
690
503
|
errors.push(`${cveId}: live_patch_available=true but live_patch_tools is empty`);
|
|
691
504
|
}
|
|
692
|
-
// v0.13.0: detect Shape A / Shape B / mixed factor shape. A 'mixed'
|
|
693
|
-
// shape would silently break the sum invariant; refuse it. See
|
|
694
|
-
// detectFactorShape() doc above for the failure mode.
|
|
695
505
|
const shape = detectFactorShape(entry.rwep_factors);
|
|
696
506
|
if (shape === 'mixed') {
|
|
697
507
|
errors.push(`${cveId}: rwep_factors mixes Shape A (booleans) with Shape B (post-weight integers) — sum invariant cannot hold. Convert factors to a single shape.`);
|
|
698
508
|
}
|
|
699
|
-
// Per-factor coherence:
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
// entry's own flags ships unnoticed. blast_radius is exempt — it is the
|
|
704
|
-
// one judgment-set factor with no deriving source field.
|
|
509
|
+
// Per-factor coherence: every Shape-B contribution must equal the weight
|
|
510
|
+
// its source field implies, because two compensating errors cancel inside
|
|
511
|
+
// the ±5 aggregate tolerance below. blast_radius is exempt — it is the one
|
|
512
|
+
// judgment-set factor with no deriving field.
|
|
705
513
|
if (shape === 'B') {
|
|
706
514
|
const f = entry.rwep_factors;
|
|
707
515
|
const aeMultiplier = resolveActiveExploitation(entry.active_exploitation).multiplier;
|
|
@@ -718,12 +526,8 @@ function validate(catalog) {
|
|
|
718
526
|
errors.push(`${cveId}: rwep_factors.${k} is ${f[k]} but the entry's source fields imply ${want}`);
|
|
719
527
|
}
|
|
720
528
|
}
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
// (the catalog field-name alias scoreCustom also honors). Check both
|
|
724
|
-
// against that single weight; keying only on `reboot_required` let a
|
|
725
|
-
// contradictory value stored under the alias slip past the coherence
|
|
726
|
-
// gate, inflating the derived score.
|
|
529
|
+
// One implied weight, two accepted spellings. Both are checked, or a
|
|
530
|
+
// contradictory value stored under the alias passes the coherence gate.
|
|
727
531
|
const rebootWant = entry.patch_required_reboot === true ? RWEP_WEIGHTS.reboot_required : 0;
|
|
728
532
|
for (const rebootKey of ['reboot_required', 'patch_required_reboot']) {
|
|
729
533
|
if (rebootKey in f && typeof f[rebootKey] === 'number' && f[rebootKey] !== rebootWant) {
|
|
@@ -740,9 +544,8 @@ function validate(catalog) {
|
|
|
740
544
|
blast_radius: entry.rwep_factors ? entry.rwep_factors.blast_radius : 0,
|
|
741
545
|
patch_available: entry.patch_available,
|
|
742
546
|
live_patch_available: entry.live_patch_available,
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
// top-level reboot_required field and compute a divergent expected RWEP.
|
|
547
|
+
// Both spellings, mirroring scoreCustom: passing only one drops the other
|
|
548
|
+
// and computes a divergent expected score.
|
|
746
549
|
reboot_required: entry.reboot_required || entry.patch_required_reboot
|
|
747
550
|
});
|
|
748
551
|
if (Math.abs(calculatedRwep - entry.rwep_score) > 5) {
|
|
@@ -753,25 +556,16 @@ function validate(catalog) {
|
|
|
753
556
|
}
|
|
754
557
|
|
|
755
558
|
/**
|
|
756
|
-
* Strict CVSS 3.
|
|
559
|
+
* Strict CVSS 3.x vector parse, as `{ ok, version, reason? }`.
|
|
757
560
|
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
* truncated metric sets, and unknown environmental-metric values, which
|
|
763
|
-
* downstream tooling then rejected wholesale.
|
|
561
|
+
* Strict CSAF validators reject a document whose cvss_v3 block is keyed off a
|
|
562
|
+
* malformed vector, so a permissive parse here fails downstream rather than
|
|
563
|
+
* here. Mandatory metrics in order are AV/AC/PR/UI/S/C/I/A; E/RL/RC and the
|
|
564
|
+
* CR..MA environmental set are optional.
|
|
764
565
|
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
* Optional environmental metrics: CR / IR / AR / MAV / MAC / MPR / MUI /
|
|
768
|
-
* MS / MC / MI / MA.
|
|
566
|
+
* 3.0 and 3.1 share one grammar and differ only in the prefix, and CSAF 2.0
|
|
567
|
+
* accepts both, so the version is recorded rather than rejected.
|
|
769
568
|
*/
|
|
770
|
-
// CVSS 3.0 and 3.1 share an identical vector grammar (metric set, value enums,
|
|
771
|
-
// and metric order are the same; only the `CVSS:X.Y/` prefix differs). CSAF
|
|
772
|
-
// 2.0 §3.2.4.3 accepts both versions in the cvss_v3 block. The strict regex
|
|
773
|
-
// matches either prefix; the parser records which version the vector declared
|
|
774
|
-
// so the emitter can stamp the right `version` field.
|
|
775
569
|
const CVSS_3X_RE = /^CVSS:3\.[01]\/AV:[NALP]\/AC:[LH]\/PR:[NLH]\/UI:[NR]\/S:[UC]\/C:[NLH]\/I:[NLH]\/A:[NLH](\/E:[XUPFH])?(\/RL:[XOTWU])?(\/RC:[XURC])?(\/CR:[XLMH])?(\/IR:[XLMH])?(\/AR:[XLMH])?(\/MAV:[XNALP])?(\/MAC:[XLH])?(\/MPR:[XNLH])?(\/MUI:[XNR])?(\/MS:[XUC])?(\/MC:[XNLH])?(\/MI:[XNLH])?(\/MA:[XNLH])?$/;
|
|
776
570
|
|
|
777
571
|
function parseCvss31Vector(v) {
|
|
@@ -793,25 +587,17 @@ function parseCvss31Vector(v) {
|
|
|
793
587
|
}
|
|
794
588
|
|
|
795
589
|
/**
|
|
796
|
-
* Package-Confidence Score
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
* Polarity is the INVERSE of RWEP: high PCS = trustworthy provenance/behaviour;
|
|
800
|
-
* low PCS = behaves like malware. RWEP answers "how urgently must I act on this
|
|
801
|
-
* known vulnerability"; PCS answers "how much do I trust this package's
|
|
802
|
-
* provenance/behaviour, independent of any CVE." The two are different axes and
|
|
803
|
-
* must never be summed or compared numerically (the `package_confidence.polarity:
|
|
804
|
-
* "trust"` const on catalog entries exists to assert direction before display).
|
|
590
|
+
* Package-Confidence Score: a supplementary 0-100 supply-chain trust signal
|
|
591
|
+
* shown alongside RWEP, never instead of it. Returns null with no usable input.
|
|
805
592
|
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
593
|
+
* Its polarity is the INVERSE of RWEP — high means trustworthy provenance, low
|
|
594
|
+
* means it behaves like malware — so the two must never be summed or compared.
|
|
595
|
+
* It is deliberately outside the RWEP factor key set and is never called from
|
|
596
|
+
* validate(), scoreCustom() or deriveRwepFromFactors(), so it cannot perturb a
|
|
597
|
+
* stored rwep_score or trip the divergence gate.
|
|
810
598
|
*
|
|
811
|
-
* Equal-weight mean of
|
|
812
|
-
*
|
|
813
|
-
* are skipped (not treated as 0) so a partially-curated entry isn't punished
|
|
814
|
-
* for un-assessed dimensions. Returns null when no usable input is present.
|
|
599
|
+
* Equal-weight mean of whichever sub-signals are present; an absent dimension is
|
|
600
|
+
* skipped rather than scored 0, so a partly-curated entry is not punished.
|
|
815
601
|
*/
|
|
816
602
|
function packageConfidence(inputs) {
|
|
817
603
|
if (!inputs || typeof inputs !== 'object') return null;
|