@blamejs/exceptd-skills 0.18.8 → 0.18.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/exceptd.js +197 -118
  3. package/data/_indexes/_meta.json +3 -3
  4. package/data/_indexes/frequency.json +2 -2
  5. package/data/d3fend-catalog.json +6 -6
  6. package/data/playbooks/identity-sso-compromise.json +2 -2
  7. package/data/playbooks/sbom.json +1 -1
  8. package/lib/citation-resolve.js +11 -0
  9. package/lib/collectors/containers.js +13 -0
  10. package/lib/cross-ref-api.js +29 -7
  11. package/lib/cve-regression-watcher.js +47 -15
  12. package/lib/framework-gap.js +27 -5
  13. package/lib/gap-detectors.js +8 -3
  14. package/lib/lint-skills.js +3 -2
  15. package/lib/playbook-runner.js +60 -5
  16. package/lib/refresh-external.js +58 -7
  17. package/lib/refresh-network.js +24 -8
  18. package/lib/rfc-cli.js +108 -18
  19. package/lib/schemas/playbook.schema.json +1 -1
  20. package/lib/scoring.js +31 -1
  21. package/lib/source-advisories.js +58 -9
  22. package/lib/ttp-mapper.js +31 -3
  23. package/lib/upstream-check-cli.js +13 -1
  24. package/lib/validate-catalog-meta.js +51 -7
  25. package/lib/validate-cve-catalog.js +10 -0
  26. package/lib/validate-playbooks.js +19 -1
  27. package/lib/xml-tokenizer.js +187 -25
  28. package/manifest.json +53 -53
  29. package/orchestrator/dispatcher.js +45 -9
  30. package/orchestrator/index.js +9 -7
  31. package/orchestrator/pipeline.js +62 -14
  32. package/orchestrator/scanner.js +40 -9
  33. package/package.json +1 -1
  34. package/sbom.cdx.json +105 -90
  35. package/scripts/build-indexes.js +21 -3
  36. package/scripts/builders/section-offsets.js +17 -8
  37. package/scripts/check-catalog-gap-budget.js +3 -3
  38. package/scripts/check-codebase-patterns.js +124 -11
  39. package/scripts/check-sbom-currency.js +69 -3
  40. package/scripts/check-test-count.js +28 -16
  41. package/scripts/check-test-subjects.js +127 -0
  42. package/scripts/check-version-tags.js +24 -5
  43. package/scripts/predeploy.js +13 -0
  44. package/scripts/refresh-upstream-catalogs.js +150 -42
  45. package/scripts/release.js +28 -11
  46. package/scripts/validate-vendor-online.js +12 -9
@@ -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
- const reviewDate = new Date(skill.last_threat_review || '2020-01-01');
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
  };
@@ -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 ---
@@ -175,7 +203,7 @@ function mcpScan() {
175
203
  return findings;
176
204
  }
177
205
 
178
- function cryptoScan() {
206
+ function cryptoScan(opts) {
179
207
  const findings = [];
180
208
 
181
209
  const opensslRaw = safeExecFile('openssl', ['version']);
@@ -218,15 +246,18 @@ function cryptoScan() {
218
246
 
219
247
  // Air-gap mode short-circuits the outbound TLS probe entirely; emit a
220
248
  // skipped annotation so operators can see the probe was intentionally
221
- // suppressed rather than failing silently.
222
- if (process.env.EXCEPTD_AIR_GAP === '1') {
249
+ // suppressed rather than failing silently. Honored equivalently for the
250
+ // EXCEPTD_AIR_GAP=1 env var AND the --air-gap / --offline / --no-network CLI
251
+ // flags (isAirGap inspects opts, env, and process.argv) — the flag is NOT a
252
+ // no-op on this egress path.
253
+ if (isAirGap(opts)) {
223
254
  findings.push({
224
255
  domain: 'crypto',
225
256
  signal: 'tls_probe',
226
257
  probe: 'skipped (air-gap)',
227
258
  severity: 'info',
228
259
  skill_hint: 'pqc-first',
229
- action_required: 'Air-gap mode active — TLS probe suppressed. Run with EXCEPTD_AIR_GAP=0 to probe.'
260
+ action_required: 'Air-gap mode active — TLS probe suppressed. Run without --air-gap (and with EXCEPTD_AIR_GAP unset) to probe.'
230
261
  });
231
262
  } else {
232
263
  const target = process.env.EXCEPTD_TLS_PROBE_TARGET || 'registry.npmjs.org:443';
@@ -589,4 +620,4 @@ function loadJson(filename) {
589
620
  }
590
621
  }
591
622
 
592
- module.exports = { scan, scanDomain, sanitizeConfig };
623
+ 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.8",
3
+ "version": "0.18.11",
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",