@blamejs/exceptd-skills 0.12.13 → 0.12.15
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 +150 -0
- package/bin/exceptd.js +147 -9
- package/data/_indexes/_meta.json +45 -45
- package/data/_indexes/activity-feed.json +4 -4
- package/data/_indexes/catalog-summaries.json +29 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +319 -76
- package/data/cve-catalog.json +515 -475
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +21 -4
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +18 -5
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +742 -78
- package/lib/refresh-external.js +40 -22
- package/lib/refresh-network.js +193 -17
- package/lib/scoring.js +20 -7
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +56 -18
- package/lib/validate-indexes.js +88 -37
- package/lib/verify.js +72 -0
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +150 -17
- package/package.json +3 -1
- package/sbom.cdx.json +7 -7
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +83 -39
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +106 -3
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
|
@@ -3,12 +3,21 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Scheduled task coordinator for skill currency maintenance.
|
|
5
5
|
*
|
|
6
|
-
* Schedules: weekly currency check, monthly CVE validation, annual
|
|
7
|
-
* Emits events via event-bus.js when currency thresholds
|
|
6
|
+
* Schedules: weekly currency check, monthly CVE validation reminder, annual
|
|
7
|
+
* full audit reminder. Emits events via event-bus.js when currency thresholds
|
|
8
|
+
* are breached.
|
|
8
9
|
*
|
|
9
10
|
* This is a simple interval-based scheduler. For production use, swap for a
|
|
10
11
|
* proper cron daemon or cloud scheduler without changing the task definitions.
|
|
11
12
|
*
|
|
13
|
+
* Bootstrap-fire policy. Long-cadence tasks (monthly, annual) are also
|
|
14
|
+
* evaluated on `start()` so a freshly-restarted watcher does not silently
|
|
15
|
+
* skip a due interval. Whether a task fires on bootstrap is gated by a
|
|
16
|
+
* persisted "last fired" timestamp store at
|
|
17
|
+
* `~/.exceptd/scheduler-last-fired.json` — the task fires only when the
|
|
18
|
+
* elapsed time since the persisted timestamp exceeds the interval. The
|
|
19
|
+
* weekly currency check always fires on bootstrap (legacy behavior).
|
|
20
|
+
*
|
|
12
21
|
* Implementation note — INT32 overflow guard. `setInterval` / `setTimeout`
|
|
13
22
|
* delay values are coerced to a signed 32-bit integer; any value above
|
|
14
23
|
* 2^31 - 1 ms (~24.8 days) is silently clamped to 1 ms by Node, which
|
|
@@ -19,6 +28,10 @@
|
|
|
19
28
|
* delay — including multi-year intervals — fires exactly when due.
|
|
20
29
|
*/
|
|
21
30
|
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const os = require('os');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
|
|
22
35
|
const { bus, EVENT_TYPES } = require('./event-bus');
|
|
23
36
|
const { currencyCheck } = require('./pipeline');
|
|
24
37
|
|
|
@@ -36,9 +49,64 @@ const CURRENCY_THRESHOLDS = {
|
|
|
36
49
|
warning: 70
|
|
37
50
|
};
|
|
38
51
|
|
|
52
|
+
const LAST_FIRED_KEYS = {
|
|
53
|
+
WEEKLY_CURRENCY: 'weekly_currency_check',
|
|
54
|
+
MONTHLY_CVE_VALIDATION: 'monthly_cve_validation',
|
|
55
|
+
ANNUAL_AUDIT: 'annual_full_audit'
|
|
56
|
+
};
|
|
57
|
+
|
|
39
58
|
let unschedulers = [];
|
|
40
59
|
let running = false;
|
|
41
60
|
|
|
61
|
+
// --- persistent last-fired store ---
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the path of the last-fired persistence file. Defaults to
|
|
65
|
+
* `~/.exceptd/scheduler-last-fired.json`. Honors EXCEPTD_HOME for the test
|
|
66
|
+
* suite so unit tests stay isolated from the maintainer's real home dir.
|
|
67
|
+
*/
|
|
68
|
+
function _lastFiredStorePath() {
|
|
69
|
+
const root = process.env.EXCEPTD_HOME || path.join(os.homedir(), '.exceptd');
|
|
70
|
+
return path.join(root, 'scheduler-last-fired.json');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _loadLastFired() {
|
|
74
|
+
const p = _lastFiredStorePath();
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
77
|
+
} catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _saveLastFired(store) {
|
|
83
|
+
const p = _lastFiredStorePath();
|
|
84
|
+
try {
|
|
85
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
86
|
+
fs.writeFileSync(p, JSON.stringify(store, null, 2));
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// Persistence is best-effort. A failed write only means the next start
|
|
89
|
+
// can't tell whether the task fired; it does not break the running
|
|
90
|
+
// scheduler.
|
|
91
|
+
console.error('[scheduler] could not persist last-fired:', err.message);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function _markFired(key, when) {
|
|
96
|
+
const store = _loadLastFired();
|
|
97
|
+
store[key] = when || new Date().toISOString();
|
|
98
|
+
_saveLastFired(store);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function _shouldBootstrapFire(key, intervalMs) {
|
|
102
|
+
const store = _loadLastFired();
|
|
103
|
+
const stamp = store[key];
|
|
104
|
+
if (!stamp) return true;
|
|
105
|
+
const last = Date.parse(stamp);
|
|
106
|
+
if (!Number.isFinite(last)) return true;
|
|
107
|
+
return (Date.now() - last) >= intervalMs;
|
|
108
|
+
}
|
|
109
|
+
|
|
42
110
|
// --- scheduling primitive ---
|
|
43
111
|
|
|
44
112
|
/**
|
|
@@ -71,16 +139,56 @@ function scheduleEvery(intervalMs, handler) {
|
|
|
71
139
|
// --- public API ---
|
|
72
140
|
|
|
73
141
|
/**
|
|
74
|
-
* Start the scheduler. Runs
|
|
142
|
+
* Start the scheduler. Runs the weekly task immediately, then schedules all
|
|
143
|
+
* three on their intervals. Monthly and annual tasks are also "bootstrap
|
|
144
|
+
* fired" on start when the persisted last-fired timestamp is older than the
|
|
145
|
+
* interval (or absent), so a restarted watcher does not silently skip a due
|
|
146
|
+
* window. Bootstrap-fire happens inside the same try/catch the periodic
|
|
147
|
+
* wrapper uses so a single thrown task cannot crash the watcher.
|
|
75
148
|
*/
|
|
76
149
|
function start() {
|
|
77
150
|
if (running) return;
|
|
78
151
|
running = true;
|
|
79
152
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
153
|
+
const safeRun = (label, fn) => {
|
|
154
|
+
try { fn(); }
|
|
155
|
+
catch (e) { console.error('[scheduler] ' + label + ' failed:', e); }
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Weekly always fires on bootstrap (legacy behavior).
|
|
159
|
+
safeRun('weekly currency bootstrap', () => {
|
|
160
|
+
runWeeklyCurrencyCheck();
|
|
161
|
+
_markFired(LAST_FIRED_KEYS.WEEKLY_CURRENCY);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Monthly + annual bootstrap-fire only when the persisted timestamp is
|
|
165
|
+
// older than the interval. This closes the "freshly-restarted watcher
|
|
166
|
+
// never fires the long-cadence task" gap.
|
|
167
|
+
if (_shouldBootstrapFire(LAST_FIRED_KEYS.MONTHLY_CVE_VALIDATION, INTERVALS.MONTHLY_CVE_VALIDATION)) {
|
|
168
|
+
safeRun('monthly CVE bootstrap', () => {
|
|
169
|
+
runMonthlyCveValidation();
|
|
170
|
+
_markFired(LAST_FIRED_KEYS.MONTHLY_CVE_VALIDATION);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (_shouldBootstrapFire(LAST_FIRED_KEYS.ANNUAL_AUDIT, INTERVALS.ANNUAL_AUDIT)) {
|
|
174
|
+
safeRun('annual audit bootstrap', () => {
|
|
175
|
+
runAnnualAudit();
|
|
176
|
+
_markFired(LAST_FIRED_KEYS.ANNUAL_AUDIT);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
unschedulers.push(scheduleEvery(INTERVALS.WEEKLY_CURRENCY, () => {
|
|
181
|
+
runWeeklyCurrencyCheck();
|
|
182
|
+
_markFired(LAST_FIRED_KEYS.WEEKLY_CURRENCY);
|
|
183
|
+
}));
|
|
184
|
+
unschedulers.push(scheduleEvery(INTERVALS.MONTHLY_CVE_VALIDATION, () => {
|
|
185
|
+
runMonthlyCveValidation();
|
|
186
|
+
_markFired(LAST_FIRED_KEYS.MONTHLY_CVE_VALIDATION);
|
|
187
|
+
}));
|
|
188
|
+
unschedulers.push(scheduleEvery(INTERVALS.ANNUAL_AUDIT, () => {
|
|
189
|
+
runAnnualAudit();
|
|
190
|
+
_markFired(LAST_FIRED_KEYS.ANNUAL_AUDIT);
|
|
191
|
+
}));
|
|
84
192
|
|
|
85
193
|
console.log('[scheduler] Started. Weekly currency check, monthly CVE validation, annual audit scheduled.');
|
|
86
194
|
}
|
|
@@ -113,14 +221,25 @@ function runWeeklyCurrencyCheck() {
|
|
|
113
221
|
|
|
114
222
|
const { currency_report, action_required, critical_count } = currencyCheck();
|
|
115
223
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
224
|
+
// Emit ONE aggregated SKILL_CURRENCY_LOW_AGGREGATE event per run instead
|
|
225
|
+
// of N per-skill events. Per-run aggregate prevents downstream consumers
|
|
226
|
+
// (`watch`, dashboards, alerting webhooks) from receiving an event storm
|
|
227
|
+
// when N skills are simultaneously stale — common after a long pause
|
|
228
|
+
// between runs or on first bootstrap. The aggregate payload carries
|
|
229
|
+
// critical_count + the full array of stale skills so consumers can still
|
|
230
|
+
// drill in. The legacy per-skill SKILL_CURRENCY_LOW signature is preserved
|
|
231
|
+
// for callers (and tests) that consume bus.skillCurrencyLow() directly.
|
|
232
|
+
const critical = currency_report.filter(s => s.currency_score < CURRENCY_THRESHOLDS.critical);
|
|
233
|
+
if (critical.length > 0) {
|
|
234
|
+
bus.emit(EVENT_TYPES.SKILL_CURRENCY_LOW_AGGREGATE, {
|
|
235
|
+
critical_count: critical.length,
|
|
236
|
+
skills: critical.map(s => ({
|
|
237
|
+
skill_name: s.skill,
|
|
238
|
+
currency_score: s.currency_score,
|
|
239
|
+
days_since_review: s.days_since_review
|
|
240
|
+
})),
|
|
241
|
+
timestamp
|
|
242
|
+
});
|
|
124
243
|
}
|
|
125
244
|
|
|
126
245
|
const result = {
|
|
@@ -129,7 +248,7 @@ function runWeeklyCurrencyCheck() {
|
|
|
129
248
|
skills_checked: currency_report.length,
|
|
130
249
|
action_required,
|
|
131
250
|
critical_count,
|
|
132
|
-
critical_skills:
|
|
251
|
+
critical_skills: critical.map(s => s.skill),
|
|
133
252
|
warning_skills: currency_report.filter(s =>
|
|
134
253
|
s.currency_score >= CURRENCY_THRESHOLDS.critical && s.currency_score < CURRENCY_THRESHOLDS.warning
|
|
135
254
|
).map(s => s.skill)
|
|
@@ -177,4 +296,18 @@ function runAnnualAudit() {
|
|
|
177
296
|
};
|
|
178
297
|
}
|
|
179
298
|
|
|
180
|
-
module.exports = {
|
|
299
|
+
module.exports = {
|
|
300
|
+
start,
|
|
301
|
+
stop,
|
|
302
|
+
runCurrencyNow,
|
|
303
|
+
scheduleEvery,
|
|
304
|
+
SAFE_MAX_MS,
|
|
305
|
+
TICK_MS,
|
|
306
|
+
INTERVALS,
|
|
307
|
+
LAST_FIRED_KEYS,
|
|
308
|
+
// Internal hooks exposed for tests; not part of the operator surface.
|
|
309
|
+
_lastFiredStorePath,
|
|
310
|
+
_shouldBootstrapFire,
|
|
311
|
+
_markFired,
|
|
312
|
+
_loadLastFired,
|
|
313
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/exceptd-skills",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.15",
|
|
4
4
|
"description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 38 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-security",
|
|
@@ -54,8 +54,10 @@
|
|
|
54
54
|
"data/",
|
|
55
55
|
"skills/",
|
|
56
56
|
"keys/public.pem",
|
|
57
|
+
"keys/EXPECTED_FINGERPRINT",
|
|
57
58
|
"manifest.json",
|
|
58
59
|
"manifest-snapshot.json",
|
|
60
|
+
"manifest-snapshot.sha256",
|
|
59
61
|
"sbom.cdx.json",
|
|
60
62
|
"AGENTS.md",
|
|
61
63
|
"ARCHITECTURE.md",
|
package/sbom.cdx.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.6",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:0fb3661f-4f3b-4d31-98b3-d5fd759aa8c1",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-05-
|
|
7
|
+
"timestamp": "2026-05-14T16:47:04.535Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"name": "hand-written",
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"component": {
|
|
16
|
-
"bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.12.
|
|
16
|
+
"bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.12.15",
|
|
17
17
|
"type": "application",
|
|
18
18
|
"name": "@blamejs/exceptd-skills",
|
|
19
|
-
"version": "0.12.
|
|
19
|
+
"version": "0.12.15",
|
|
20
20
|
"description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 38 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
|
|
21
21
|
"licenses": [
|
|
22
22
|
{
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
],
|
|
28
|
-
"purl": "pkg:npm/%40blamejs/exceptd-skills@0.12.
|
|
28
|
+
"purl": "pkg:npm/%40blamejs/exceptd-skills@0.12.15",
|
|
29
29
|
"externalReferences": [
|
|
30
30
|
{
|
|
31
31
|
"type": "distribution",
|
|
32
|
-
"url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.12.
|
|
32
|
+
"url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.12.15"
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"type": "vcs",
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
"hashes": [
|
|
130
130
|
{
|
|
131
131
|
"alg": "SHA-256",
|
|
132
|
-
"content": "
|
|
132
|
+
"content": "fa9814c2d18db221a2dc552cf2a5047467d87a2590cbc9d64b8a0a340e545b55"
|
|
133
133
|
}
|
|
134
134
|
],
|
|
135
135
|
"externalReferences": [
|
|
@@ -32,10 +32,12 @@
|
|
|
32
32
|
|
|
33
33
|
const fs = require("fs");
|
|
34
34
|
const path = require("path");
|
|
35
|
+
const crypto = require("crypto");
|
|
35
36
|
|
|
36
37
|
const ROOT = path.join(__dirname, "..");
|
|
37
38
|
const MANIFEST_PATH = path.join(ROOT, "manifest.json");
|
|
38
39
|
const SNAPSHOT_PATH = path.join(ROOT, "manifest-snapshot.json");
|
|
40
|
+
const SNAPSHOT_SHA_PATH = path.join(ROOT, "manifest-snapshot.sha256");
|
|
39
41
|
|
|
40
42
|
function captureSurface(manifest) {
|
|
41
43
|
// Public surface = the set of facts downstream consumers may have
|
|
@@ -188,6 +190,36 @@ if (require.main === module) {
|
|
|
188
190
|
process.exit(2);
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
// Audit G F23 — when manifest-snapshot.sha256 is present, validate that
|
|
194
|
+
// the on-disk snapshot still hashes to the recorded value. Catches a
|
|
195
|
+
// hand-edit of manifest-snapshot.json that bypassed refresh-manifest-
|
|
196
|
+
// snapshot.js (so the F5 commit-only guard never had a chance to fire).
|
|
197
|
+
// The file is OPTIONAL: when absent, the gate warns-and-continues so
|
|
198
|
+
// pre-v0.12.14 trees still work.
|
|
199
|
+
if (fs.existsSync(SNAPSHOT_SHA_PATH)) {
|
|
200
|
+
const expectedLine = fs.readFileSync(SNAPSHOT_SHA_PATH, "utf8").trim();
|
|
201
|
+
const expectedSha = expectedLine.split(/\s+/)[0];
|
|
202
|
+
const liveSha = crypto
|
|
203
|
+
.createHash("sha256")
|
|
204
|
+
.update(fs.readFileSync(SNAPSHOT_PATH))
|
|
205
|
+
.digest("hex");
|
|
206
|
+
if (expectedSha !== liveSha) {
|
|
207
|
+
console.error(
|
|
208
|
+
"[check-manifest-snapshot] manifest-snapshot.json integrity check FAILED " +
|
|
209
|
+
`(expected ${expectedSha.slice(0, 12)}…, live ${liveSha.slice(0, 12)}…). ` +
|
|
210
|
+
"Someone edited manifest-snapshot.json without running refresh-manifest-snapshot.js. " +
|
|
211
|
+
"Re-run `node scripts/refresh-manifest-snapshot.js --commit-only` to regenerate."
|
|
212
|
+
);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
console.warn(
|
|
217
|
+
"[check-manifest-snapshot] WARN: manifest-snapshot.sha256 missing — " +
|
|
218
|
+
"integrity check skipped. Run `node scripts/refresh-manifest-snapshot.js --commit-only` " +
|
|
219
|
+
"to generate it."
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
191
223
|
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8"));
|
|
192
224
|
const current = captureSurface(manifest);
|
|
193
225
|
const result = diff(baseline, current);
|
|
@@ -62,11 +62,67 @@ function checkSbomCurrency(root) {
|
|
|
62
62
|
if (sbom.bomFormat !== "CycloneDX" || sbom.specVersion !== "1.6") {
|
|
63
63
|
errors.push("SBOM is not CycloneDX 1.6");
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
// Audit G F2 — component-level cross-check. A renamed or version-bumped
|
|
67
|
+
// skill that never made it into the SBOM refresh will pass the count
|
|
68
|
+
// check (the cardinality is unchanged) but the per-component name +
|
|
69
|
+
// version comparison surfaces it. Two component classes are recognised:
|
|
70
|
+
//
|
|
71
|
+
// 1. Skill components — bom-ref begins with "skill:" OR the component
|
|
72
|
+
// name matches a manifest.skills[].name. Each one must exist in
|
|
73
|
+
// manifest.skills with the same version.
|
|
74
|
+
// 2. Vendor components — bom-ref begins with "vendor:". Validated
|
|
75
|
+
// against vendor/blamejs/_PROVENANCE.json when present.
|
|
76
|
+
//
|
|
77
|
+
// Components that don't fit either pattern are surfaced as warnings
|
|
78
|
+
// (not errors) so the gate isn't brittle against future component types.
|
|
79
|
+
const components = Array.isArray(sbom.components) ? sbom.components : [];
|
|
80
|
+
const skillByName = new Map(
|
|
81
|
+
(manifest.skills || []).map((s) => [s.name, s])
|
|
82
|
+
);
|
|
83
|
+
const provPath = path.join(root, "vendor", "blamejs", "_PROVENANCE.json");
|
|
84
|
+
let vendorProv = null;
|
|
85
|
+
if (fs.existsSync(provPath)) {
|
|
86
|
+
try { vendorProv = JSON.parse(fs.readFileSync(provPath, "utf8")); } catch { /* leave null */ }
|
|
87
|
+
}
|
|
88
|
+
for (const comp of components) {
|
|
89
|
+
const bomRef = typeof comp["bom-ref"] === "string" ? comp["bom-ref"] : "";
|
|
90
|
+
const name = comp.name;
|
|
91
|
+
const version = comp.version;
|
|
92
|
+
if (bomRef.startsWith("skill:") || skillByName.has(name)) {
|
|
93
|
+
const skillName = bomRef.startsWith("skill:")
|
|
94
|
+
? bomRef.slice("skill:".length)
|
|
95
|
+
: name;
|
|
96
|
+
const live = skillByName.get(skillName);
|
|
97
|
+
if (!live) {
|
|
98
|
+
errors.push(
|
|
99
|
+
`SBOM component "${name}" (bom-ref ${bomRef}) is not in manifest.skills — skill renamed or removed without SBOM refresh`
|
|
100
|
+
);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (live.version && version && String(live.version) !== String(version)) {
|
|
104
|
+
errors.push(
|
|
105
|
+
`SBOM component "${name}" version ${version} != manifest.skills version ${live.version} — bump without SBOM refresh`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
} else if (bomRef.startsWith("vendor:")) {
|
|
109
|
+
if (vendorProv && vendorProv.pinned_commit) {
|
|
110
|
+
const expected = vendorProv.pinned_commit.slice(0, 12);
|
|
111
|
+
if (version && String(version) !== expected) {
|
|
112
|
+
errors.push(
|
|
113
|
+
`SBOM vendor component "${name}" version ${version} != _PROVENANCE.json pinned_commit ${expected}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
65
120
|
return {
|
|
66
121
|
ok: errors.length === 0,
|
|
67
122
|
errors,
|
|
68
123
|
skills: sbomSkills,
|
|
69
124
|
catalogs: sbomCatalogs,
|
|
125
|
+
components_validated: components.length,
|
|
70
126
|
};
|
|
71
127
|
}
|
|
72
128
|
|
|
@@ -76,10 +132,16 @@ function main() {
|
|
|
76
132
|
if (!result.ok) {
|
|
77
133
|
for (const e of result.errors) process.stderr.write(e + "\n");
|
|
78
134
|
process.stderr.write("Run `npm run refresh-sbom` to regenerate sbom.cdx.json.\n");
|
|
79
|
-
|
|
135
|
+
// v0.11.13 pattern: set exitCode + return so buffered stdout/stderr
|
|
136
|
+
// writes drain before the event loop exits. process.exit() can
|
|
137
|
+
// truncate piped output (CI log capture, JSON consumers).
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
return;
|
|
80
140
|
}
|
|
81
|
-
process.stdout.write(
|
|
82
|
-
|
|
141
|
+
process.stdout.write(
|
|
142
|
+
`SBOM current — ${result.skills} skills, ${result.catalogs} catalogs, ` +
|
|
143
|
+
`${result.components_validated} components validated.\n`
|
|
144
|
+
);
|
|
83
145
|
}
|
|
84
146
|
|
|
85
147
|
module.exports = { checkSbomCurrency, resolveRoot };
|
|
@@ -89,10 +89,49 @@ function git(args, cwd) {
|
|
|
89
89
|
// findings masked. Codex P1 flag on PR #2 of v0.12.8.
|
|
90
90
|
function resolveBaseRef(opts, cwd) {
|
|
91
91
|
if (opts.staged) return null; // staged mode uses --cached / HEAD throughout
|
|
92
|
+
// F14 — fall back gracefully when origin/main is unreachable. The
|
|
93
|
+
// original implementation tried `merge-base HEAD <opts.base>` and, on
|
|
94
|
+
// failure, returned opts.base verbatim — which then failed every
|
|
95
|
+
// subsequent git invocation, surfacing as a runner-level error. In CI
|
|
96
|
+
// (full clones) the original ref usually resolves; on a developer
|
|
97
|
+
// laptop without `origin/main` configured (fresh clone, detached
|
|
98
|
+
// worktree, alternative remote name) the gate would fail entirely.
|
|
99
|
+
//
|
|
100
|
+
// Order of preference:
|
|
101
|
+
// 1. merge-base against the requested base
|
|
102
|
+
// 2. requested base verbatim, if `git rev-parse --verify` resolves it
|
|
103
|
+
// 3. local `main` HEAD if it exists
|
|
104
|
+
// 4. HEAD~1 as a last resort (single-commit diff)
|
|
105
|
+
const tryResolve = (ref) => {
|
|
106
|
+
try {
|
|
107
|
+
git(["merge-base", "HEAD", ref], cwd).trim();
|
|
108
|
+
return ref;
|
|
109
|
+
} catch { /* not resolvable */ }
|
|
110
|
+
try {
|
|
111
|
+
git(["rev-parse", "--verify", ref], cwd).trim();
|
|
112
|
+
return ref;
|
|
113
|
+
} catch { return null; }
|
|
114
|
+
};
|
|
92
115
|
try {
|
|
93
116
|
const mb = git(["merge-base", "HEAD", opts.base], cwd).trim();
|
|
94
117
|
if (mb) return mb;
|
|
95
|
-
} catch { /*
|
|
118
|
+
} catch { /* fall through */ }
|
|
119
|
+
const direct = tryResolve(opts.base);
|
|
120
|
+
if (direct) return direct;
|
|
121
|
+
const local = tryResolve("main");
|
|
122
|
+
if (local) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
`[check-test-coverage] WARN: ${opts.base} unreachable; falling back to local main\n`
|
|
125
|
+
);
|
|
126
|
+
return local;
|
|
127
|
+
}
|
|
128
|
+
const parent = tryResolve("HEAD~1");
|
|
129
|
+
if (parent) {
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`[check-test-coverage] WARN: ${opts.base} unreachable and no local main; falling back to HEAD~1\n`
|
|
132
|
+
);
|
|
133
|
+
return parent;
|
|
134
|
+
}
|
|
96
135
|
return opts.base;
|
|
97
136
|
}
|
|
98
137
|
|
|
@@ -165,6 +204,23 @@ function categorize(file) {
|
|
|
165
204
|
if (norm.startsWith("scripts/") && norm.endsWith(".js")) return "lib";
|
|
166
205
|
if (norm.startsWith("data/playbooks/") && norm.endsWith(".json")) return "playbook";
|
|
167
206
|
if (norm === "data/cve-catalog.json") return "cve-catalog";
|
|
207
|
+
// F11 — files matching catalog/schema/SBOM shapes are surfaced for manual
|
|
208
|
+
// review rather than silent allowlist. These changes (manifest.json,
|
|
209
|
+
// schemas/*, data/*.json, sbom.cdx.json, manifest-snapshot.*) can carry
|
|
210
|
+
// semantic surface but the analyzer has no syntactic surface extractor
|
|
211
|
+
// for them — humans should look.
|
|
212
|
+
if (norm === "manifest.json") return "manual-review";
|
|
213
|
+
if (norm === "manifest-snapshot.json") return "manual-review";
|
|
214
|
+
if (norm === "manifest-snapshot.sha256") return "manual-review";
|
|
215
|
+
if (norm === "sbom.cdx.json") return "manual-review";
|
|
216
|
+
if (norm.startsWith("lib/schemas/")) return "manual-review";
|
|
217
|
+
// v0.12.14: data/_indexes/ is auto-regenerated from data/ + manifest by
|
|
218
|
+
// `npm run build-indexes`; the source-of-truth diff is in the data/
|
|
219
|
+
// files themselves. Allowlist the derived index files so they don't
|
|
220
|
+
// perpetually surface as manual-review on every release commit.
|
|
221
|
+
if (norm.startsWith("data/_indexes/")) return "allowlist-derived";
|
|
222
|
+
if (norm.startsWith("data/") && norm.endsWith(".json")) return "manual-review";
|
|
223
|
+
if (norm === "package.json") return "manual-review";
|
|
168
224
|
return "other";
|
|
169
225
|
}
|
|
170
226
|
|
|
@@ -271,15 +327,20 @@ function safeParse(s) { try { return s ? JSON.parse(s) : null; } catch { return
|
|
|
271
327
|
|
|
272
328
|
function loadTestCorpus(cwd) {
|
|
273
329
|
const root = path.join(cwd, "tests");
|
|
274
|
-
if (!fs.existsSync(root)) return "";
|
|
330
|
+
if (!fs.existsSync(root)) return { joined: "", files: [] };
|
|
275
331
|
const acc = [];
|
|
332
|
+
const files = [];
|
|
276
333
|
walk(root, p => {
|
|
277
334
|
const norm = p.replace(/\\/g, "/");
|
|
278
335
|
if (/\.(js|json)$/.test(norm)) {
|
|
279
|
-
try {
|
|
336
|
+
try {
|
|
337
|
+
const content = fs.readFileSync(p, "utf8");
|
|
338
|
+
acc.push(content);
|
|
339
|
+
files.push({ path: norm, content });
|
|
340
|
+
} catch { /* ignore unreadable */ }
|
|
280
341
|
}
|
|
281
342
|
});
|
|
282
|
-
return acc.join("\n\x00\n");
|
|
343
|
+
return { joined: acc.join("\n\x00\n"), files };
|
|
283
344
|
}
|
|
284
345
|
|
|
285
346
|
function walk(dir, fn) {
|
|
@@ -302,22 +363,76 @@ function coversCliFlag(corpus, flag) {
|
|
|
302
363
|
return corpus.includes(flag);
|
|
303
364
|
}
|
|
304
365
|
|
|
366
|
+
// F10 — same-file context check. A test corpus is no longer treated as
|
|
367
|
+
// one giant string for lib-export coverage: the identifier must appear
|
|
368
|
+
// inside a real test block (`test(`, `it(`, `describe(`, or an `assert(`
|
|
369
|
+
// argument) within the SAME file that issues the matching require().
|
|
370
|
+
// Pre-fix: an `assert.equal(...)` mention in one test file plus a stray
|
|
371
|
+
// `require('../lib/x')` in a completely different test file counted as
|
|
372
|
+
// coverage. That's not coverage — it's textual coincidence.
|
|
373
|
+
//
|
|
374
|
+
// `corpus` may be either a string (legacy joined corpus, used by
|
|
375
|
+
// CLI/playbook/CVE coverage probes) or the structured shape
|
|
376
|
+
// `{ joined, files }` produced by loadTestCorpus().
|
|
305
377
|
function coversLibExport(corpus, libRel, ident) {
|
|
306
378
|
const baseName = path.basename(libRel).replace(/\.js$/, "");
|
|
307
379
|
const baseFile = path.basename(libRel); // e.g. "check-sbom-currency.js"
|
|
308
380
|
const identRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
|
|
309
|
-
// Primary: a `require()` that mentions the module name AND a reference
|
|
310
|
-
// to the identifier anywhere in the test corpus. Catches the canonical
|
|
311
|
-
// `const { foo } = require('../lib/x')` test shape.
|
|
312
381
|
const requireRe = new RegExp("require\\([^)]*" + escapeRe(baseName) + "[^)]*\\)");
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
382
|
+
// Accept the structured shape (preferred). Walk files individually.
|
|
383
|
+
if (corpus && Array.isArray(corpus.files)) {
|
|
384
|
+
for (const f of corpus.files) {
|
|
385
|
+
const hasRequire = requireRe.test(f.content);
|
|
386
|
+
const mentionsSpawnPath = f.content.includes(baseFile);
|
|
387
|
+
if (!hasRequire && !mentionsSpawnPath) continue;
|
|
388
|
+
if (!identRe.test(f.content)) continue;
|
|
389
|
+
// F10 — require the identifier appears inside a test block in this
|
|
390
|
+
// file. Recognise `test(`, `it(`, `describe(`, or `assert(` (or any
|
|
391
|
+
// `assert.<member>(`) bracketed argument that mentions the ident.
|
|
392
|
+
if (mentionsIdentInTestContext(f.content, ident)) return true;
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
// Fallback: legacy joined-string corpus.
|
|
397
|
+
const joined = typeof corpus === "string" ? corpus : (corpus && corpus.joined) || "";
|
|
398
|
+
if (requireRe.test(joined) && identRe.test(joined)) return true;
|
|
399
|
+
if (joined.includes(baseFile) && identRe.test(joined)) return true;
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Returns true when `ident` appears as a token inside the body of any
|
|
404
|
+
// `test( ... )`, `it( ... )`, `describe( ... )`, `assert( ... )` or
|
|
405
|
+
// `assert.<member>( ... )` call in the file. We approximate "the body of
|
|
406
|
+
// the call" by finding the opening paren after the keyword, then walking
|
|
407
|
+
// matched parens until the call closes. This is a syntactic-enough check
|
|
408
|
+
// for vanilla JavaScript tests; the goal is to refuse "ident only appears
|
|
409
|
+
// in a top-level comment" while still accepting `assert.deepEqual(foo, ...)`.
|
|
410
|
+
function mentionsIdentInTestContext(content, ident) {
|
|
411
|
+
const tokenRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
|
|
412
|
+
// Quick reject: file does not mention the identifier at all.
|
|
413
|
+
if (!tokenRe.test(content)) return false;
|
|
414
|
+
const callRe = /\b(test|it|describe|assert(?:\.[A-Za-z_$][\w$]*)?)\s*\(/g;
|
|
415
|
+
let m;
|
|
416
|
+
while ((m = callRe.exec(content)) !== null) {
|
|
417
|
+
const start = m.index + m[0].length; // pointer to first char inside (
|
|
418
|
+
let depth = 1;
|
|
419
|
+
let i = start;
|
|
420
|
+
let inStr = null;
|
|
421
|
+
while (i < content.length && depth > 0) {
|
|
422
|
+
const c = content[i];
|
|
423
|
+
if (inStr) {
|
|
424
|
+
if (c === "\\") { i += 2; continue; }
|
|
425
|
+
if (c === inStr) inStr = null;
|
|
426
|
+
} else {
|
|
427
|
+
if (c === '"' || c === "'" || c === "`") inStr = c;
|
|
428
|
+
else if (c === "(") depth++;
|
|
429
|
+
else if (c === ")") depth--;
|
|
430
|
+
}
|
|
431
|
+
i++;
|
|
432
|
+
}
|
|
433
|
+
const body = content.slice(start, i - 1);
|
|
434
|
+
if (tokenRe.test(body)) return true;
|
|
435
|
+
}
|
|
321
436
|
return false;
|
|
322
437
|
}
|
|
323
438
|
|
|
@@ -341,7 +456,8 @@ function analyze(opts) {
|
|
|
341
456
|
// between calls produces false add/remove findings.
|
|
342
457
|
const resolvedBase = resolveBaseRef(opts, cwd);
|
|
343
458
|
const changed = listChangedFiles(opts, cwd, resolvedBase);
|
|
344
|
-
const
|
|
459
|
+
const corpusObj = loadTestCorpus(cwd);
|
|
460
|
+
const corpus = corpusObj.joined;
|
|
345
461
|
|
|
346
462
|
const findings = [];
|
|
347
463
|
const allowlisted = [];
|
|
@@ -355,7 +471,12 @@ function analyze(opts) {
|
|
|
355
471
|
}
|
|
356
472
|
if (cat === "skill") { allowlisted.push({ file: ch.file, reason: "skill-signed" }); continue; }
|
|
357
473
|
if (cat === "workflow") { manualReview.push({ file: ch.file, reason: "workflow" }); continue; }
|
|
358
|
-
|
|
474
|
+
// F11 — data catalogs, schemas, manifests, SBOM go to manual review
|
|
475
|
+
// instead of being silently allowlisted. They show up in CI output.
|
|
476
|
+
if (cat === "manual-review") { manualReview.push({ file: ch.file, reason: "manual-review" }); continue; }
|
|
477
|
+
// v0.12.14: derived index files allowlist (auto-regenerated artifacts).
|
|
478
|
+
if (cat === "allowlist-derived") { allowlisted.push({ file: ch.file, reason: "derived-artifact" }); continue; }
|
|
479
|
+
if (cat === "other") { manualReview.push({ file: ch.file, reason: "unclassified" }); continue; }
|
|
359
480
|
if (ch.status !== "D" && isWhitespaceOnly(opts, ch.file, cwd, resolvedBase)) {
|
|
360
481
|
allowlisted.push({ file: ch.file, reason: "whitespace-only" });
|
|
361
482
|
continue;
|
|
@@ -381,9 +502,11 @@ function analyze(opts) {
|
|
|
381
502
|
const b = extractLibExports(before);
|
|
382
503
|
const a = extractLibExports(after);
|
|
383
504
|
const d = diffSets(b, a);
|
|
384
|
-
|
|
505
|
+
// F10 — pass the structured corpus so coversLibExport can enforce
|
|
506
|
+
// same-file require()+identifier-in-test-context coverage.
|
|
507
|
+
for (const id of d.added) if (!coversLibExport(corpusObj, ch.file, id))
|
|
385
508
|
findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "added" });
|
|
386
|
-
for (const id of d.removed) if (coversLibExport(
|
|
509
|
+
for (const id of d.removed) if (coversLibExport(corpusObj, ch.file, id))
|
|
387
510
|
findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "removed-but-test-remains" });
|
|
388
511
|
} else if (cat === "playbook") {
|
|
389
512
|
const b = extractPlaybookIds(before);
|