@blamejs/exceptd-skills 0.12.22 → 0.12.24

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 (47) hide show
  1. package/AGENTS.md +18 -12
  2. package/ARCHITECTURE.md +2 -2
  3. package/CHANGELOG.md +152 -2
  4. package/CONTEXT.md +126 -69
  5. package/README.md +21 -8
  6. package/bin/exceptd.js +972 -464
  7. package/data/_indexes/_meta.json +3 -3
  8. package/data/_indexes/stale-content.json +10 -3
  9. package/data/playbooks/ai-api.json +1 -1
  10. package/data/playbooks/containers.json +1 -1
  11. package/data/playbooks/cred-stores.json +1 -1
  12. package/data/playbooks/crypto-codebase.json +1 -1
  13. package/data/playbooks/crypto.json +1 -1
  14. package/data/playbooks/framework.json +1 -1
  15. package/data/playbooks/hardening.json +1 -1
  16. package/data/playbooks/kernel.json +1 -1
  17. package/data/playbooks/library-author.json +1 -1
  18. package/data/playbooks/mcp.json +1 -1
  19. package/data/playbooks/runtime.json +1 -1
  20. package/data/playbooks/sbom.json +1 -1
  21. package/data/playbooks/secrets.json +39 -1
  22. package/lib/auto-discovery.js +28 -4
  23. package/lib/cross-ref-api.js +12 -11
  24. package/lib/cve-curation.js +18 -19
  25. package/lib/exit-codes.js +72 -0
  26. package/lib/flag-suggest.js +130 -0
  27. package/lib/id-validation.js +95 -0
  28. package/lib/lint-skills.js +73 -6
  29. package/lib/playbook-runner.js +617 -343
  30. package/lib/prefetch.js +134 -21
  31. package/lib/refresh-external.js +205 -26
  32. package/lib/refresh-network.js +64 -16
  33. package/lib/schemas/cve-catalog.schema.json +7 -1
  34. package/lib/schemas/playbook.schema.json +51 -0
  35. package/lib/scoring.js +49 -7
  36. package/lib/sign.js +10 -11
  37. package/lib/source-osv.js +7 -7
  38. package/lib/upstream-check-cli.js +16 -1
  39. package/lib/upstream-check.js +9 -0
  40. package/lib/validate-catalog-meta.js +1 -1
  41. package/lib/validate-cve-catalog.js +1 -1
  42. package/lib/verify.js +56 -30
  43. package/manifest.json +40 -40
  44. package/package.json +8 -2
  45. package/sbom.cdx.json +6 -6
  46. package/scripts/check-test-coverage.js +67 -0
  47. package/scripts/verify-shipped-tarball.js +27 -18
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Levenshtein-distance flag-typo suggestions.
5
+ *
6
+ * Operator typos `--evidnce` / `--csaf-stats` / `--bundle-epohc` were silently
7
+ * absorbed by the argv parser, falling through as boolean true flags with no
8
+ * value, then producing cryptic downstream errors. This helper compares an
9
+ * unknown flag to a verb-scoped allowlist and returns the closest match at
10
+ * distance ≤ 2 AND ≤ floor(flag.length / 2).
11
+ *
12
+ * Per-verb allowlists are the canonical CLI surface. Adding a new flag to a
13
+ * verb means appending to the allowlist here AND updating the printPlaybookVerbHelp
14
+ * block; a test asserts the two sets agree.
15
+ */
16
+
17
+ function editDistance(a, b) {
18
+ if (a === b) return 0;
19
+ if (a.length === 0) return b.length;
20
+ if (b.length === 0) return a.length;
21
+ const prev = new Array(b.length + 1);
22
+ const curr = new Array(b.length + 1);
23
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
24
+ for (let i = 1; i <= a.length; i++) {
25
+ curr[0] = i;
26
+ for (let j = 1; j <= b.length; j++) {
27
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
28
+ curr[j] = Math.min(
29
+ curr[j - 1] + 1,
30
+ prev[j] + 1,
31
+ prev[j - 1] + cost,
32
+ );
33
+ }
34
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
35
+ }
36
+ return prev[b.length];
37
+ }
38
+
39
+ /**
40
+ * Suggest the closest allowlisted flag to a given unknown flag.
41
+ *
42
+ * @param {string} flag - operator-supplied flag name without leading --
43
+ * @param {string[]} allowlist - known flag names for the active verb
44
+ * @returns {string|null} the suggested flag name or null when no close match
45
+ */
46
+ function suggestFlag(flag, allowlist) {
47
+ if (typeof flag !== 'string' || flag.length === 0) return null;
48
+ if (!Array.isArray(allowlist) || allowlist.length === 0) return null;
49
+ const probe = flag.toLowerCase();
50
+ const cap = Math.min(2, Math.floor(flag.length / 2));
51
+ let bestDist = Infinity;
52
+ let best = null;
53
+ for (const candidate of allowlist) {
54
+ const d = editDistance(probe, candidate.toLowerCase());
55
+ if (d < bestDist && d <= cap) {
56
+ bestDist = d;
57
+ best = candidate;
58
+ }
59
+ }
60
+ return best;
61
+ }
62
+
63
+ /**
64
+ * Per-verb known-flag allowlist. Every operator-facing flag should appear
65
+ * exactly once per verb where it is consumed. Flags consumed by every verb
66
+ * (e.g. `pretty`, `json`, `help`) live under '_global'.
67
+ */
68
+ const VERB_FLAG_ALLOWLIST = Object.freeze({
69
+ _global: ['help', 'pretty', 'json', 'verbose'],
70
+ run: [
71
+ 'evidence', 'evidence-dir', 'session-id', 'force-overwrite', 'attestation-root',
72
+ 'mode', 'air-gap', 'force-stale', 'operator', 'ack', 'csaf-status',
73
+ 'publisher-namespace', 'vex', 'diff-from-latest', 'all', 'scope',
74
+ 'strict-preconditions', 'ci', 'block-on-jurisdiction-clock', 'upstream-check',
75
+ 'session-key', 'tlp', 'bundle-deterministic', 'bundle-epoch',
76
+ ],
77
+ ci: [
78
+ 'evidence', 'evidence-dir', 'session-id', 'force-overwrite', 'attestation-root',
79
+ 'mode', 'air-gap', 'force-stale', 'operator', 'ack', 'csaf-status',
80
+ 'publisher-namespace', 'vex', 'all', 'scope', 'required', 'format',
81
+ 'strict-preconditions', 'block-on-jurisdiction-clock', 'tlp',
82
+ ],
83
+ 'run-all': [
84
+ 'evidence', 'evidence-dir', 'session-id', 'force-overwrite', 'attestation-root',
85
+ 'mode', 'air-gap', 'force-stale', 'operator', 'ack', 'csaf-status',
86
+ 'publisher-namespace', 'vex', 'scope', 'strict-preconditions', 'tlp',
87
+ ],
88
+ 'ai-run': [
89
+ 'evidence', 'no-stream', 'session-id', 'force-overwrite', 'attestation-root',
90
+ 'operator', 'ack', 'csaf-status', 'publisher-namespace', 'air-gap',
91
+ 'mode', 'force-stale', 'tlp',
92
+ ],
93
+ ingest: [
94
+ 'evidence', 'session-id', 'force-overwrite', 'attestation-root', 'operator',
95
+ 'ack', 'csaf-status', 'publisher-namespace', 'air-gap', 'force-stale',
96
+ 'strict-preconditions',
97
+ ],
98
+ brief: ['all', 'scope', 'directives', 'flat', 'phase'],
99
+ discover: ['scan-only', 'scope'],
100
+ ask: [],
101
+ attest: [
102
+ 'against', 'playbook', 'since', 'latest', 'format', 'force', 'dry-run',
103
+ 'all-older-than',
104
+ ],
105
+ reattest: [
106
+ 'playbook', 'since', 'latest', 'force-replay', 'attestation-root',
107
+ ],
108
+ doctor: ['signatures', 'cves', 'rfcs', 'fix', 'registry-check', 'exit-codes'],
109
+ lint: ['evidence'],
110
+ refresh: [
111
+ 'apply', 'dry-run', 'from-cache', 'from-fixture', 'network', 'source',
112
+ 'advisory', 'force-stale', 'force-stale-acked', 'air-gap', 'swarm',
113
+ ],
114
+ prefetch: ['source', 'cache-dir', 'max-age', 'force', 'no-network', 'quiet'],
115
+ });
116
+
117
+ /**
118
+ * Return the allowlist for a verb (global flags always included).
119
+ */
120
+ function flagsFor(verb) {
121
+ const verbFlags = VERB_FLAG_ALLOWLIST[verb] || [];
122
+ return [...VERB_FLAG_ALLOWLIST._global, ...verbFlags];
123
+ }
124
+
125
+ module.exports = {
126
+ editDistance,
127
+ suggestFlag,
128
+ flagsFor,
129
+ VERB_FLAG_ALLOWLIST,
130
+ };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared validation for path-component-shaped operator inputs.
5
+ *
6
+ * Six sites in `bin/exceptd.js` previously hand-rolled regexes of the form
7
+ * /^[A-Za-z0-9._-]{1,64}$/ for `--session-id`, `--playbook`, attestation
8
+ * filenames, and `--evidence-dir` filenames. Each regex was slightly
9
+ * different in character class ordering; each grew its own follow-on checks
10
+ * (all-dots refusal, length cap, leading-dot refusal) at different rates.
11
+ *
12
+ * This module is the single source of truth. Adding a new path-component
13
+ * input means calling `validateIdComponent(value, role)` and propagating
14
+ * the returned {ok, reason} pair to the caller's emit-error path.
15
+ *
16
+ * Three role types, three character classes:
17
+ * - 'session' — sessions live under `.exceptd/attestations/<sid>/`. Allow
18
+ * lower+upper alpha, digit, dot, underscore, hyphen, 1-64
19
+ * chars. Refuse all-dots.
20
+ * - 'playbook' — playbook ids index `data/playbooks/<id>.json`. Stricter:
21
+ * lowercase-only, must start with a letter, no dots (all
22
+ * catalogued playbook ids match `/^[a-z][a-z0-9-]{0,63}$/`).
23
+ * - 'filename' — attestation filename inside a session directory. Same
24
+ * charset as 'session' but length cap reflects filename
25
+ * policy (no path separators ever).
26
+ *
27
+ * The function never reads the filesystem; combine with realpathSync at
28
+ * the caller for full path-traversal defense.
29
+ */
30
+
31
+ const SESSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
32
+ const PLAYBOOK_RE = /^[a-z][a-z0-9-]{0,63}$/;
33
+ const FILENAME_RE = /^[A-Za-z0-9._-]{1,80}$/;
34
+ const ALL_DOTS_RE = /^\.+$/;
35
+
36
+ function validateIdComponent(value, role) {
37
+ if (typeof value !== 'string') {
38
+ return { ok: false, reason: `expected string, got ${typeof value}` };
39
+ }
40
+ if (value.length === 0) {
41
+ return { ok: false, reason: 'must not be empty' };
42
+ }
43
+ let re;
44
+ let constraint;
45
+ switch (role) {
46
+ case 'session':
47
+ re = SESSION_RE;
48
+ constraint = '^[A-Za-z0-9._-]{1,64}$';
49
+ break;
50
+ case 'playbook':
51
+ re = PLAYBOOK_RE;
52
+ constraint = '^[a-z][a-z0-9-]{0,63}$ (lowercase, starts with letter, no dots)';
53
+ break;
54
+ case 'filename':
55
+ re = FILENAME_RE;
56
+ constraint = '^[A-Za-z0-9._-]{1,80}$';
57
+ break;
58
+ default:
59
+ return { ok: false, reason: `unknown role: ${role}` };
60
+ }
61
+ if (!re.test(value)) {
62
+ return { ok: false, reason: `must match ${constraint}` };
63
+ }
64
+ // All-dots refusal applies after the character-class regex because the
65
+ // session/filename classes admit any string of dots (`.`, `..`, `...`),
66
+ // each of which path-resolves into or above the intended directory.
67
+ if (ALL_DOTS_RE.test(value)) {
68
+ return { ok: false, reason: 'must not consist entirely of dots' };
69
+ }
70
+ return { ok: true };
71
+ }
72
+
73
+ /**
74
+ * Cheap typed-throw wrapper for callers that prefer exceptions over result
75
+ * objects (lib/playbook-runner.js uses this shape for loadPlaybook).
76
+ */
77
+ function assertIdComponent(value, role) {
78
+ const r = validateIdComponent(value, role);
79
+ if (!r.ok) {
80
+ const err = new Error(`invalid ${role} id (${r.reason}): ${typeof value === 'string' ? value.slice(0, 80) : typeof value}`);
81
+ err.code = 'EXCEPTD_INVALID_ID';
82
+ err.role = role;
83
+ err.reason = r.reason;
84
+ throw err;
85
+ }
86
+ return value;
87
+ }
88
+
89
+ module.exports = {
90
+ validateIdComponent,
91
+ assertIdComponent,
92
+ SESSION_RE,
93
+ PLAYBOOK_RE,
94
+ FILENAME_RE,
95
+ };
@@ -162,11 +162,11 @@ function readJson(p) {
162
162
  function parseFrontmatter(text) {
163
163
  const lines = text.split(/\r?\n/);
164
164
  const result = {};
165
- // S4: track every top-level key we've already assigned. YAML's
166
- // last-wins semantics would let a tampered skill set name twice
167
- // ("name: real-skill\nname: evil-skill") and silently take the
168
- // second value — a skill-identity spoofing primitive. Refuse
169
- // duplicates outright; an honest skill never has them.
165
+ // Track every top-level key we've already assigned. YAML's last-wins
166
+ // semantics would let a tampered skill set name twice
167
+ // ("name: real-skill\nname: evil-skill") and silently take the second
168
+ // value — a skill-identity spoofing primitive. Refuse duplicates
169
+ // outright; an honest skill never has them.
170
170
  const seenKeys = new Set();
171
171
  let i = 0;
172
172
  while (i < lines.length) {
@@ -655,6 +655,59 @@ function findOrphanSkillFiles(manifestSkills) {
655
655
  return orphans;
656
656
  }
657
657
 
658
+ // Substrings that indicate an artifact `source` makes a network call. Used
659
+ // by lintPlaybookAirGap() to flag artifacts that lack an air_gap_alternative.
660
+ // Conservative-by-design — false positives are surfaced as `warn` (not
661
+ // `error`) and a playbook author who has reviewed the source can suppress
662
+ // by adding an air_gap_alternative even when the source itself is offline.
663
+ const PLAYBOOK_NET_PATTERNS = [
664
+ 'https://', 'http://', 'gh api', 'gh release', 'curl ', 'wget ', 'fetch ',
665
+ ];
666
+
667
+ const PLAYBOOK_DIR = path.join(DATA_DIR, 'playbooks');
668
+
669
+ /**
670
+ * Air-gap completeness lint for shipped playbooks. Walks every
671
+ * data/playbooks/*.json file, examines phases.look.artifacts[], and warns
672
+ * when an artifact's `source` contains a network-call substring without a
673
+ * sibling `air_gap_alternative`. The playbook schema's hard `if/then`
674
+ * conditional (added v0.12.24) catches this for playbooks marked
675
+ * `_meta.air_gap_mode: true`; this lint surfaces the gap for every
676
+ * playbook, on the principle that a non-air-gap playbook may still be
677
+ * invoked under `exceptd --air-gap` and operators deserve the warning.
678
+ *
679
+ * Returns an array of `{ playbook, artifact_id, source }` warning records.
680
+ */
681
+ function lintPlaybookAirGap() {
682
+ const warnings = [];
683
+ if (!fs.existsSync(PLAYBOOK_DIR)) return warnings;
684
+ const files = fs.readdirSync(PLAYBOOK_DIR).filter(f => f.endsWith('.json') && !f.startsWith('_'));
685
+ for (const f of files) {
686
+ let playbook;
687
+ try {
688
+ playbook = readJson(path.join(PLAYBOOK_DIR, f));
689
+ } catch {
690
+ continue; // schema validator catches parse errors separately
691
+ }
692
+ const arts = playbook && playbook.phases && playbook.phases.look && playbook.phases.look.artifacts;
693
+ if (!Array.isArray(arts)) continue;
694
+ for (const a of arts) {
695
+ if (!a || typeof a !== 'object') continue;
696
+ const src = a.source;
697
+ if (typeof src !== 'string') continue;
698
+ const isNet = PLAYBOOK_NET_PATTERNS.some(p => src.includes(p));
699
+ if (isNet && !a.air_gap_alternative) {
700
+ warnings.push({
701
+ playbook: playbook._meta && playbook._meta.id ? playbook._meta.id : f.replace(/\.json$/, ''),
702
+ artifact_id: a.id || '<unknown>',
703
+ source: src,
704
+ });
705
+ }
706
+ }
707
+ }
708
+ return warnings;
709
+ }
710
+
658
711
  function main() {
659
712
  const opts = parseArgs(process.argv);
660
713
  const manifest = readJson(MANIFEST_PATH);
@@ -700,6 +753,7 @@ function main() {
700
753
  // A targeted single-skill lint is for diagnosing one entry; running
701
754
  // the orphan walk there would surface unrelated findings.
702
755
  let orphans = [];
756
+ let airGapWarnings = [];
703
757
  if (!opts.skill) {
704
758
  orphans = findOrphanSkillFiles(manifest.skills);
705
759
  for (const o of orphans) {
@@ -707,14 +761,25 @@ function main() {
707
761
  console.log(` - skill.md exists on disk but not in manifest: ${o}`);
708
762
  console.log(` fix: re-run \`node lib/sign.js sign-all\` after adding it to manifest.json, OR delete the orphan directory`);
709
763
  }
764
+ // P4 — air-gap completeness lint over data/playbooks/*.json.
765
+ airGapWarnings = lintPlaybookAirGap();
766
+ for (const w of airGapWarnings) {
767
+ console.log(`WARN playbook:${w.playbook}`);
768
+ console.log(` - [warn] artifact "${w.artifact_id}" source contains a network call but has no air_gap_alternative`);
769
+ console.log(` source: ${w.source}`);
770
+ console.log(` fix: add an air_gap_alternative source (offline file path / packaged dataset / pre-staged artifact)`);
771
+ }
710
772
  }
711
773
 
712
774
  const total = results.length;
713
775
  const passed = total - failed - warned;
714
776
  const orphanSummary = orphans.length ? `, ${orphans.length} orphan skill.md file(s)` : '';
715
777
  const warnSummary = warned ? `, ${warned} with warnings` : '';
778
+ const airGapSummary = airGapWarnings && airGapWarnings.length
779
+ ? `, ${airGapWarnings.length} playbook artifact(s) missing air_gap_alternative`
780
+ : '';
716
781
  console.log(
717
- `\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}.`,
782
+ `\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}${airGapSummary}.`,
718
783
  );
719
784
  process.exit(failed === 0 && orphans.length === 0 ? 0 : 1);
720
785
  }
@@ -727,6 +792,8 @@ module.exports = {
727
792
  unquote,
728
793
  findOrphanSkillFiles,
729
794
  findMissingSections,
795
+ lintPlaybookAirGap,
796
+ PLAYBOOK_NET_PATTERNS,
730
797
  REQUIRED_SECTIONS,
731
798
  COUNTERMEASURE_SECTION,
732
799
  COUNTERMEASURE_CUTOFF,