@blamejs/exceptd-skills 0.18.9 → 0.18.12
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 +36 -0
- package/bin/exceptd.js +204 -118
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/frequency.json +2 -2
- package/data/d3fend-catalog.json +6 -6
- package/data/playbooks/identity-sso-compromise.json +2 -2
- package/data/playbooks/sbom.json +1 -1
- package/lib/citation-resolve.js +11 -0
- package/lib/collectors/containers.js +13 -0
- package/lib/collectors/cred-stores.js +18 -9
- package/lib/collectors/secrets.js +4 -2
- package/lib/cross-ref-api.js +29 -7
- package/lib/cve-regression-watcher.js +47 -15
- package/lib/framework-gap.js +52 -19
- package/lib/gap-detectors.js +8 -3
- package/lib/lint-skills.js +3 -2
- package/lib/playbook-runner.js +125 -7
- package/lib/refresh-external.js +58 -7
- package/lib/refresh-network.js +18 -5
- package/lib/rfc-cli.js +113 -18
- package/lib/schemas/playbook.schema.json +1 -1
- package/lib/scoring.js +71 -8
- package/lib/source-advisories.js +58 -9
- package/lib/ttp-mapper.js +31 -3
- package/lib/upstream-check-cli.js +13 -1
- package/lib/validate-catalog-meta.js +51 -7
- package/lib/validate-cve-catalog.js +10 -0
- package/lib/validate-playbooks.js +19 -1
- package/lib/verify.js +35 -34
- package/lib/xml-tokenizer.js +187 -25
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +53 -9
- package/orchestrator/index.js +9 -7
- package/orchestrator/pipeline.js +62 -14
- package/orchestrator/scanner.js +60 -9
- package/package.json +1 -1
- package/sbom.cdx.json +115 -100
- package/scripts/build-indexes.js +21 -3
- package/scripts/builders/cwe-chains.js +5 -2
- package/scripts/builders/section-offsets.js +17 -8
- package/scripts/builders/summary-cards.js +12 -4
- package/scripts/check-catalog-gap-budget.js +3 -3
- package/scripts/check-codebase-patterns-currency.js +1 -0
- package/scripts/check-codebase-patterns.js +166 -11
- package/scripts/check-sbom-currency.js +69 -3
- package/scripts/check-test-count.js +28 -16
- package/scripts/check-test-subjects.js +148 -0
- package/scripts/check-version-tags.js +24 -5
- package/scripts/predeploy.js +32 -8
- package/scripts/refresh-upstream-catalogs.js +169 -44
- package/scripts/release.js +28 -11
package/orchestrator/pipeline.js
CHANGED
|
@@ -140,26 +140,52 @@ function _loadManifestCached() {
|
|
|
140
140
|
return parsed;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Compute the currency row for a single skill against a reference `now`.
|
|
145
|
+
*
|
|
146
|
+
* Pulled out of currencyCheck so the parse-guard is unit-testable without a
|
|
147
|
+
* real manifest read. A malformed `last_threat_review` (e.g. "2026-13-99",
|
|
148
|
+
* which is structurally ISO-shaped but not a real calendar date) must map to
|
|
149
|
+
* maximally STALE — score 0, action_required true — not to the safe-LOOKING
|
|
150
|
+
* 100 a NaN delta would otherwise yield. The misformat is also surfaced via
|
|
151
|
+
* `unparseable_review_date` so a bad date is OBSERVABLE rather than silently
|
|
152
|
+
* defaulting (matching the project rule that a no-match/fall-through path must
|
|
153
|
+
* surface, not silently pass).
|
|
154
|
+
*
|
|
155
|
+
* @param {object} skill
|
|
156
|
+
* @param {Date} now
|
|
157
|
+
*/
|
|
158
|
+
function _skillCurrencyRow(skill, now) {
|
|
159
|
+
const rawDate = skill.last_threat_review || '2020-01-01';
|
|
160
|
+
// Use the timezone-stable date-only convention the sibling builders use
|
|
161
|
+
// (append T00:00:00Z for bare YYYY-MM-DD) so the day delta doesn't shift by
|
|
162
|
+
// the runner's local offset.
|
|
163
|
+
const t = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(rawDate) ? rawDate + 'T00:00:00Z' : rawDate);
|
|
164
|
+
const unparseable = !Number.isFinite(t);
|
|
165
|
+
const reviewDate = unparseable ? new Date('2020-01-01T00:00:00Z') : new Date(t);
|
|
166
|
+
const daysSinceReview = Math.floor((now - reviewDate) / (1000 * 60 * 60 * 24));
|
|
167
|
+
|
|
168
|
+
const currencyScore = _currencyScore(daysSinceReview, skill.forward_watch?.length || 0);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
skill: skill.name,
|
|
172
|
+
last_threat_review: skill.last_threat_review,
|
|
173
|
+
days_since_review: daysSinceReview,
|
|
174
|
+
currency_score: currencyScore,
|
|
175
|
+
currency_label: _currencyLabel(currencyScore),
|
|
176
|
+
forward_watch_count: skill.forward_watch?.length || 0,
|
|
177
|
+
unparseable_review_date: unparseable,
|
|
178
|
+
action_required: currencyScore < 70
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
143
182
|
function currencyCheck() {
|
|
144
183
|
const manifest = _loadManifestCached();
|
|
145
184
|
const now = new Date();
|
|
146
185
|
const report = [];
|
|
147
186
|
|
|
148
187
|
for (const skill of manifest.skills) {
|
|
149
|
-
|
|
150
|
-
const daysSinceReview = Math.floor((now - reviewDate) / (1000 * 60 * 60 * 24));
|
|
151
|
-
|
|
152
|
-
const currencyScore = _currencyScore(daysSinceReview, skill.forward_watch?.length || 0);
|
|
153
|
-
|
|
154
|
-
report.push({
|
|
155
|
-
skill: skill.name,
|
|
156
|
-
last_threat_review: skill.last_threat_review,
|
|
157
|
-
days_since_review: daysSinceReview,
|
|
158
|
-
currency_score: currencyScore,
|
|
159
|
-
currency_label: _currencyLabel(currencyScore),
|
|
160
|
-
forward_watch_count: skill.forward_watch?.length || 0,
|
|
161
|
-
action_required: currencyScore < 70
|
|
162
|
-
});
|
|
188
|
+
report.push(_skillCurrencyRow(skill, now));
|
|
163
189
|
}
|
|
164
190
|
|
|
165
191
|
report.sort((a, b) => a.currency_score - b.currency_score);
|
|
@@ -190,6 +216,18 @@ function getAgentDefinition(stageName) {
|
|
|
190
216
|
// --- private helpers ---
|
|
191
217
|
|
|
192
218
|
function validateHandoff(stageName, output) {
|
|
219
|
+
// Guard the `in` deref below: a null / non-object / array `output` would
|
|
220
|
+
// otherwise throw an opaque "Cannot use 'in' operator …" TypeError deep in
|
|
221
|
+
// the missing-fields filter. Reject up front with a named error in the same
|
|
222
|
+
// style as buildHandoff's stageIndex RangeError, so the caller sees which
|
|
223
|
+
// input was wrong. A handoff payload is a keyed object — an array is not a
|
|
224
|
+
// valid payload, so Array.isArray is rejected too (today it would yield the
|
|
225
|
+
// less-precise "missing fields" error).
|
|
226
|
+
if (output === null || typeof output !== 'object' || Array.isArray(output)) {
|
|
227
|
+
throw new TypeError(
|
|
228
|
+
`buildHandoff: stageOutput for ${stageName} must be a non-null object`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
193
231
|
const required = {
|
|
194
232
|
'threat-researcher': ['cve_id_or_ttp', 'findings', 'primary_sources', 'confidence'],
|
|
195
233
|
'source-validator': ['verdict', 'verified_claims', 'rejected_claims'],
|
|
@@ -240,6 +278,13 @@ function _currencyScore(daysSinceReview, _forwardWatchCount) {
|
|
|
240
278
|
// and the workflow issue they gate — could never fire. The deeper penalties
|
|
241
279
|
// only bite past 180/270/365 days, so a normally-maintained skill stays
|
|
242
280
|
// 'acceptable' while a genuinely abandoned one reaches the gate.
|
|
281
|
+
//
|
|
282
|
+
// Belt-and-suspenders: a NaN delta (the day-count derived from an
|
|
283
|
+
// unparseable last_threat_review) must map to maximally STALE (0), never the
|
|
284
|
+
// safe-LOOKING 100 the additive schedule below would leave it at. This is an
|
|
285
|
+
// exported surface other callers/tests use, so guard here even though
|
|
286
|
+
// _skillCurrencyRow already substitutes a stale fallback date upstream.
|
|
287
|
+
if (!Number.isFinite(daysSinceReview)) return 0;
|
|
243
288
|
let score = 100;
|
|
244
289
|
if (daysSinceReview > 365) score -= 100; // a year+ unreviewed → 0 (critical_stale)
|
|
245
290
|
else if (daysSinceReview > 270) score -= 60; // → 40 (critical_stale, < 50)
|
|
@@ -277,4 +322,7 @@ module.exports = {
|
|
|
277
322
|
// Exported for the gate-reachability contract test: the schedule must be able
|
|
278
323
|
// to reach the warn (< 70) and critical (< 50) tiers the workflow issues on.
|
|
279
324
|
_currencyScore,
|
|
325
|
+
// Exported so the malformed-date staleness guard is unit-testable without a
|
|
326
|
+
// real manifest read.
|
|
327
|
+
_skillCurrencyRow,
|
|
280
328
|
};
|
package/orchestrator/scanner.js
CHANGED
|
@@ -15,6 +15,30 @@ const { execFileSync, spawnSync } = require('child_process');
|
|
|
15
15
|
|
|
16
16
|
const DATA_DIR = process.env.EXCEPTD_DATA_DIR || path.join(__dirname, '..', 'data');
|
|
17
17
|
|
|
18
|
+
// CLI flags that request air-gap (no-egress) operation. `--air-gap` is the
|
|
19
|
+
// documented form; `--offline` / `--no-network` are accepted aliases (the
|
|
20
|
+
// orchestrator's flag allowlists accept all three). The flag MUST be honored
|
|
21
|
+
// equivalently to EXCEPTD_AIR_GAP=1 on the egress path — historically the env
|
|
22
|
+
// var suppressed the outbound TLS probe but the CLI flag did not, so an
|
|
23
|
+
// operator passing `--air-gap` still fired a TLS s_client connect.
|
|
24
|
+
const AIR_GAP_FLAGS = ['--air-gap', '--offline', '--no-network'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve whether air-gap mode is active. True when EXCEPTD_AIR_GAP=1, when an
|
|
28
|
+
* explicit `opts.airGap` is passed, OR when any air-gap flag is present on the
|
|
29
|
+
* invoking process's argv. Checking argv makes the CLI flag effective even
|
|
30
|
+
* though the scanner is reached in-process from the orchestrator entry — the
|
|
31
|
+
* flag and the env var are now equivalent on the egress path.
|
|
32
|
+
*
|
|
33
|
+
* @param {{ airGap?: boolean }} [opts]
|
|
34
|
+
*/
|
|
35
|
+
function isAirGap(opts) {
|
|
36
|
+
if (opts && opts.airGap) return true;
|
|
37
|
+
if (process.env.EXCEPTD_AIR_GAP === '1') return true;
|
|
38
|
+
const argv = Array.isArray(process.argv) ? process.argv : [];
|
|
39
|
+
return argv.some(a => typeof a === 'string' && AIR_GAP_FLAGS.includes(a));
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
// --- public API ---
|
|
19
43
|
|
|
20
44
|
/**
|
|
@@ -32,9 +56,13 @@ const DATA_DIR = process.env.EXCEPTD_DATA_DIR || path.join(__dirname, '..', 'dat
|
|
|
32
56
|
* deadlines, auditor-ready exception language, regression schedules.
|
|
33
57
|
* `exceptd scan` will be removed in v1.0.
|
|
34
58
|
*
|
|
59
|
+
* @param {{ airGap?: boolean }} [opts] - When `opts.airGap` is set, the
|
|
60
|
+
* outbound TLS probe is suppressed exactly as EXCEPTD_AIR_GAP=1 does. The
|
|
61
|
+
* air-gap CLI flags (--air-gap / --offline / --no-network) are also honored
|
|
62
|
+
* directly off process.argv, so the flag is equivalent to the env var.
|
|
35
63
|
* @returns {{ timestamp: string, host: object, findings: object[], summary: object }}
|
|
36
64
|
*/
|
|
37
|
-
async function scan() {
|
|
65
|
+
async function scan(opts) {
|
|
38
66
|
const timestamp = new Date().toISOString();
|
|
39
67
|
const findings = [];
|
|
40
68
|
|
|
@@ -50,7 +78,7 @@ async function scan() {
|
|
|
50
78
|
const host = hostInfo();
|
|
51
79
|
findings.push(...kernelScan());
|
|
52
80
|
findings.push(...mcpScan());
|
|
53
|
-
findings.push(...cryptoScan());
|
|
81
|
+
findings.push(...cryptoScan(opts));
|
|
54
82
|
findings.push(...aiApiScan());
|
|
55
83
|
findings.push(...frameworkScan());
|
|
56
84
|
|
|
@@ -65,11 +93,11 @@ async function scan() {
|
|
|
65
93
|
* Run a targeted scan for a specific domain.
|
|
66
94
|
* @param {'kernel'|'mcp'|'crypto'|'ai_api'|'framework'} domain
|
|
67
95
|
*/
|
|
68
|
-
async function scanDomain(domain) {
|
|
96
|
+
async function scanDomain(domain, opts) {
|
|
69
97
|
const scanners = { kernel: kernelScan, mcp: mcpScan, crypto: cryptoScan, ai_api: aiApiScan, framework: frameworkScan };
|
|
70
98
|
const fn = scanners[domain];
|
|
71
99
|
if (!fn) throw new Error(`Unknown scan domain: ${domain}. Valid: ${Object.keys(scanners).join(', ')}`);
|
|
72
|
-
return fn();
|
|
100
|
+
return fn(opts);
|
|
73
101
|
}
|
|
74
102
|
|
|
75
103
|
// --- domain scanners ---
|
|
@@ -133,6 +161,26 @@ function mcpScan() {
|
|
|
133
161
|
|
|
134
162
|
for (const serverName of serverList) {
|
|
135
163
|
const server = mcpServers[serverName];
|
|
164
|
+
// A null / non-object / array server entry can't be probed for
|
|
165
|
+
// signature or pinned-version the way a real server config can.
|
|
166
|
+
// Dereferencing its fields would throw inside this per-server loop,
|
|
167
|
+
// which the outer catch would mis-report as a whole-file config
|
|
168
|
+
// parse error — dropping every sibling server's finding. Emit a
|
|
169
|
+
// per-server malformed marker and continue so siblings still scan,
|
|
170
|
+
// mirroring how a genuine parse failure is surfaced per-file.
|
|
171
|
+
if (server === null || typeof server !== 'object' || Array.isArray(server)) {
|
|
172
|
+
findings.push({
|
|
173
|
+
domain: 'mcp',
|
|
174
|
+
signal: 'mcp_server_malformed',
|
|
175
|
+
tool,
|
|
176
|
+
config_path: p,
|
|
177
|
+
server_name: serverName,
|
|
178
|
+
severity: 'low',
|
|
179
|
+
skill_hint: 'mcp-agent-trust',
|
|
180
|
+
action_required: 'MCP server entry is not an object — verify the config is well-formed'
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
136
184
|
const isSigned = server.signature !== undefined;
|
|
137
185
|
const serverJson = JSON.stringify(server);
|
|
138
186
|
const hasPinnedVersion = /[@#]\d+\.\d+\.\d+/.test(serverJson);
|
|
@@ -175,7 +223,7 @@ function mcpScan() {
|
|
|
175
223
|
return findings;
|
|
176
224
|
}
|
|
177
225
|
|
|
178
|
-
function cryptoScan() {
|
|
226
|
+
function cryptoScan(opts) {
|
|
179
227
|
const findings = [];
|
|
180
228
|
|
|
181
229
|
const opensslRaw = safeExecFile('openssl', ['version']);
|
|
@@ -218,15 +266,18 @@ function cryptoScan() {
|
|
|
218
266
|
|
|
219
267
|
// Air-gap mode short-circuits the outbound TLS probe entirely; emit a
|
|
220
268
|
// skipped annotation so operators can see the probe was intentionally
|
|
221
|
-
// suppressed rather than failing silently.
|
|
222
|
-
|
|
269
|
+
// suppressed rather than failing silently. Honored equivalently for the
|
|
270
|
+
// EXCEPTD_AIR_GAP=1 env var AND the --air-gap / --offline / --no-network CLI
|
|
271
|
+
// flags (isAirGap inspects opts, env, and process.argv) — the flag is NOT a
|
|
272
|
+
// no-op on this egress path.
|
|
273
|
+
if (isAirGap(opts)) {
|
|
223
274
|
findings.push({
|
|
224
275
|
domain: 'crypto',
|
|
225
276
|
signal: 'tls_probe',
|
|
226
277
|
probe: 'skipped (air-gap)',
|
|
227
278
|
severity: 'info',
|
|
228
279
|
skill_hint: 'pqc-first',
|
|
229
|
-
action_required: 'Air-gap mode active — TLS probe suppressed. Run with EXCEPTD_AIR_GAP
|
|
280
|
+
action_required: 'Air-gap mode active — TLS probe suppressed. Run without --air-gap (and with EXCEPTD_AIR_GAP unset) to probe.'
|
|
230
281
|
});
|
|
231
282
|
} else {
|
|
232
283
|
const target = process.env.EXCEPTD_TLS_PROBE_TARGET || 'registry.npmjs.org:443';
|
|
@@ -589,4 +640,4 @@ function loadJson(filename) {
|
|
|
589
640
|
}
|
|
590
641
|
}
|
|
591
642
|
|
|
592
|
-
module.exports = { scan, scanDomain, sanitizeConfig };
|
|
643
|
+
module.exports = { scan, scanDomain, sanitizeConfig, isAirGap };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/exceptd-skills",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.12",
|
|
4
4
|
"description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 51 skills, 11 catalogs (459 CVEs / 181 CWEs / 805 ATT&CK + ICS / 170 ATLAS / 468 D3FEND / 8888 RFCs), 35 jurisdictions, 10-class catalog gap detector + budget gate, real XML parser + canonical-form diff + content-pattern regression detection, Ed25519-signed.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-security",
|