@blamejs/exceptd-skills 0.19.34 → 0.19.36
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 +28 -0
- package/bin/exceptd.js +0 -5
- package/data/_indexes/_meta.json +4 -4
- package/data/cve-catalog.json +24 -24
- package/data/zeroday-lessons.json +1249 -1249
- package/lib/collectors/ai-api.js +150 -22
- package/lib/collectors/cicd-pipeline-compromise.js +73 -28
- package/lib/collectors/library-author.js +141 -5
- package/lib/collectors/sbom.js +96 -12
- package/lib/collectors/scan-excludes.js +3 -2
- package/lib/cve-regression-watcher.js +0 -3
- package/lib/framework-gap.js +5 -0
- package/lib/lint-skills.js +24 -4
- package/lib/playbook-runner.js +68 -14
- package/lib/prefetch.js +3 -2
- package/lib/refresh-external.js +0 -6
- package/lib/refresh-network.js +3 -4
- package/lib/scoring.js +8 -1
- package/lib/ttp-mapper.js +14 -3
- package/lib/upstream-check-cli.js +26 -1
- package/lib/validate-cve-catalog.js +9 -2
- package/lib/validate-playbooks.js +9 -11
- package/manifest.json +53 -53
- package/orchestrator/index.js +0 -1
- package/package.json +1 -1
- package/sbom.cdx.json +82 -82
- package/scripts/audit-perf.js +24 -13
- package/scripts/builders/theater-fingerprints.js +9 -4
- package/scripts/check-agents-md-collectors.js +15 -3
- package/scripts/check-codebase-patterns.js +16 -3
- package/scripts/check-manifest-snapshot.js +49 -8
- package/scripts/check-test-coverage.js +17 -1
- package/scripts/refresh-mitre-atlas.js +5 -1
- package/scripts/refresh-mitre-ics-attack.js +5 -1
- package/scripts/refresh-rfc-index.js +6 -1
- package/scripts/refresh-upstream-catalogs.js +25 -13
- package/scripts/release.js +0 -2
- package/scripts/run-e2e-scenarios.js +2 -2
- package/scripts/verify-shipped-tarball.js +0 -1
package/lib/collectors/sbom.js
CHANGED
|
@@ -172,13 +172,24 @@ function captureLockfile(p, ecosystem, parser, label) {
|
|
|
172
172
|
path: p,
|
|
173
173
|
size_bytes: Buffer.byteLength(content, "utf8"),
|
|
174
174
|
...stats,
|
|
175
|
+
// A parser that could not decode the structure returns `error`; the read
|
|
176
|
+
// itself succeeded, so the two failures carry different kinds downstream.
|
|
177
|
+
...(stats && stats.error ? { error_kind: "lockfile_parse_failed" } : {}),
|
|
175
178
|
};
|
|
176
179
|
} catch (e) {
|
|
177
|
-
return { file: label, ecosystem, path: p, error: e.message };
|
|
180
|
+
return { file: label, ecosystem, path: p, error: e.message, error_kind: "lockfile_read_failed" };
|
|
178
181
|
}
|
|
179
182
|
}
|
|
180
183
|
|
|
181
|
-
|
|
184
|
+
// `errors` is the collector_errors channel: a directory the walk could not read
|
|
185
|
+
// is a scan gap the operator has to see, not a silent zero. Required, not
|
|
186
|
+
// defaulted: a default array would restore the discard-into-the-void behaviour
|
|
187
|
+
// for any caller that forgets it, and neither this function nor
|
|
188
|
+
// findSbomDocuments is exported, so no test could reach that caller.
|
|
189
|
+
function findLockfiles(cwd, errors) {
|
|
190
|
+
if (!Array.isArray(errors)) {
|
|
191
|
+
throw new TypeError("findLockfiles: `errors` must be the collector_errors array — scan gaps have nowhere else to go");
|
|
192
|
+
}
|
|
182
193
|
const found = [];
|
|
183
194
|
for (const lf of LOCKFILES) {
|
|
184
195
|
const p = path.join(cwd, lf.file);
|
|
@@ -196,7 +207,13 @@ function findLockfiles(cwd) {
|
|
|
196
207
|
}
|
|
197
208
|
}
|
|
198
209
|
}
|
|
199
|
-
} catch {
|
|
210
|
+
} catch (e) {
|
|
211
|
+
errors.push({
|
|
212
|
+
artifact_id: "lockfile-inventory",
|
|
213
|
+
kind: "readdir_failed",
|
|
214
|
+
reason: `cwd root: ${e.message} — requirements*.txt variants were not enumerated`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
200
217
|
|
|
201
218
|
for (const sub of SUBDIR_PROBE_PATHS) {
|
|
202
219
|
const subDir = path.join(cwd, sub);
|
|
@@ -204,7 +221,18 @@ function findLockfiles(cwd) {
|
|
|
204
221
|
try {
|
|
205
222
|
if (!fs.statSync(subDir).isDirectory()) continue;
|
|
206
223
|
entries = fs.readdirSync(subDir, { withFileTypes: true });
|
|
207
|
-
} catch {
|
|
224
|
+
} catch (e) {
|
|
225
|
+
// An absent probe dir is the normal case, not a gap. A permission or I/O
|
|
226
|
+
// failure on one that IS there means the subtree went unscanned.
|
|
227
|
+
if (e.code !== "ENOENT") {
|
|
228
|
+
errors.push({
|
|
229
|
+
artifact_id: "lockfile-inventory",
|
|
230
|
+
kind: "readdir_failed",
|
|
231
|
+
reason: `${sub}/: ${e.message} — subtree not scanned for lockfiles`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
208
236
|
for (const e of entries) {
|
|
209
237
|
if (e.isDirectory()) {
|
|
210
238
|
// packages/* gets one more level for monorepo workspaces, no deeper.
|
|
@@ -242,7 +270,11 @@ function findLockfiles(cwd) {
|
|
|
242
270
|
return found;
|
|
243
271
|
}
|
|
244
272
|
|
|
245
|
-
|
|
273
|
+
// `errors` is required for the same reason it is on findLockfiles above.
|
|
274
|
+
function findSbomDocuments(cwd, errors) {
|
|
275
|
+
if (!Array.isArray(errors)) {
|
|
276
|
+
throw new TypeError("findSbomDocuments: `errors` must be the collector_errors array — scan gaps have nowhere else to go");
|
|
277
|
+
}
|
|
246
278
|
const found = [];
|
|
247
279
|
for (const s of SBOM_FORMATS) {
|
|
248
280
|
const p = path.join(cwd, s.file);
|
|
@@ -250,24 +282,41 @@ function findSbomDocuments(cwd) {
|
|
|
250
282
|
// Open once and fstat the descriptor rather than existsSync→statSync→read:
|
|
251
283
|
// ENOENT still skips, and there is no TOCTOU window.
|
|
252
284
|
try { fd = fs.openSync(p, "r"); }
|
|
253
|
-
catch (e) { if (e.code === "ENOENT") continue; found.push({ file: s.file, format: s.format, error: e.message }); continue; }
|
|
285
|
+
catch (e) { if (e.code === "ENOENT") continue; found.push({ file: s.file, format: s.format, error: e.message, error_kind: "open_failed" }); continue; }
|
|
254
286
|
try {
|
|
255
287
|
const stat = fs.fstatSync(fd);
|
|
256
288
|
let content;
|
|
257
289
|
// Read from the open descriptor, never by path again. readFileSync(fd) loops
|
|
258
290
|
// to EOF; a single readSync can return short on a network or FUSE mount,
|
|
259
291
|
// NUL-padding the buffer and truncating otherwise-valid JSON.
|
|
260
|
-
try { content = fs.readFileSync(fd, "utf8"); } catch {
|
|
292
|
+
try { content = fs.readFileSync(fd, "utf8"); } catch (e) {
|
|
293
|
+
content = null;
|
|
294
|
+
errors.push({
|
|
295
|
+
artifact_id: "sbom-document",
|
|
296
|
+
kind: "read_failed",
|
|
297
|
+
reason: `${s.file}: ${e.message} — component count not derived`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
261
300
|
let component_count = null;
|
|
262
301
|
if (content && s.format === "cyclonedx-1.x") {
|
|
263
302
|
try {
|
|
264
303
|
const j = JSON.parse(content);
|
|
265
304
|
component_count = (j.components || []).length;
|
|
266
|
-
} catch {
|
|
305
|
+
} catch (e) {
|
|
306
|
+
// A present-but-unparseable SBOM reported `null components` with no
|
|
307
|
+
// explanation, which reads as "no components" rather than "not read".
|
|
308
|
+
errors.push({
|
|
309
|
+
artifact_id: "sbom-document",
|
|
310
|
+
kind: "parse_failed",
|
|
311
|
+
reason: `${s.file}: ${e.message} — component count unavailable`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
267
314
|
}
|
|
268
315
|
found.push({ file: s.file, format: s.format, size_bytes: stat.size, component_count });
|
|
269
316
|
} catch (e) {
|
|
270
|
-
|
|
317
|
+
// The descriptor opened; fstat is what failed here. Reporting it as an
|
|
318
|
+
// open failure sends the operator after the wrong thing.
|
|
319
|
+
found.push({ file: s.file, format: s.format, error: e.message, error_kind: "stat_failed" });
|
|
271
320
|
} finally {
|
|
272
321
|
if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } }
|
|
273
322
|
}
|
|
@@ -281,10 +330,31 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
281
330
|
const root = path.resolve(cwd);
|
|
282
331
|
|
|
283
332
|
// sbom-tool-available: a lockfile or an SBOM document is a sufficient proxy.
|
|
284
|
-
const lockfiles = findLockfiles(root);
|
|
285
|
-
const sbomDocuments = findSbomDocuments(root);
|
|
333
|
+
const lockfiles = findLockfiles(root, errors);
|
|
334
|
+
const sbomDocuments = findSbomDocuments(root, errors);
|
|
286
335
|
const hasAnything = lockfiles.length > 0 || sbomDocuments.length > 0;
|
|
287
336
|
|
|
337
|
+
// A lockfile that was found but not read contributes no component count, so
|
|
338
|
+
// the inventory under-reports; the operator sees which one and why.
|
|
339
|
+
for (const l of lockfiles) {
|
|
340
|
+
if (!l.error) continue;
|
|
341
|
+
errors.push({
|
|
342
|
+
artifact_id: "lockfile-inventory",
|
|
343
|
+
kind: l.error_kind || "lockfile_read_failed",
|
|
344
|
+
reason: `${l.file}: ${l.error}`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
for (const s of sbomDocuments) {
|
|
348
|
+
if (!s.error) continue;
|
|
349
|
+
errors.push({
|
|
350
|
+
artifact_id: "sbom-document",
|
|
351
|
+
// Carried from the push site, mirroring the lockfile loop above: an
|
|
352
|
+
// fstat failure and an open failure are different operator actions.
|
|
353
|
+
kind: s.error_kind || "open_failed",
|
|
354
|
+
reason: `${s.file}: ${s.error}`,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
288
358
|
const artifacts = {
|
|
289
359
|
"lockfile-inventory": {
|
|
290
360
|
value: lockfiles.length
|
|
@@ -346,8 +416,13 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
346
416
|
}
|
|
347
417
|
npmLockfile.integrity_present_count = withIntegrity;
|
|
348
418
|
npmLockfile.integrity_missing_count = withoutIntegrity;
|
|
349
|
-
} catch {
|
|
419
|
+
} catch (e) {
|
|
350
420
|
// A malformed lockfile leaves the indicator unflipped: inconclusive, not miss.
|
|
421
|
+
errors.push({
|
|
422
|
+
artifact_id: "lockfile-inventory",
|
|
423
|
+
kind: "lockfile_parse_failed",
|
|
424
|
+
reason: `${npmLockfile.file}: ${e.message} — lockfile-no-integrity left undecided (inconclusive, not miss)`,
|
|
425
|
+
});
|
|
351
426
|
}
|
|
352
427
|
}
|
|
353
428
|
|
|
@@ -378,6 +453,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
378
453
|
lockfiles_found: lockfiles.length,
|
|
379
454
|
sbom_documents_found: sbomDocuments.length,
|
|
380
455
|
ecosystems_detected: [...new Set(lockfiles.map(l => l.ecosystem))],
|
|
456
|
+
// Magnitude behind the lockfile-no-integrity verdict: "hit" alone does not
|
|
457
|
+
// say whether one entry or nine hundred resolve without a hash. Present
|
|
458
|
+
// only when an npm lockfile was found AND parsed.
|
|
459
|
+
...(npmLockfile && npmLockfile.integrity_present_count != null
|
|
460
|
+
? {
|
|
461
|
+
npm_integrity_present_count: npmLockfile.integrity_present_count,
|
|
462
|
+
npm_integrity_missing_count: npmLockfile.integrity_missing_count,
|
|
463
|
+
}
|
|
464
|
+
: {}),
|
|
381
465
|
},
|
|
382
466
|
collector_errors: errors,
|
|
383
467
|
};
|
|
@@ -85,8 +85,9 @@ function walkTree(root, opts = {}) {
|
|
|
85
85
|
}
|
|
86
86
|
walk(full, depth + 1);
|
|
87
87
|
} else if (entry.isSymbolicLink()) {
|
|
88
|
-
// A symlink Dirent is neither isDirectory() nor isFile()
|
|
89
|
-
|
|
88
|
+
// A symlink Dirent is neither isDirectory() nor isFile(), so it is
|
|
89
|
+
// never traversed and never emitted — including a dangling one.
|
|
90
|
+
continue;
|
|
90
91
|
} else if (entry.isFile()) {
|
|
91
92
|
// A regular file cannot introduce a directory cycle, so no realpath /
|
|
92
93
|
// `seen` check. Windows path.relative returns backslashes, so rel is
|
package/lib/framework-gap.js
CHANGED
|
@@ -159,6 +159,11 @@ function lagScore(frameworkId, controlGaps, globalFrameworks) {
|
|
|
159
159
|
* free text. Omitting `opts.lessons` (parsed zeroday-lessons.json) yields a
|
|
160
160
|
* report with no new-control section rather than an error. Returns
|
|
161
161
|
* { frameworks, universal_gaps, new_control_requirements, theater_risks, summary }.
|
|
162
|
+
*
|
|
163
|
+
* `cveCatalog` is positional surface this function does not read — the scenario
|
|
164
|
+
* matches against `controlGaps` alone. It holds the fourth slot so `opts` stays
|
|
165
|
+
* fifth, and mirrors theaterCheck(), which does read its catalog. Dropping the
|
|
166
|
+
* parameter would silently bind every caller's `opts` argument to it instead.
|
|
162
167
|
*/
|
|
163
168
|
function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, opts = {}) {
|
|
164
169
|
const scenario = threatScenario.toLowerCase();
|
package/lib/lint-skills.js
CHANGED
|
@@ -100,8 +100,20 @@ function isStrictIsoCalendarDate(s) {
|
|
|
100
100
|
const KEBAB_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
|
101
101
|
const JSON_FILENAME_RE = /^[A-Za-z0-9._-]+\.json$/;
|
|
102
102
|
|
|
103
|
+
// `helpExitCode` is null unless the run is over before linting starts: 0 after
|
|
104
|
+
// --help, 2 after an unknown argument. parseArgs never terminates the process —
|
|
105
|
+
// it returns the code and the caller must honour it and stop.
|
|
106
|
+
//
|
|
107
|
+
// The invariant this keeps: every exit in this file goes through safeExit(), so
|
|
108
|
+
// no path here can write to stdout and then terminate synchronously. That is the
|
|
109
|
+
// `process-exit-after-stdout-write` shape, and it is a convention held file-wide
|
|
110
|
+
// rather than a repair of an observed truncation: Node writes pipe stdout
|
|
111
|
+
// synchronously on Windows and Linux (asynchronously on macOS), and the help
|
|
112
|
+
// block is a few hundred bytes, so process.exit() did not in fact truncate it
|
|
113
|
+
// here. Holding the invariant means a future help block that grows, or a run on
|
|
114
|
+
// a platform with async pipe writes, cannot reintroduce the class.
|
|
103
115
|
function parseArgs(argv) {
|
|
104
|
-
const opts = { skill: null, quiet: false, strict: false };
|
|
116
|
+
const opts = { skill: null, quiet: false, strict: false, helpExitCode: null };
|
|
105
117
|
for (let i = 2; i < argv.length; i++) {
|
|
106
118
|
const a = argv[i];
|
|
107
119
|
if (a === '--skill') {
|
|
@@ -115,11 +127,13 @@ function parseArgs(argv) {
|
|
|
115
127
|
opts.strict = true;
|
|
116
128
|
} else if (a === '--help' || a === '-h') {
|
|
117
129
|
printHelp();
|
|
118
|
-
|
|
130
|
+
opts.helpExitCode = 0;
|
|
131
|
+
return opts;
|
|
119
132
|
} else {
|
|
120
133
|
console.error(`Unknown argument: ${a}`);
|
|
121
134
|
printHelp();
|
|
122
|
-
|
|
135
|
+
opts.helpExitCode = 2;
|
|
136
|
+
return opts;
|
|
123
137
|
}
|
|
124
138
|
}
|
|
125
139
|
return opts;
|
|
@@ -798,6 +812,10 @@ function lintPlaybookAirGap() {
|
|
|
798
812
|
|
|
799
813
|
function main() {
|
|
800
814
|
const opts = parseArgs(process.argv);
|
|
815
|
+
if (opts.helpExitCode !== null) {
|
|
816
|
+
safeExit(opts.helpExitCode);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
801
819
|
const manifest = readJson(MANIFEST_PATH);
|
|
802
820
|
|
|
803
821
|
let skills = manifest.skills;
|
|
@@ -805,7 +823,8 @@ function main() {
|
|
|
805
823
|
skills = skills.filter((s) => s.name === opts.skill);
|
|
806
824
|
if (skills.length === 0) {
|
|
807
825
|
console.error(`No skill named "${opts.skill}" in manifest.json`);
|
|
808
|
-
|
|
826
|
+
safeExit(2);
|
|
827
|
+
return;
|
|
809
828
|
}
|
|
810
829
|
}
|
|
811
830
|
|
|
@@ -888,6 +907,7 @@ function main() {
|
|
|
888
907
|
|
|
889
908
|
// The frontmatter parser is exported so `watchlist` does not grow a second one.
|
|
890
909
|
module.exports = {
|
|
910
|
+
parseArgs,
|
|
891
911
|
parseFrontmatter,
|
|
892
912
|
extractFrontmatterBlock,
|
|
893
913
|
unquote,
|
package/lib/playbook-runner.js
CHANGED
|
@@ -1292,6 +1292,60 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1292
1292
|
analyze_complete: !!(analyzeResult && typeof analyzeResult === 'object'),
|
|
1293
1293
|
validate_complete: !!(validateResult && typeof validateResult === 'object'),
|
|
1294
1294
|
};
|
|
1295
|
+
|
|
1296
|
+
// Nothing downstream guards the analyze result's container shape: every
|
|
1297
|
+
// bundle format maps over `analyze.matched_cves` directly, and so does
|
|
1298
|
+
// analyzeFindingShape, so a non-object result or a non-array matched_cves
|
|
1299
|
+
// throws before phase 7 produces any output at all. Normalizing once, here,
|
|
1300
|
+
// is what makes the whole phase survive it — the run still emits an evidence
|
|
1301
|
+
// bundle and the shape failure is readable on runtime_errors. The container
|
|
1302
|
+
// and its elements are both normalized: every bundle builder dereferences an
|
|
1303
|
+
// element, so one null inside an otherwise well-formed array aborts the phase
|
|
1304
|
+
// exactly as a non-array container does.
|
|
1305
|
+
const shapeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
|
|
1306
|
+
const analyzeIsObject = !!analyzeResult && typeof analyzeResult === 'object' && !Array.isArray(analyzeResult);
|
|
1307
|
+
if (!analyzeIsObject || !Array.isArray(analyzeResult.matched_cves)) {
|
|
1308
|
+
pushRunError(runOpts._runErrors, {
|
|
1309
|
+
kind: 'analyze_shape',
|
|
1310
|
+
message: analyzeIsObject
|
|
1311
|
+
? `analyze.matched_cves is ${shapeOf(analyzeResult.matched_cves)}, expected an array; treated as empty`
|
|
1312
|
+
: `analyze result is ${shapeOf(analyzeResult)}, expected an object; treated as empty`,
|
|
1313
|
+
}, { dedupeKey: x => x.message || '' });
|
|
1314
|
+
// A copy, so close() reports the caller's malformed result without
|
|
1315
|
+
// rewriting it underneath them.
|
|
1316
|
+
analyzeResult = { ...(analyzeIsObject ? analyzeResult : {}), matched_cves: [] };
|
|
1317
|
+
} else {
|
|
1318
|
+
// Dropped rather than repaired: a consumer cannot read a CVE id off a null,
|
|
1319
|
+
// and inventing a placeholder entry would put a finding in the evidence
|
|
1320
|
+
// bundle that the analyze phase never produced.
|
|
1321
|
+
const usable = (e) => !!e && typeof e === 'object' && !Array.isArray(e);
|
|
1322
|
+
const dropped = analyzeResult.matched_cves.filter((e) => !usable(e));
|
|
1323
|
+
if (dropped.length) {
|
|
1324
|
+
pushRunError(runOpts._runErrors, {
|
|
1325
|
+
kind: 'analyze_shape',
|
|
1326
|
+
message: `analyze.matched_cves holds ${dropped.length} malformed element(s) (${[...new Set(dropped.map(shapeOf))].join(', ')}); they are dropped and the remaining ${analyzeResult.matched_cves.length - dropped.length} are used`,
|
|
1327
|
+
}, { dedupeKey: x => x.message || '' });
|
|
1328
|
+
analyzeResult = { ...analyzeResult, matched_cves: analyzeResult.matched_cves.filter(usable) };
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// The residual guard for the four sites that interpolate the finding shape —
|
|
1333
|
+
// the notification draft, the auditor-ready exception language, the
|
|
1334
|
+
// learning-loop lesson and the feeds_into finding context. The normalization
|
|
1335
|
+
// above removes the shapes analyzeFindingShape is known to throw on; this
|
|
1336
|
+
// catches anything it still throws on, degrading those four fields to
|
|
1337
|
+
// `<MISSING:…>` and putting the throw on runtime_errors rather than aborting
|
|
1338
|
+
// the phase.
|
|
1339
|
+
const safeFindingShape = () => {
|
|
1340
|
+
try { return analyzeFindingShape(analyzeResult); }
|
|
1341
|
+
catch (e) {
|
|
1342
|
+
pushRunError(runOpts._runErrors,
|
|
1343
|
+
{ kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) },
|
|
1344
|
+
{ dedupeKey: x => x.message || '' });
|
|
1345
|
+
return {};
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1295
1349
|
const enrichNotification = (na) => {
|
|
1296
1350
|
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
1297
1351
|
o && typeof o === 'object' &&
|
|
@@ -1359,16 +1413,9 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1359
1413
|
// Which template vars failed to resolve; empty when all rendered.
|
|
1360
1414
|
...(function () {
|
|
1361
1415
|
const missing = [];
|
|
1362
|
-
//
|
|
1363
|
-
// the failure surfaces
|
|
1364
|
-
|
|
1365
|
-
try { findingShape = analyzeFindingShape(analyzeResult); }
|
|
1366
|
-
catch (e) {
|
|
1367
|
-
if (Array.isArray(runOpts._runErrors)) {
|
|
1368
|
-
pushRunError(runOpts._runErrors, { kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) }, { dedupeKey: e => e.message || '' });
|
|
1369
|
-
}
|
|
1370
|
-
findingShape = {};
|
|
1371
|
-
}
|
|
1416
|
+
// safeFindingShape, not analyzeFindingShape: a malformed analyze result
|
|
1417
|
+
// must not bring down close(); the failure surfaces on runtime_errors.
|
|
1418
|
+
const findingShape = safeFindingShape();
|
|
1372
1419
|
const draft = interpolate(
|
|
1373
1420
|
na.draft_notification,
|
|
1374
1421
|
{ ...agentSignals, ...findingShape },
|
|
@@ -1416,7 +1463,11 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1416
1463
|
// does not supply, so the auditor-ready language routinely renders
|
|
1417
1464
|
// `<MISSING:…>`. missing_interpolation_vars names which.
|
|
1418
1465
|
const exMissing = [];
|
|
1419
|
-
|
|
1466
|
+
// safeFindingShape, like the other three shape consumers. The feeds_into
|
|
1467
|
+
// context below reports the same throw on every close(), so this site's
|
|
1468
|
+
// own disposition is not separately observable — it is uniform so a
|
|
1469
|
+
// future reader does not read the bare catch as a deliberate exemption.
|
|
1470
|
+
const findingShape = safeFindingShape();
|
|
1420
1471
|
exception = {
|
|
1421
1472
|
scope: interpolate(t.scope, { ...agentSignals, ...findingShape }, exMissing),
|
|
1422
1473
|
duration: t.duration,
|
|
@@ -1492,7 +1543,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1492
1543
|
|
|
1493
1544
|
const lesson = c.learning_loop?.enabled ? {
|
|
1494
1545
|
enabled: true,
|
|
1495
|
-
attack_vector: interpolate(c.learning_loop.lesson_template.attack_vector,
|
|
1546
|
+
attack_vector: interpolate(c.learning_loop.lesson_template.attack_vector, safeFindingShape()),
|
|
1496
1547
|
control_gap: c.learning_loop.lesson_template.control_gap,
|
|
1497
1548
|
framework_gap: c.learning_loop.lesson_template.framework_gap,
|
|
1498
1549
|
new_control_requirement: c.learning_loop.lesson_template.new_control_requirement,
|
|
@@ -1542,7 +1593,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1542
1593
|
analyze: analyzeResult,
|
|
1543
1594
|
validate: validateResult,
|
|
1544
1595
|
// Same two-sourced finding.* shape and guards as the escalation context.
|
|
1545
|
-
finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...
|
|
1596
|
+
finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...safeFindingShape() },
|
|
1546
1597
|
// Without the accumulator, a feeds_into condition failure never reaches
|
|
1547
1598
|
// analyze.runtime_errors[].
|
|
1548
1599
|
...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
|
|
@@ -2356,7 +2407,10 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2356
2407
|
'@id': productPurl,
|
|
2357
2408
|
subcomponents: [{ '@id': productPurl }],
|
|
2358
2409
|
};
|
|
2359
|
-
|
|
2410
|
+
// validate() selects the priority-1 path as its last rung, so a null
|
|
2411
|
+
// selected_remediation means the phase declares no remediation paths at all
|
|
2412
|
+
// and remediation_options_considered is empty too — there is no id to name.
|
|
2413
|
+
const remediationId = validate.selected_remediation?.id || null;
|
|
2360
2414
|
const remediationDescription = validate.selected_remediation?.description || null;
|
|
2361
2415
|
const actionStatementFor = (fallback) => {
|
|
2362
2416
|
if (remediationId && remediationDescription) {
|
package/lib/prefetch.js
CHANGED
|
@@ -487,8 +487,9 @@ function isFresh(idx, source, id, maxAgeMs) {
|
|
|
487
487
|
|
|
488
488
|
function authHeadersForSource(source) {
|
|
489
489
|
if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
|
|
490
|
-
// `pins` is the registered
|
|
491
|
-
//
|
|
490
|
+
// `pins` is the registered name for the MITRE GitHub releases feed. The
|
|
491
|
+
// `github` alias is defensive: no registered source emits it, and prefetch()
|
|
492
|
+
// refuses `--source github` before any header is built.
|
|
492
493
|
if ((source === "pins" || source === "github") && process.env.GITHUB_TOKEN) {
|
|
493
494
|
return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
|
|
494
495
|
}
|
package/lib/refresh-external.js
CHANGED
|
@@ -1701,12 +1701,6 @@ async function main() {
|
|
|
1701
1701
|
process.exitCode = cacheIntegrityFailure ? 4 : (hadFailure ? 1 : 0);
|
|
1702
1702
|
}
|
|
1703
1703
|
|
|
1704
|
-
async function sequential(items, fn) {
|
|
1705
|
-
const out = [];
|
|
1706
|
-
for (const it of items) out.push(await fn(it));
|
|
1707
|
-
return out;
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
1704
|
if (require.main === module) {
|
|
1711
1705
|
main().catch((err) => {
|
|
1712
1706
|
// A hinted error prints its message plus a JSON line, never a stack trace.
|
package/lib/refresh-network.js
CHANGED
|
@@ -116,10 +116,9 @@ function getBufferOnce(url, timeoutMs) {
|
|
|
116
116
|
if (!isAllowedTarballHost(url)) {
|
|
117
117
|
return reject(new Error(`refusing to fetch tarball from non-allowlisted host: ${u.hostname}`));
|
|
118
118
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
})();
|
|
119
|
+
// Same reader as the buffered-length check, so the streaming cap and the
|
|
120
|
+
// post-download cap cannot drift apart when the default changes.
|
|
121
|
+
const cap = tarballSizeCap();
|
|
123
122
|
const req = https.get({
|
|
124
123
|
host: u.host, path: u.pathname + u.search,
|
|
125
124
|
headers: { "User-Agent": "exceptd/refresh-network" },
|
package/lib/scoring.js
CHANGED
|
@@ -431,10 +431,17 @@ function validate(catalog) {
|
|
|
431
431
|
const errors = [];
|
|
432
432
|
for (const [cveId, entry] of Object.entries(catalog)) {
|
|
433
433
|
if (cveId.startsWith('_')) continue;
|
|
434
|
+
// A null or non-object entry cannot be walked field by field: `field in
|
|
435
|
+
// entry` throws, taking down validation of the whole catalog. Report it as
|
|
436
|
+
// the error it is, so the remaining entries are still checked.
|
|
437
|
+
if (!entry || typeof entry !== 'object') {
|
|
438
|
+
errors.push(`${cveId}: entry is ${entry === null ? 'null' : typeof entry}, expected an object`);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
434
441
|
// Drafts carry a conservative-default rwep_score beside null-until-curated
|
|
435
442
|
// factor fields, so the divergence check below fires on every one. They are
|
|
436
443
|
// reviewed through `_auto_imported_meta.curation_needed` instead.
|
|
437
|
-
if (entry
|
|
444
|
+
if (entry._auto_imported === true) continue;
|
|
438
445
|
for (const field of CVE_SCHEMA_REQUIRED) {
|
|
439
446
|
if (!(field in entry)) {
|
|
440
447
|
errors.push(`${cveId}: missing required field '${field}'`);
|
package/lib/ttp-mapper.js
CHANGED
|
@@ -28,7 +28,20 @@ function map(controlId, gapCatalog) {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// The single source of gapsFor()'s no-match answer. Both routes to it — an
|
|
32
|
+
// absent catalog and a present one that matched nothing — are the same answer
|
|
33
|
+
// to the operator, so they must carry the same words.
|
|
34
|
+
const noDocumentedGaps = (attackPattern) => ({
|
|
35
|
+
attack_pattern: attackPattern,
|
|
36
|
+
found_gaps: false,
|
|
37
|
+
message: 'No documented gaps for this pattern — verify manually'
|
|
38
|
+
});
|
|
39
|
+
|
|
31
40
|
function gapsFor(attackPattern, gapCatalog, atlasCatalog) {
|
|
41
|
+
// Object.entries(null) throws. An absent catalog is "nothing documented",
|
|
42
|
+
// which is the same answer an empty one gives — as in map(), the caller gets
|
|
43
|
+
// the module's no-data shape rather than an exception.
|
|
44
|
+
if (!gapCatalog || typeof gapCatalog !== 'object') return noDocumentedGaps(attackPattern);
|
|
32
45
|
const results = [];
|
|
33
46
|
for (const [controlId, entry] of Object.entries(gapCatalog)) {
|
|
34
47
|
if (controlId.startsWith('_')) continue;
|
|
@@ -36,9 +49,7 @@ function gapsFor(attackPattern, gapCatalog, atlasCatalog) {
|
|
|
36
49
|
results.push({ control_id: controlId, framework: entry.framework, control_name: entry.control_name, gap: entry.misses });
|
|
37
50
|
}
|
|
38
51
|
}
|
|
39
|
-
if (results.length === 0)
|
|
40
|
-
return { attack_pattern: attackPattern, found_gaps: false, message: 'No documented gaps for this pattern — verify manually' };
|
|
41
|
-
}
|
|
52
|
+
if (results.length === 0) return noDocumentedGaps(attackPattern);
|
|
42
53
|
return { attack_pattern: attackPattern, found_gaps: true, controls_with_gap: results };
|
|
43
54
|
}
|
|
44
55
|
|
|
@@ -16,14 +16,25 @@ const { safeExit } = require("./exit-codes");
|
|
|
16
16
|
const ROOT = path.resolve(__dirname, "..");
|
|
17
17
|
const { fetchLatestPublished, buildFreshnessReport } = require("./upstream-check.js");
|
|
18
18
|
|
|
19
|
+
// --flag base names this CLI accepts; drives the unknown-flag error message.
|
|
20
|
+
const KNOWN_FLAGS = Object.freeze(["--timeout", "--raw", "--air-gap"]);
|
|
21
|
+
|
|
19
22
|
function parseArgs(argv) {
|
|
20
|
-
const out = { timeoutMs: 5000, raw: false, airGap: false };
|
|
23
|
+
const out = { timeoutMs: 5000, raw: false, airGap: false, unknownFlags: [] };
|
|
21
24
|
for (let i = 2; i < argv.length; i++) {
|
|
22
25
|
const a = argv[i];
|
|
23
26
|
if (a === "--timeout") { out.timeoutMs = parseInt(argv[++i], 10) || 5000; }
|
|
24
27
|
else if (a.startsWith("--timeout=")) { out.timeoutMs = parseInt(a.slice("--timeout=".length), 10) || 5000; }
|
|
25
28
|
else if (a === "--raw") out.raw = true;
|
|
26
29
|
else if (a === "--air-gap") out.airGap = true;
|
|
30
|
+
// Any remaining --flag is a typo; refused below, before any network work.
|
|
31
|
+
// Only `--`-prefixed tokens are collected, matching lib/prefetch.js: a
|
|
32
|
+
// single-dash `-air-gap` or an en-dashed `–air-gap` is not recognized here
|
|
33
|
+
// and still reaches the registry probe.
|
|
34
|
+
else if (a.startsWith("--")) {
|
|
35
|
+
const eq = a.indexOf("=");
|
|
36
|
+
out.unknownFlags.push(eq === -1 ? a : a.slice(0, eq));
|
|
37
|
+
}
|
|
27
38
|
}
|
|
28
39
|
return out;
|
|
29
40
|
}
|
|
@@ -42,6 +53,20 @@ function readManifest() {
|
|
|
42
53
|
|
|
43
54
|
(async () => {
|
|
44
55
|
const opts = parseArgs(process.argv);
|
|
56
|
+
// Usage errors exit 2 on stderr, keeping stdout reserved for the one report
|
|
57
|
+
// line callers parse. Checked before the air-gap branch and the probe.
|
|
58
|
+
if (opts.unknownFlags.length > 0) {
|
|
59
|
+
const uniq = [...new Set(opts.unknownFlags)];
|
|
60
|
+
process.stderr.write(JSON.stringify({
|
|
61
|
+
ok: false,
|
|
62
|
+
source: "upstream-check",
|
|
63
|
+
error: `upstream-check: unknown flag(s): ${uniq.join(", ")}`,
|
|
64
|
+
unknown_flags: uniq,
|
|
65
|
+
known_flags: KNOWN_FLAGS,
|
|
66
|
+
}) + "\n");
|
|
67
|
+
process.exitCode = 2;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
45
70
|
// The registry probe is a network call, so air-gap mode answers with a
|
|
46
71
|
// `skipped` envelope instead. `ok: null` is neither pass nor fail.
|
|
47
72
|
if (process.env.EXCEPTD_AIR_GAP === "1" || opts.airGap) {
|
|
@@ -393,7 +393,11 @@ function main() {
|
|
|
393
393
|
(missingFromSchema.length ? ` Missing from schema: ${JSON.stringify(missingFromSchema)}\n` : '') +
|
|
394
394
|
` Reconcile by updating one or the other so they agree exactly.\n`,
|
|
395
395
|
);
|
|
396
|
-
process.exit(
|
|
396
|
+
// safeExit, not process.exit(): stderr is an async pipe under CI log
|
|
397
|
+
// capture, and exiting synchronously discards the diagnostic above while
|
|
398
|
+
// still failing the gate — a red run with no reason printed.
|
|
399
|
+
safeExit(1);
|
|
400
|
+
return;
|
|
397
401
|
}
|
|
398
402
|
}
|
|
399
403
|
|
|
@@ -415,7 +419,10 @@ function main() {
|
|
|
415
419
|
`!= actual entry count (${f.actual}). Update _meta.entry_count to ${f.actual}.\n`,
|
|
416
420
|
);
|
|
417
421
|
}
|
|
418
|
-
|
|
422
|
+
// Same reason as the enum-mismatch exit above: the drift lines are the whole
|
|
423
|
+
// value of the failure, so the process must unwind rather than exit here.
|
|
424
|
+
safeExit(1);
|
|
425
|
+
return;
|
|
419
426
|
}
|
|
420
427
|
|
|
421
428
|
let failed = 0;
|
|
@@ -98,11 +98,6 @@ function readJson(p) {
|
|
|
98
98
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
function readJsonIfExists(p) {
|
|
102
|
-
if (!fs.existsSync(p)) return null;
|
|
103
|
-
return readJson(p);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
101
|
function typeOf(value) {
|
|
107
102
|
if (value === null) return 'null';
|
|
108
103
|
if (Array.isArray(value)) return 'array';
|
|
@@ -239,6 +234,9 @@ function loadContext() {
|
|
|
239
234
|
const d3 = readJson(D3FEND_PATH);
|
|
240
235
|
// Required, not optional: an optional load skips attack_ref validation silently.
|
|
241
236
|
const attack = readJson(ATTACK_PATH);
|
|
237
|
+
if (!attack || typeof attack !== 'object' || Array.isArray(attack)) {
|
|
238
|
+
throw new Error(`validate-playbooks: ${ATTACK_PATH} did not parse to a technique map (got ${attack === null ? 'null' : typeof attack}). Refusing to validate with the attack_ref checks silently disabled.`);
|
|
239
|
+
}
|
|
242
240
|
|
|
243
241
|
// Sourced from the schema so the hard-error checks in checkCrossRefs stay in
|
|
244
242
|
// lockstep with its enum lists.
|
|
@@ -266,9 +264,9 @@ function loadContext() {
|
|
|
266
264
|
cveKeys: new Set(Object.keys(cve).filter((k) => !k.startsWith('_'))),
|
|
267
265
|
cweKeys: new Set(Object.keys(cwe).filter((k) => !k.startsWith('_'))),
|
|
268
266
|
d3fendKeys: new Set(Object.keys(d3).filter((k) => !k.startsWith('_'))),
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
267
|
+
// Never nullable: a null here silently disables every attack_ref check, the
|
|
268
|
+
// exact failure the required load above exists to prevent.
|
|
269
|
+
attackKeys: new Set(Object.keys(attack).filter((k) => !k.startsWith('_'))),
|
|
272
270
|
clockStartsEnum: clockStartsEnum ? new Set(clockStartsEnum) : null,
|
|
273
271
|
frameworksEnum: frameworksEnum ? new Set(frameworksEnum) : null,
|
|
274
272
|
};
|
|
@@ -347,7 +345,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
347
345
|
}
|
|
348
346
|
// Hard Rule #4: domain-level TTPs resolve against the ATT&CK catalog.
|
|
349
347
|
for (const a of domain.attack_refs || []) {
|
|
350
|
-
if (
|
|
348
|
+
if (!ctx.attackKeys.has(a)) {
|
|
351
349
|
warn(`domain.attack_refs: unresolved "${a}" (not in data/attack-techniques.json)`);
|
|
352
350
|
}
|
|
353
351
|
}
|
|
@@ -381,7 +379,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
381
379
|
}
|
|
382
380
|
indIds.add(ind.id);
|
|
383
381
|
}
|
|
384
|
-
if (ind.attack_ref &&
|
|
382
|
+
if (ind.attack_ref && !ctx.attackKeys.has(ind.attack_ref)) {
|
|
385
383
|
warn(
|
|
386
384
|
`phases.detect.indicators[${i}].attack_ref: unresolved "${ind.attack_ref}" (not in data/attack-techniques.json)`,
|
|
387
385
|
);
|
|
@@ -581,7 +579,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
581
579
|
if (at.atlas_ttp && !ctx.atlasKeys.has(at.atlas_ttp)) {
|
|
582
580
|
warn(`${label}.applies_to.atlas_ttp: unresolved "${at.atlas_ttp}" (not in data/atlas-ttps.json)`);
|
|
583
581
|
}
|
|
584
|
-
if (at.attack_technique &&
|
|
582
|
+
if (at.attack_technique && !ctx.attackKeys.has(at.attack_technique)) {
|
|
585
583
|
warn(`${label}.applies_to.attack_technique: unresolved "${at.attack_technique}" (not in data/attack-techniques.json)`);
|
|
586
584
|
}
|
|
587
585
|
}
|