@blamejs/exceptd-skills 0.14.9 → 0.14.11
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 +32 -0
- package/bin/exceptd.js +166 -49
- package/data/_indexes/_meta.json +2 -2
- package/lib/citation-resolve.js +4 -1
- package/lib/collectors/cicd-pipeline-compromise.js +8 -2
- package/lib/collectors/citation-hygiene.js +10 -5
- package/lib/collectors/crypto-codebase.js +11 -6
- package/lib/collectors/sbom.js +9 -2
- package/lib/collectors/scan-excludes.js +0 -0
- package/lib/collectors/secrets.js +32 -4
- package/lib/cve-cli.js +5 -1
- package/lib/framework-gap.js +21 -2
- package/lib/playbook-runner.js +23 -10
- package/lib/prefetch.js +5 -1
- package/lib/refresh-external.js +29 -4
- package/lib/refresh-network.js +16 -1
- package/manifest.json +44 -44
- package/orchestrator/index.js +61 -6
- package/package.json +1 -1
- package/sbom.cdx.json +42 -42
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
const fs = require("node:fs");
|
|
20
20
|
const path = require("node:path");
|
|
21
|
-
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
|
|
21
|
+
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
|
|
22
22
|
|
|
23
23
|
const COLLECTOR_ID = "secrets";
|
|
24
24
|
|
|
@@ -83,6 +83,18 @@ const IAC_GLOB_PREFIX = ["pulumi.", "arm."];
|
|
|
83
83
|
// source of truth for what counts as a hit; the collector
|
|
84
84
|
// implements the same patterns so its signal_overrides match what
|
|
85
85
|
// the runner would compute.
|
|
86
|
+
// AWS-published documentation/example access-key IDs. These appear verbatim
|
|
87
|
+
// throughout AWS docs, SDK samples, and countless READMEs, so a literal match
|
|
88
|
+
// is example material, not a leaked credential. `cred-stores` demotes the same
|
|
89
|
+
// value (its FP[0]); secrets.js must too or it false-positives on any README
|
|
90
|
+
// that quotes the AWS docs. The 40-char example secret
|
|
91
|
+
// (`wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) carries the literal `EXAMPLE`
|
|
92
|
+
// token, which the AWS-secret-access-key pattern already requires elsewhere;
|
|
93
|
+
// the access-key ID is the one that needs an explicit allowlist.
|
|
94
|
+
const AWS_EXAMPLE_ACCESS_KEY_IDS = new Set([
|
|
95
|
+
"AKIAIOSFODNN7EXAMPLE",
|
|
96
|
+
]);
|
|
97
|
+
|
|
86
98
|
const INDICATOR_PATTERNS = [
|
|
87
99
|
{ id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
88
100
|
{ id: "aws-secret-access-key", re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi },
|
|
@@ -199,10 +211,16 @@ function scanContent(full, rel) {
|
|
|
199
211
|
const matches = buf.matchAll(p.re);
|
|
200
212
|
let count = 0;
|
|
201
213
|
for (const m of matches) {
|
|
214
|
+
// Demote AWS-published example access-key IDs (e.g. the docs' canonical
|
|
215
|
+
// AKIAIOSFODNN7EXAMPLE). A README quoting the AWS docs must not hit.
|
|
216
|
+
if (p.id === "aws-access-key-id" && AWS_EXAMPLE_ACCESS_KEY_IDS.has(m[0])) continue;
|
|
202
217
|
hits.push({
|
|
203
218
|
indicator_id: p.id,
|
|
204
219
|
file: rel,
|
|
205
220
|
offset: m.index,
|
|
221
|
+
// 1-based line of the match so buildEvidenceLocations emits a region
|
|
222
|
+
// (SARIF startLine) instead of a bare file-level location.
|
|
223
|
+
line: lineFromOffset(buf, m.index),
|
|
206
224
|
redacted_match: redactMatch(m[0]),
|
|
207
225
|
});
|
|
208
226
|
if (++count >= 5) break; // cap per-indicator-per-file
|
|
@@ -264,6 +282,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
264
282
|
if (r.hits) allHits.push(...r.hits);
|
|
265
283
|
if (r.skipped === "read_error") {
|
|
266
284
|
errors.push({ artifact_id: "secret-regex-scan-text-files", kind: "read_failed", reason: `${f.rel}: ${r.reason}` });
|
|
285
|
+
} else if (r.skipped === "file_too_large") {
|
|
286
|
+
// A secret in the first bytes of a large file would otherwise be
|
|
287
|
+
// dropped silently. Record the skip so the operator knows this file
|
|
288
|
+
// was NOT scanned (mirrors crypto-codebase's >1 MB read_failed entry).
|
|
289
|
+
errors.push({
|
|
290
|
+
artifact_id: "secret-regex-scan-text-files",
|
|
291
|
+
kind: "file_too_large_skipped",
|
|
292
|
+
reason: `${f.rel}: ${r.bytes} bytes exceeds ${MAX_FILE_BYTES}-byte scan limit; not scanned for secrets`,
|
|
293
|
+
});
|
|
267
294
|
}
|
|
268
295
|
}
|
|
269
296
|
|
|
@@ -311,9 +338,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
311
338
|
|
|
312
339
|
// Per-indicator file locations for every indicator flipped to "hit", so
|
|
313
340
|
// a SARIF result points at the file carrying the secret / bad posture.
|
|
314
|
-
// Content-regex hits
|
|
315
|
-
//
|
|
316
|
-
// posture indicators contribute the carrier file path directly
|
|
341
|
+
// Content-regex hits carry a 1-based `line` (derived from the match offset),
|
|
342
|
+
// so these locations include a startLine region. The file-presence and
|
|
343
|
+
// posture indicators contribute the carrier file path directly (file-level,
|
|
344
|
+
// no line).
|
|
317
345
|
const evidence_locations = {};
|
|
318
346
|
for (const p of INDICATOR_PATTERNS) {
|
|
319
347
|
if (signal_overrides[p.id] === "hit") {
|
package/lib/cve-cli.js
CHANGED
|
@@ -28,7 +28,11 @@ const { resolveCve } = require("./citation-resolve.js");
|
|
|
28
28
|
process.exitCode = 1;
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
// Trim the positional so a whitespace-only argument (`cve " "`) is
|
|
32
|
+
// treated identically to a missing one (`cve ""`) — a usage error, not a
|
|
33
|
+
// "fabricated" lookup of the literal spaces.
|
|
34
|
+
const rawId = argv.find((a) => !a.startsWith("--"));
|
|
35
|
+
const id = rawId == null ? rawId : rawId.trim();
|
|
32
36
|
const pretty = flags.has("--pretty");
|
|
33
37
|
const json = flags.has("--json") || pretty;
|
|
34
38
|
|
package/lib/framework-gap.js
CHANGED
|
@@ -141,7 +141,7 @@ function lagScore(frameworkId, controlGaps, globalFrameworks) {
|
|
|
141
141
|
* @param {object} cveCatalog - Parsed cve-catalog.json (optional)
|
|
142
142
|
* @returns {{ frameworks: object, universal_gaps: object[], theater_risks: object[] }}
|
|
143
143
|
*/
|
|
144
|
-
function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}) {
|
|
144
|
+
function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, opts = {}) {
|
|
145
145
|
const scenario = threatScenario.toLowerCase();
|
|
146
146
|
|
|
147
147
|
const relevantGaps = Object.entries(controlGaps).filter(([, gap]) => {
|
|
@@ -207,6 +207,25 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}) {
|
|
|
207
207
|
theater_test_present: !!g.theater_test,
|
|
208
208
|
}));
|
|
209
209
|
|
|
210
|
+
// Summary matching count. With `all` frameworks the summary counts every
|
|
211
|
+
// scenario-relevant gap across the whole catalog (relevantGaps). With an
|
|
212
|
+
// explicit framework filter the summary must agree with the per-framework
|
|
213
|
+
// body the operator actually sees — otherwise `framework-gap nist-800-53
|
|
214
|
+
// <cve>` shows e.g. "2 matching control gap(s)" per-framework but "Summary:
|
|
215
|
+
// 8 matching gaps" (every framework's hits, pre-filter). Sum the per-
|
|
216
|
+
// framework gap_count so body + summary agree. De-duplicate by gap key in
|
|
217
|
+
// case a single gap matches multiple requested frameworks.
|
|
218
|
+
let matchingGapCount;
|
|
219
|
+
if (opts.allFrameworks) {
|
|
220
|
+
matchingGapCount = relevantGaps.length;
|
|
221
|
+
} else {
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
for (const id of frameworkIds) {
|
|
224
|
+
for (const g of frameworkResults[id]?.gaps ?? []) seen.add(g.id);
|
|
225
|
+
}
|
|
226
|
+
matchingGapCount = seen.size;
|
|
227
|
+
}
|
|
228
|
+
|
|
210
229
|
return {
|
|
211
230
|
threat_scenario: threatScenario,
|
|
212
231
|
frameworks: frameworkResults,
|
|
@@ -217,7 +236,7 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}) {
|
|
|
217
236
|
})),
|
|
218
237
|
theater_risks: theaterRisks,
|
|
219
238
|
summary: {
|
|
220
|
-
total_gaps:
|
|
239
|
+
total_gaps: matchingGapCount,
|
|
221
240
|
universal_gaps: universalGaps.length,
|
|
222
241
|
theater_risk_controls: theaterRisks.length
|
|
223
242
|
}
|
package/lib/playbook-runner.js
CHANGED
|
@@ -1545,16 +1545,21 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1545
1545
|
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
1546
1546
|
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
1547
1547
|
);
|
|
1548
|
-
// Thread runOpts
|
|
1549
|
-
// operator_consent.explicit before
|
|
1550
|
-
|
|
1551
|
-
//
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1548
|
+
// Thread runOpts + the engine-computed classification through so
|
|
1549
|
+
// computeClockStart can check operator_consent.explicit before
|
|
1550
|
+
// auto-stamping detect_confirmed, and so an engine-confirmed detection
|
|
1551
|
+
// starts the clock even without a separately-submitted classification.
|
|
1552
|
+
const engineClassification = analyzeResult?._detect_classification || null;
|
|
1553
|
+
const clockStart = obligation
|
|
1554
|
+
? computeClockStart(obligation.clock_starts, agentSignals, runOpts, engineClassification)
|
|
1555
|
+
: null;
|
|
1556
|
+
// When the clock event is detect_confirmed AND detection was confirmed
|
|
1557
|
+
// (by the agent OR the engine) AND the operator did NOT pass --ack,
|
|
1558
|
+
// surface clock_pending_ack so the notification record is visibly waiting
|
|
1559
|
+
// on acknowledgement.
|
|
1555
1560
|
const clockPendingAck = !clockStart
|
|
1556
1561
|
&& obligation?.clock_starts === 'detect_confirmed'
|
|
1557
|
-
&& agentSignals?.detection_classification === 'detected'
|
|
1562
|
+
&& (agentSignals?.detection_classification === 'detected' || engineClassification === 'detected')
|
|
1558
1563
|
&& !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
|
|
1559
1564
|
const deadline = obligation && clockStart
|
|
1560
1565
|
? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
|
|
@@ -3571,13 +3576,21 @@ function stripOuterParens(expr) {
|
|
|
3571
3576
|
* waiting on acknowledgement.
|
|
3572
3577
|
* - All other events without an explicit timestamp: return null.
|
|
3573
3578
|
*/
|
|
3574
|
-
function computeClockStart(eventName, agentSignals, runOpts = {}) {
|
|
3579
|
+
function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassification = null) {
|
|
3575
3580
|
// The agent submits clock_started_at_<event> ISO strings as it progresses.
|
|
3576
3581
|
const key = `clock_started_at_${eventName}`;
|
|
3577
3582
|
if (agentSignals && agentSignals[key]) return new Date(agentSignals[key]);
|
|
3578
3583
|
// For detect_confirmed: only auto-stamp when the operator has explicitly
|
|
3579
3584
|
// acknowledged the result via --ack. Otherwise leave the clock pending.
|
|
3580
|
-
|
|
3585
|
+
// Detection is "confirmed" when EITHER the agent submitted
|
|
3586
|
+
// detection_classification:'detected' OR the engine itself classified the
|
|
3587
|
+
// detect phase as 'detected'. Pre-fix only the agent-submitted signal was
|
|
3588
|
+
// honored, so an engine-confirmed detection (indicators fired from
|
|
3589
|
+
// signal_overrides without a separate classification submission) never
|
|
3590
|
+
// started the regulatory clock — notification deadlines silently stalled.
|
|
3591
|
+
const detected = agentSignals?.detection_classification === 'detected'
|
|
3592
|
+
|| engineClassification === 'detected';
|
|
3593
|
+
if (eventName === 'detect_confirmed' && detected
|
|
3581
3594
|
&& runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true) {
|
|
3582
3595
|
return new Date();
|
|
3583
3596
|
}
|
package/lib/prefetch.js
CHANGED
|
@@ -112,7 +112,7 @@ function parseArgs(argv) {
|
|
|
112
112
|
for (let i = 2; i < argv.length; i++) {
|
|
113
113
|
const a = argv[i];
|
|
114
114
|
if (a === "--force") out.force = true;
|
|
115
|
-
else if (a === "--no-network" || a === "--dry-run") out.noNetwork = true;
|
|
115
|
+
else if (a === "--no-network" || a === "--dry-run" || a === "--air-gap") out.noNetwork = true;
|
|
116
116
|
else if (a === "--quiet") out.quiet = true;
|
|
117
117
|
else if (a === "--help" || a === "-h") out.help = true;
|
|
118
118
|
else if (a === "--source") out.source = argv[++i];
|
|
@@ -122,6 +122,10 @@ function parseArgs(argv) {
|
|
|
122
122
|
else if (a === "--cache-dir") out.cacheDir = path.resolve(argv[++i]);
|
|
123
123
|
else if (a.startsWith("--cache-dir=")) out.cacheDir = path.resolve(a.slice("--cache-dir=".length));
|
|
124
124
|
}
|
|
125
|
+
// The global air-gap switch implies a report-only / no-egress run: treat
|
|
126
|
+
// EXCEPTD_AIR_GAP=1 the same as --no-network so prefetch never plans live
|
|
127
|
+
// fetches under air-gap.
|
|
128
|
+
if (process.env.EXCEPTD_AIR_GAP === "1") out.noNetwork = true;
|
|
125
129
|
return out;
|
|
126
130
|
}
|
|
127
131
|
|
package/lib/refresh-external.js
CHANGED
|
@@ -202,10 +202,11 @@ Outputs:
|
|
|
202
202
|
|
|
203
203
|
Exit codes (refresh's own scheme — distinct from the seven-phase verbs):
|
|
204
204
|
0 applied (or a clean dry-run with no diffs to surface)
|
|
205
|
+
1 apply-mode downstream gate failed (build-indexes, or a per-source error)
|
|
205
206
|
2 error (unknown --source, unreadable fixture, invalid --advisory id, air-gap refusal)
|
|
206
207
|
3 draft produced, editorial review pending (a successful --advisory seed —
|
|
207
208
|
NOT a failure; run --advisory <id> --apply to land it, or curate first)
|
|
208
|
-
4 network/source unreachable
|
|
209
|
+
4 network/source unreachable OR cache precondition refused (unsigned/stale/tampered/unindexed cache)
|
|
209
210
|
Note: exit 3 here means "review needed", which differs from \`exceptd run\`'s
|
|
210
211
|
exit 3 ("ran but no evidence"). Script \`refresh --advisory\` on the body's
|
|
211
212
|
\`ok\` field, not on \`$? == 0\`.
|
|
@@ -1252,8 +1253,18 @@ async function withCatalogLock(catalogPath, mutator) {
|
|
|
1252
1253
|
}
|
|
1253
1254
|
|
|
1254
1255
|
function chosenSources(opts) {
|
|
1255
|
-
|
|
1256
|
+
// Flag-absent (opts.source == null) means "all sources" — the default
|
|
1257
|
+
// refresh behavior. Flag-present-but-empty (`--source ""`, or a value that
|
|
1258
|
+
// trims to nothing like `--source ","`) is an operator error, not a
|
|
1259
|
+
// silent run-everything: refuse and list the valid names so the typo is
|
|
1260
|
+
// visible rather than masquerading as a full refresh.
|
|
1261
|
+
if (opts.source == null) return Object.values(ALL_SOURCES);
|
|
1256
1262
|
const names = opts.source.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1263
|
+
if (names.length === 0) {
|
|
1264
|
+
const err = new Error(`refresh-external: --source requires at least one source name. Valid: ${Object.keys(ALL_SOURCES).join(", ")}`);
|
|
1265
|
+
err._exceptd_unknown_source = true;
|
|
1266
|
+
throw err;
|
|
1267
|
+
}
|
|
1257
1268
|
const out = [];
|
|
1258
1269
|
for (const n of names) {
|
|
1259
1270
|
if (!ALL_SOURCES[n]) {
|
|
@@ -1428,7 +1439,7 @@ async function main() {
|
|
|
1428
1439
|
// the seed is printed to stdout for review.
|
|
1429
1440
|
// An empty --advisory value (`--advisory ""` / `--advisory=`) must error
|
|
1430
1441
|
// rather than silently falling through to a full-refresh dry-run.
|
|
1431
|
-
if (opts.advisory === "") {
|
|
1442
|
+
if (opts.advisory != null && opts.advisory.trim() === "") {
|
|
1432
1443
|
process.stderr.write(JSON.stringify({
|
|
1433
1444
|
ok: false,
|
|
1434
1445
|
error: "refresh: --advisory requires a non-empty identifier (e.g. CVE-2026-1234, GHSA-xxxx-xxxx-xxxx, MAL-2026-1).",
|
|
@@ -1496,11 +1507,22 @@ async function main() {
|
|
|
1496
1507
|
? await Promise.all(sources.map(runOne))
|
|
1497
1508
|
: await sequential(sources, runOne);
|
|
1498
1509
|
|
|
1510
|
+
// Cache-integrity refusals (sha256 mismatch, missing/partial _index.json,
|
|
1511
|
+
// unindexed payload) are thrown by readCachedJson with _exceptd_exit_code=4
|
|
1512
|
+
// but caught inside runOne and returned as a per-source error — so the
|
|
1513
|
+
// throw never reaches main().catch where the code is otherwise honored.
|
|
1514
|
+
// Carry the marker through here so main() can prefer exit 4 (BLOCKED /
|
|
1515
|
+
// precondition refusal) over the generic per-source-failure exit 1.
|
|
1516
|
+
let cacheIntegrityFailure = false;
|
|
1499
1517
|
for (const { src, diff, error } of outcomes) {
|
|
1500
1518
|
if (error) {
|
|
1501
1519
|
log(`\n [${src.name}] ${src.description}`);
|
|
1502
1520
|
log(` error: ${error.message}`);
|
|
1503
1521
|
report.sources[src.name] = { status: "error", error: error.message };
|
|
1522
|
+
if (error._exceptd_cache_integrity || error._exceptd_exit_code === 4) {
|
|
1523
|
+
report.sources[src.name].cache_integrity = true;
|
|
1524
|
+
cacheIntegrityFailure = true;
|
|
1525
|
+
}
|
|
1504
1526
|
hadFailure = true;
|
|
1505
1527
|
continue;
|
|
1506
1528
|
}
|
|
@@ -1550,7 +1572,10 @@ async function main() {
|
|
|
1550
1572
|
// truncate buffered stdout (refresh-report path log line, summary log
|
|
1551
1573
|
// lines piped to a consumer). exitCode + return lets the event loop end
|
|
1552
1574
|
// naturally and stdout drains in full.
|
|
1553
|
-
|
|
1575
|
+
// Prefer the documented BLOCKED (4) code when any source refused on a
|
|
1576
|
+
// cache-integrity precondition; fall back to generic failure (1) for other
|
|
1577
|
+
// per-source errors / downstream gate failures.
|
|
1578
|
+
process.exitCode = cacheIntegrityFailure ? 4 : (hadFailure ? 1 : 0);
|
|
1554
1579
|
}
|
|
1555
1580
|
|
|
1556
1581
|
async function sequential(items, fn) {
|
package/lib/refresh-network.js
CHANGED
|
@@ -45,14 +45,16 @@ const PKG_NAME = "@blamejs/exceptd-skills";
|
|
|
45
45
|
const REQUEST_TIMEOUT_MS = 15000;
|
|
46
46
|
|
|
47
47
|
function parseArgs(argv) {
|
|
48
|
-
const out = { force: false, dryRun: false, timeoutMs: REQUEST_TIMEOUT_MS, json: false };
|
|
48
|
+
const out = { force: false, dryRun: false, timeoutMs: REQUEST_TIMEOUT_MS, json: false, airGap: false };
|
|
49
49
|
for (let i = 2; i < argv.length; i++) {
|
|
50
50
|
const a = argv[i];
|
|
51
51
|
if (a === "--force") out.force = true;
|
|
52
52
|
else if (a === "--dry-run") out.dryRun = true;
|
|
53
53
|
else if (a === "--json") out.json = true;
|
|
54
|
+
else if (a === "--air-gap") out.airGap = true;
|
|
54
55
|
else if (a === "--timeout") out.timeoutMs = parseInt(argv[++i], 10) || REQUEST_TIMEOUT_MS;
|
|
55
56
|
}
|
|
57
|
+
if (process.env.EXCEPTD_AIR_GAP === "1") out.airGap = true;
|
|
56
58
|
return out;
|
|
57
59
|
}
|
|
58
60
|
|
|
@@ -275,6 +277,19 @@ async function main() {
|
|
|
275
277
|
const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
276
278
|
const localVersion = localPkg.version;
|
|
277
279
|
|
|
280
|
+
// Air-gap refusal. --network needs egress to registry.npmjs.org for the
|
|
281
|
+
// /latest metadata + tarball pull. Under air-gap there is no offline
|
|
282
|
+
// substitute (the test fixture path remains available for offline tests),
|
|
283
|
+
// so refuse before any network attempt and point at the offline workflow.
|
|
284
|
+
if (opts.airGap && !process.env.EXCEPTD_REGISTRY_FIXTURE) {
|
|
285
|
+
emit({
|
|
286
|
+
ok: false,
|
|
287
|
+
source: "air-gap",
|
|
288
|
+
error: "air-gap: refresh --network requires network egress; refused. Use --from-cache --apply for the offline path.",
|
|
289
|
+
}, opts.json);
|
|
290
|
+
process.exitCode = 4; return;
|
|
291
|
+
}
|
|
292
|
+
|
|
278
293
|
progress(`local v${localVersion} — querying npm registry...`, opts.json);
|
|
279
294
|
|
|
280
295
|
let meta;
|