@blamejs/exceptd-skills 0.12.15 → 0.12.16
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 +67 -0
- package/bin/exceptd.js +377 -20
- package/data/_indexes/_meta.json +3 -3
- package/data/cve-catalog.json +1 -1
- package/data/playbooks/ai-api.json +3 -1
- package/data/playbooks/containers.json +11 -3
- package/data/playbooks/cred-stores.json +3 -1
- package/data/playbooks/crypto-codebase.json +11 -11
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/hardening.json +3 -1
- package/data/playbooks/kernel.json +3 -1
- package/data/playbooks/library-author.json +21 -10
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +3 -1
- package/data/playbooks/sbom.json +2 -2
- package/data/playbooks/secrets.json +3 -1
- package/lib/auto-discovery.js +36 -31
- package/lib/cve-curation.js +15 -9
- package/lib/prefetch.js +30 -8
- package/lib/refresh-network.js +40 -0
- package/lib/scoring.js +171 -11
- package/lib/validate-playbooks.js +46 -0
- package/manifest.json +39 -39
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/verify-shipped-tarball.js +35 -6
package/lib/scoring.js
CHANGED
|
@@ -3,6 +3,40 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* RWEP — Real-World Exploit Priority scoring engine
|
|
5
5
|
* Supplements CVSS with exploit availability, active exploitation, and operational constraints.
|
|
6
|
+
*
|
|
7
|
+
* ----------------------------------------------------------------------------
|
|
8
|
+
* `rwep_factors` dual-semantics (audit J F2)
|
|
9
|
+
* ----------------------------------------------------------------------------
|
|
10
|
+
* Catalog entries (data/cve-catalog.json) store `rwep_factors` as an object
|
|
11
|
+
* whose values are POST-WEIGHT CONTRIBUTIONS for boolean / ladder factors
|
|
12
|
+
* but the RAW BLAST RADIUS for `blast_radius`. The two shapes coexist because
|
|
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
|
+
* ----------------------------------------------------------------------------
|
|
6
40
|
*/
|
|
7
41
|
|
|
8
42
|
const CVE_SCHEMA_REQUIRED = [
|
|
@@ -28,6 +62,29 @@ const RWEP_WEIGHTS = {
|
|
|
28
62
|
reboot_required: 5
|
|
29
63
|
};
|
|
30
64
|
|
|
65
|
+
// audit J F4: active_exploitation ladder. Aligned with playbook-runner's
|
|
66
|
+
// _activeExploitationLadder so the catalog scorer and the runtime evaluator
|
|
67
|
+
// produce identical results for the same string value. 'unknown' contributes
|
|
68
|
+
// a quarter of the confirmed weight (5 points) — operationally "we have not
|
|
69
|
+
// confirmed, but absence of evidence is not evidence of absence; do not
|
|
70
|
+
// score zero on a fresh CVE that hasn't been triaged yet".
|
|
71
|
+
const ACTIVE_EXPLOITATION_LADDER = {
|
|
72
|
+
confirmed: 1.0,
|
|
73
|
+
suspected: 0.5,
|
|
74
|
+
unknown: 0.25,
|
|
75
|
+
none: 0,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// The canonical set of factor keys scoreCustom recognises. Used by
|
|
79
|
+
// validateFactors to flag unknown keys (audit J F8).
|
|
80
|
+
const RECOGNISED_FACTOR_KEYS = new Set([
|
|
81
|
+
'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
|
|
82
|
+
'active_exploitation', 'blast_radius', 'patch_available',
|
|
83
|
+
'live_patch_available', 'reboot_required',
|
|
84
|
+
// accepted alias for the catalog field name
|
|
85
|
+
'patch_required_reboot',
|
|
86
|
+
]);
|
|
87
|
+
|
|
31
88
|
function score(cveId, catalog) {
|
|
32
89
|
const entry = catalog[cveId];
|
|
33
90
|
if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
|
|
@@ -68,13 +125,29 @@ function validateFactors(factors) {
|
|
|
68
125
|
} else if (!aeAllowed.includes(factors.active_exploitation)) {
|
|
69
126
|
warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
|
|
70
127
|
}
|
|
128
|
+
// audit J F6: NaN diagnostics. The prior message read "expected number,
|
|
129
|
+
// got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
|
|
130
|
+
// NaN === 'number'`. Number.isFinite catches NaN + Infinity + -Infinity
|
|
131
|
+
// and emits a useful message.
|
|
71
132
|
if (factors.blast_radius === undefined || factors.blast_radius === null) {
|
|
72
133
|
warnings.push('blast_radius: missing (treated as 0)');
|
|
73
|
-
} else if (typeof factors.blast_radius !== 'number'
|
|
134
|
+
} else if (typeof factors.blast_radius !== 'number') {
|
|
74
135
|
warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
|
|
136
|
+
} else if (Number.isNaN(factors.blast_radius)) {
|
|
137
|
+
warnings.push('blast_radius: NaN is not a valid numeric value (treated as 0)');
|
|
138
|
+
} else if (!Number.isFinite(factors.blast_radius)) {
|
|
139
|
+
warnings.push(`blast_radius: ${factors.blast_radius > 0 ? 'Infinity' : '-Infinity'} is not a finite numeric value (treated as 0)`);
|
|
75
140
|
} else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
|
|
76
141
|
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)`);
|
|
77
142
|
}
|
|
143
|
+
// audit J F8: surface unknown factor keys so a typo'd answer file
|
|
144
|
+
// (`patch_avilable`, `cisa-kev`, etc.) doesn't silently default to false
|
|
145
|
+
// with no diagnostic.
|
|
146
|
+
for (const k of Object.keys(factors)) {
|
|
147
|
+
if (!RECOGNISED_FACTOR_KEYS.has(k)) {
|
|
148
|
+
warnings.push(`unknown factor: ${k} (ignored — not in the recognised key set)`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
78
151
|
return warnings;
|
|
79
152
|
}
|
|
80
153
|
|
|
@@ -113,8 +186,15 @@ function scoreCustom(factors, opts) {
|
|
|
113
186
|
score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
|
|
114
187
|
score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
|
|
115
188
|
score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
|
|
116
|
-
|
|
117
|
-
|
|
189
|
+
// audit J F4 + F16: active_exploitation goes through the ladder rather
|
|
190
|
+
// than two hand-written branches with `Math.floor(weight/2)`. The floor
|
|
191
|
+
// was a no-op for even weights (20/2 = 10) but would have silently
|
|
192
|
+
// truncated to asymmetric results if a future operator bumped the
|
|
193
|
+
// weight to 21. The ladder + multiplication preserves the contribution
|
|
194
|
+
// exactly, including the new `unknown → 0.25 × weight = 5` mapping that
|
|
195
|
+
// aligns the catalog scorer with playbook-runner._activeExploitationLadder.
|
|
196
|
+
const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[active_exploitation] ?? 0;
|
|
197
|
+
score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
|
|
118
198
|
// v0.12.15 (audit J F1, F5): blast_radius numeric coercion must reject
|
|
119
199
|
// NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
|
|
120
200
|
// check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
|
|
@@ -128,15 +208,60 @@ function scoreCustom(factors, opts) {
|
|
|
128
208
|
score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
|
|
129
209
|
score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
|
|
130
210
|
|
|
211
|
+
// audit J F10: keep the pre-clamp value so collectWarnings consumers can
|
|
212
|
+
// see deduction magnitude (e.g. a -25 raw score collapsed to 0 hides the
|
|
213
|
+
// fact that the entry had three mitigating factors).
|
|
214
|
+
const rawUnclamped = score;
|
|
215
|
+
|
|
131
216
|
// v0.12.15 (audit J F1): defense-in-depth clamp against any unforeseen
|
|
132
217
|
// NaN production above (negative weight + Infinity + math edge case).
|
|
133
218
|
const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
|
|
134
219
|
if (opts && opts.collectWarnings) {
|
|
135
|
-
return {
|
|
220
|
+
return {
|
|
221
|
+
score: clamped,
|
|
222
|
+
_rwep_raw_unclamped: Number.isFinite(rawUnclamped) ? rawUnclamped : null,
|
|
223
|
+
_scoring_warnings: validateFactors(factors),
|
|
224
|
+
};
|
|
136
225
|
}
|
|
137
226
|
return clamped;
|
|
138
227
|
}
|
|
139
228
|
|
|
229
|
+
/**
|
|
230
|
+
* audit J F3 + audit M P1-C bridge: derive an RWEP score from a
|
|
231
|
+
* `rwep_factors` object regardless of which shape it uses.
|
|
232
|
+
*
|
|
233
|
+
* - SHAPE A (boolean / string-ladder): values are booleans + an
|
|
234
|
+
* active_exploitation string + a numeric blast_radius. Route through
|
|
235
|
+
* scoreCustom() — the canonical formula.
|
|
236
|
+
* - SHAPE B (catalog post-weight): values are numeric contributions
|
|
237
|
+
* (0 / ±N) plus a numeric blast_radius. Sum the numeric values and
|
|
238
|
+
* clamp to [0, 100]. This is how catalog `rwep_factors` are stored.
|
|
239
|
+
*
|
|
240
|
+
* Heuristic: if every value is a number, treat as Shape B (sum). If any
|
|
241
|
+
* value is boolean or a recognised ladder string, treat as Shape A
|
|
242
|
+
* (scoreCustom). This lets the curation apply-path and the auto-discovery
|
|
243
|
+
* builder share one canonical derivation that handles either operator
|
|
244
|
+
* input style without duplicating the scoring formula.
|
|
245
|
+
*/
|
|
246
|
+
function deriveRwepFromFactors(factors) {
|
|
247
|
+
if (!factors || typeof factors !== 'object') return 0;
|
|
248
|
+
const values = Object.values(factors);
|
|
249
|
+
if (values.length === 0) return 0;
|
|
250
|
+
const aeAllowed = new Set(['none', 'unknown', 'suspected', 'confirmed']);
|
|
251
|
+
const hasBooleanOrLadder = values.some(
|
|
252
|
+
(v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v)),
|
|
253
|
+
);
|
|
254
|
+
if (hasBooleanOrLadder) {
|
|
255
|
+
return scoreCustom(factors);
|
|
256
|
+
}
|
|
257
|
+
// Shape B: catalog post-weight. Sum + clamp.
|
|
258
|
+
let sum = 0;
|
|
259
|
+
for (const v of values) {
|
|
260
|
+
if (typeof v === 'number' && Number.isFinite(v)) sum += v;
|
|
261
|
+
}
|
|
262
|
+
return Math.max(0, Math.min(100, sum));
|
|
263
|
+
}
|
|
264
|
+
|
|
140
265
|
function timeline(rwepScore) {
|
|
141
266
|
if (rwepScore >= 90) return { hours: 4, label: 'Immediate — live patch or isolate within 4 hours' };
|
|
142
267
|
if (rwepScore >= 75) return { hours: 24, label: 'Urgent — patch or compensating controls within 24 hours' };
|
|
@@ -146,17 +271,36 @@ function timeline(rwepScore) {
|
|
|
146
271
|
return { hours: null, label: 'Low — next scheduled maintenance' };
|
|
147
272
|
}
|
|
148
273
|
|
|
149
|
-
function compare(cveId, catalog) {
|
|
274
|
+
function compare(cveId, catalog, opts) {
|
|
150
275
|
const entry = catalog[cveId];
|
|
151
276
|
if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
|
|
152
277
|
|
|
153
|
-
|
|
278
|
+
// audit J F11: `--recompute` ignores the stored rwep_score and forces a
|
|
279
|
+
// fresh computation from rwep_factors. Useful for catching catalog drift
|
|
280
|
+
// (stored score grew stale relative to current weights) and for auditing
|
|
281
|
+
// the divergence between stored vs. formula-derived scores.
|
|
282
|
+
const recompute = !!(opts && opts.recompute);
|
|
283
|
+
let rwep;
|
|
284
|
+
if (recompute) {
|
|
285
|
+
const factors = entry.rwep_factors || {};
|
|
286
|
+
// The catalog's rwep_factors shape is "post-weight" (Shape B). Route
|
|
287
|
+
// through the shape-detecting helper so a catalog whose factors were
|
|
288
|
+
// hand-edited in either shape still produces a usable score.
|
|
289
|
+
rwep = deriveRwepFromFactors(factors);
|
|
290
|
+
} else {
|
|
291
|
+
rwep = entry.rwep_score;
|
|
292
|
+
}
|
|
154
293
|
const cvss = entry.cvss_score;
|
|
155
294
|
const cvssEquivalent = cvss * 10;
|
|
156
295
|
const delta = rwep - cvssEquivalent;
|
|
157
296
|
|
|
297
|
+
// audit J F15: narrow the "broadly aligned" band from ±20 to ±10. The old
|
|
298
|
+
// ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
|
|
299
|
+
// where the operator-facing point is precisely that the CVSS-calibrated
|
|
300
|
+
// SLA is insufficient. ±10 is the tightest classifier that still treats
|
|
301
|
+
// ordinary CVSS rounding noise as alignment.
|
|
158
302
|
let explanation = '';
|
|
159
|
-
if (delta >
|
|
303
|
+
if (delta > 10) {
|
|
160
304
|
explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
|
|
161
305
|
const driving = [];
|
|
162
306
|
if (entry.cisa_kev) driving.push('CISA KEV (+25)');
|
|
@@ -166,7 +310,7 @@ function compare(cveId, catalog) {
|
|
|
166
310
|
if (entry.patch_required_reboot && !entry.live_patch_available) driving.push('reboot required (+5)');
|
|
167
311
|
explanation += driving.join(', ');
|
|
168
312
|
explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
|
|
169
|
-
} else if (delta < -
|
|
313
|
+
} else if (delta < -10) {
|
|
170
314
|
explanation = `RWEP lower than CVSS equivalent. Mitigating factors: `;
|
|
171
315
|
const mitigating = [];
|
|
172
316
|
if (entry.patch_available) mitigating.push('patch available (-15)');
|
|
@@ -178,15 +322,20 @@ function compare(cveId, catalog) {
|
|
|
178
322
|
explanation = 'CVSS and RWEP are broadly aligned for this CVE.';
|
|
179
323
|
}
|
|
180
324
|
|
|
181
|
-
|
|
325
|
+
const out = {
|
|
182
326
|
cve_id: cveId,
|
|
183
327
|
cvss: cvss,
|
|
184
328
|
rwep: rwep,
|
|
185
329
|
cvss_framework_sla: timeline(cvssEquivalent),
|
|
186
330
|
rwep_actual_sla: timeline(rwep),
|
|
187
331
|
delta,
|
|
188
|
-
explanation
|
|
332
|
+
explanation,
|
|
189
333
|
};
|
|
334
|
+
if (recompute) {
|
|
335
|
+
out.stored_rwep_score = entry.rwep_score;
|
|
336
|
+
out.recomputed = true;
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
190
339
|
}
|
|
191
340
|
|
|
192
341
|
function validate(catalog) {
|
|
@@ -222,4 +371,15 @@ function validate(catalog) {
|
|
|
222
371
|
return errors;
|
|
223
372
|
}
|
|
224
373
|
|
|
225
|
-
module.exports = {
|
|
374
|
+
module.exports = {
|
|
375
|
+
score,
|
|
376
|
+
scoreCustom,
|
|
377
|
+
timeline,
|
|
378
|
+
compare,
|
|
379
|
+
validate,
|
|
380
|
+
validateFactors,
|
|
381
|
+
deriveRwepFromFactors,
|
|
382
|
+
RWEP_WEIGHTS,
|
|
383
|
+
ACTIVE_EXPLOITATION_LADDER,
|
|
384
|
+
RECOGNISED_FACTOR_KEYS,
|
|
385
|
+
};
|
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
* govern.jurisdiction_obligations[] (the schema does not give
|
|
32
32
|
* jurisdiction_obligations an explicit `id` field; the shipped playbooks
|
|
33
33
|
* reference them by this composite string).
|
|
34
|
+
* - _meta.mutex is symmetric across the whole playbook set: if A lists B,
|
|
35
|
+
* B must list A. Asymmetry surfaces as a warning in v0.12.16 (and will
|
|
36
|
+
* flip to error in v0.13.0) — see checkMutexReciprocity().
|
|
34
37
|
*
|
|
35
38
|
* Finding severity:
|
|
36
39
|
* - error — structural problems that block the runner (missing required
|
|
@@ -397,6 +400,44 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
397
400
|
return findings;
|
|
398
401
|
}
|
|
399
402
|
|
|
403
|
+
/* Cross-playbook mutex-reciprocity check.
|
|
404
|
+
*
|
|
405
|
+
* `_meta.mutex` is a symmetric relation: if playbook A lists B, B must list A.
|
|
406
|
+
* Asymmetry is a latent runner bug — the engine's mutex enforcement only
|
|
407
|
+
* blocks concurrent execution from whichever side declared the conflict, so
|
|
408
|
+
* an asymmetric declaration silently degrades to a race condition when the
|
|
409
|
+
* undeclared side is started first.
|
|
410
|
+
*
|
|
411
|
+
* Emits one warning per asymmetric pair (keyed off the side that declares
|
|
412
|
+
* the edge). v0.12.16 keeps this at warning severity per the patch-class
|
|
413
|
+
* cadence; v0.13.0 will flip it to error via --strict / predeploy
|
|
414
|
+
* `informational: false`.
|
|
415
|
+
*/
|
|
416
|
+
function checkMutexReciprocity(playbooks) {
|
|
417
|
+
const findings = [];
|
|
418
|
+
const mutexMap = new Map();
|
|
419
|
+
for (const pb of playbooks) {
|
|
420
|
+
if (!pb.data || !pb.data._meta || !pb.data._meta.id) continue;
|
|
421
|
+
const id = pb.data._meta.id;
|
|
422
|
+
const mutex = Array.isArray(pb.data._meta.mutex) ? pb.data._meta.mutex : [];
|
|
423
|
+
mutexMap.set(id, new Set(mutex));
|
|
424
|
+
}
|
|
425
|
+
const byPlaybook = new Map(); // playbookId -> array of warning messages
|
|
426
|
+
for (const [id, mset] of mutexMap.entries()) {
|
|
427
|
+
for (const other of mset) {
|
|
428
|
+
const otherSet = mutexMap.get(other);
|
|
429
|
+
if (!otherSet) continue; // unresolved-id warning is already emitted by checkCrossRefs
|
|
430
|
+
if (!otherSet.has(id)) {
|
|
431
|
+
const msg = `_meta.mutex: asymmetric mutex with "${other}" — "${other}" does not list "${id}" in its _meta.mutex. v0.13.0 will flip this to a hard error.`;
|
|
432
|
+
if (!byPlaybook.has(id)) byPlaybook.set(id, []);
|
|
433
|
+
byPlaybook.get(id).push(msg);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
findings.push(byPlaybook);
|
|
438
|
+
return byPlaybook;
|
|
439
|
+
}
|
|
440
|
+
|
|
400
441
|
function main() {
|
|
401
442
|
const opts = parseArgs(process.argv);
|
|
402
443
|
const schema = readJson(SCHEMA_PATH);
|
|
@@ -408,6 +449,7 @@ function main() {
|
|
|
408
449
|
playbookIds.add(pb.data._meta.id);
|
|
409
450
|
}
|
|
410
451
|
}
|
|
452
|
+
const mutexAsymmetries = checkMutexReciprocity(playbooks);
|
|
411
453
|
|
|
412
454
|
let errored = 0;
|
|
413
455
|
let warned = 0;
|
|
@@ -425,6 +467,9 @@ function main() {
|
|
|
425
467
|
...validate(pb.data, schema, 'playbook', label),
|
|
426
468
|
...checkCrossRefs(pb.data, ctx, playbookIds),
|
|
427
469
|
];
|
|
470
|
+
const reciprocityMsgs =
|
|
471
|
+
(pb.data && pb.data._meta && mutexAsymmetries.get(pb.data._meta.id)) || [];
|
|
472
|
+
for (const m of reciprocityMsgs) findings.push({ severity: 'warning', message: m });
|
|
428
473
|
const effective = opts.strict
|
|
429
474
|
? findings.map((f) => ({ ...f, severity: 'error' }))
|
|
430
475
|
: findings;
|
|
@@ -459,6 +504,7 @@ function main() {
|
|
|
459
504
|
module.exports = {
|
|
460
505
|
validate,
|
|
461
506
|
checkCrossRefs,
|
|
507
|
+
checkMutexReciprocity,
|
|
462
508
|
loadContext,
|
|
463
509
|
loadPlaybooks,
|
|
464
510
|
obligationKey,
|