@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.
- package/AGENTS.md +18 -12
- package/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +152 -2
- package/CONTEXT.md +126 -69
- package/README.md +21 -8
- package/bin/exceptd.js +972 -464
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/stale-content.json +10 -3
- package/data/playbooks/ai-api.json +1 -1
- package/data/playbooks/containers.json +1 -1
- package/data/playbooks/cred-stores.json +1 -1
- package/data/playbooks/crypto-codebase.json +1 -1
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/framework.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/kernel.json +1 -1
- package/data/playbooks/library-author.json +1 -1
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +1 -1
- package/data/playbooks/sbom.json +1 -1
- package/data/playbooks/secrets.json +39 -1
- package/lib/auto-discovery.js +28 -4
- package/lib/cross-ref-api.js +12 -11
- package/lib/cve-curation.js +18 -19
- package/lib/exit-codes.js +72 -0
- package/lib/flag-suggest.js +130 -0
- package/lib/id-validation.js +95 -0
- package/lib/lint-skills.js +73 -6
- package/lib/playbook-runner.js +617 -343
- package/lib/prefetch.js +134 -21
- package/lib/refresh-external.js +205 -26
- package/lib/refresh-network.js +64 -16
- package/lib/schemas/cve-catalog.schema.json +7 -1
- package/lib/schemas/playbook.schema.json +51 -0
- package/lib/scoring.js +49 -7
- package/lib/sign.js +10 -11
- package/lib/source-osv.js +7 -7
- package/lib/upstream-check-cli.js +16 -1
- package/lib/upstream-check.js +9 -0
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-cve-catalog.js +1 -1
- package/lib/verify.js +56 -30
- package/manifest.json +40 -40
- package/package.json +8 -2
- package/sbom.cdx.json +6 -6
- package/scripts/check-test-coverage.js +67 -0
- 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
|
+
};
|
package/lib/lint-skills.js
CHANGED
|
@@ -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
|
-
//
|
|
166
|
-
//
|
|
167
|
-
// ("name: real-skill\nname: evil-skill") and silently take the
|
|
168
|
-
//
|
|
169
|
-
//
|
|
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,
|