@blamejs/exceptd-skills 0.12.28 → 0.12.30

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.
@@ -184,15 +184,29 @@ function readMaybe(p) {
184
184
 
185
185
  // --- Categorization ---------------------------------------------------------
186
186
 
187
+ // Mechanical / contributor-only docs the gate auto-allows: their content
188
+ // has no operator-facing semantic surface (CONTRIBUTING is for PRs;
189
+ // LICENSE / NOTICE / CODE_OF_CONDUCT are boilerplate; .gitignore / .npmrc
190
+ // / .editorconfig are tooling). Edits here never need a regression test.
187
191
  const DOCS_ALWAYS_GREEN = new Set([
188
- "CHANGELOG.md", "README.md", "CONTRIBUTING.md", "SECURITY.md",
189
- "LICENSE", "NOTICE", "CODE_OF_CONDUCT.md", "AGENTS.md", "CLAUDE.md",
190
- "SUPPORT.md", "MIGRATING.md", ".gitignore", ".npmrc", ".editorconfig",
192
+ "CONTRIBUTING.md", "LICENSE", "NOTICE", "CODE_OF_CONDUCT.md",
193
+ "CLAUDE.md", "SUPPORT.md", ".gitignore", ".npmrc", ".editorconfig",
194
+ ]);
195
+
196
+ // Cycle 9 finding: operator-facing docs (release notes, install instructions,
197
+ // security disclosure policy, migration guides, AI-assistant ground truth)
198
+ // previously auto-greened. A PR could land deceptive copy here without any
199
+ // reviewer signal. Downgrade to manual-review so the diff surfaces in the
200
+ // gate output — a human (or the maintainer reviewing the bot summary) at
201
+ // least sees the change exists.
202
+ const DOCS_MANUAL_REVIEW = new Set([
203
+ "CHANGELOG.md", "README.md", "SECURITY.md", "MIGRATING.md", "AGENTS.md",
191
204
  ]);
192
205
 
193
206
  function categorize(file) {
194
207
  const norm = file.replace(/\\/g, "/");
195
208
  if (DOCS_ALWAYS_GREEN.has(norm)) return "docs";
209
+ if (DOCS_MANUAL_REVIEW.has(norm)) return "manual-review";
196
210
  if (norm.startsWith("tests/")) return "test"; // no recursion
197
211
  if (norm.startsWith("docs/")) return "docs";
198
212
  if (norm.endsWith(".md") && !norm.startsWith("data/")) return "docs";
@@ -662,5 +676,5 @@ module.exports = {
662
676
  extractCliSurface, extractLibExports, extractPlaybookIds, extractCveIocChanges,
663
677
  coversCliVerb, coversCliFlag, coversLibExport, coversPlaybookId, coversCveIoc,
664
678
  scanForCoincidenceAsserts,
665
- DOCS_ALWAYS_GREEN,
679
+ DOCS_ALWAYS_GREEN, DOCS_MANUAL_REVIEW,
666
680
  };
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * scripts/refresh-reverse-refs.js — rebuild reverse references in
4
+ * data/{atlas-ttps,cwe-catalog,d3fend-catalog,rfc-references}.json from
5
+ * the manifest.json forward direction.
6
+ *
7
+ * Background. Each skill in manifest.json declares forward references via
8
+ * atlas_refs / cwe_refs / d3fend_refs / rfc_refs. The four catalogs above
9
+ * carry a denormalised reverse field per entry (`exceptd_skills` for
10
+ * atlas-ttps, `skills_referencing` for the other three) listing every
11
+ * skill that points at that entry. The reverse field drifts whenever a
12
+ * skill adds or removes a forward ref without the catalog being updated
13
+ * in lockstep — Cycle 9 audit found this drift in production.
14
+ *
15
+ * Behaviour. For each catalog file:
16
+ * 1. Walk every skill's relevant forward-ref array in manifest.json.
17
+ * 2. For every catalog entry, list every skill that references it.
18
+ * 3. Sort the resulting skill list and write it back into the per-entry
19
+ * reverse field. All other fields are preserved untouched.
20
+ *
21
+ * The script is idempotent: a second run produces no further changes.
22
+ *
23
+ * The script does NOT touch playbooks_referencing — that field carries
24
+ * playbook ids (data/playbooks/*.json), not skill names; it has its own
25
+ * source of truth and is out of scope for this audit fix.
26
+ *
27
+ * Run: node scripts/refresh-reverse-refs.js
28
+ * npm run refresh-reverse-refs
29
+ *
30
+ * Exit code: 0 always (script is unconditionally write-mode). Use
31
+ * tests/reverse-ref-drift.test.js as the read-only drift detector.
32
+ */
33
+
34
+ 'use strict';
35
+
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+
39
+ const REPO_ROOT = path.resolve(__dirname, '..');
40
+ const MANIFEST_PATH = path.join(REPO_ROOT, 'manifest.json');
41
+ const DATA_DIR = path.join(REPO_ROOT, 'data');
42
+
43
+ /* Per-catalog config:
44
+ * file relative path under data/
45
+ * forwardField manifest.skills[].* array name
46
+ * reverseField per-entry reverse field name in the catalog
47
+ */
48
+ const CATALOGS = [
49
+ {
50
+ file: 'atlas-ttps.json',
51
+ forwardField: 'atlas_refs',
52
+ reverseField: 'exceptd_skills',
53
+ },
54
+ {
55
+ file: 'cwe-catalog.json',
56
+ forwardField: 'cwe_refs',
57
+ reverseField: 'skills_referencing',
58
+ },
59
+ {
60
+ file: 'd3fend-catalog.json',
61
+ forwardField: 'd3fend_refs',
62
+ reverseField: 'skills_referencing',
63
+ },
64
+ {
65
+ file: 'rfc-references.json',
66
+ forwardField: 'rfc_refs',
67
+ reverseField: 'skills_referencing',
68
+ },
69
+ ];
70
+
71
+ function readJson(p) {
72
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
73
+ }
74
+
75
+ function buildReverseIndex(skills, forwardField) {
76
+ // entryId -> Set<skillName>
77
+ const index = new Map();
78
+ for (const skill of skills) {
79
+ const refs = Array.isArray(skill[forwardField]) ? skill[forwardField] : [];
80
+ for (const id of refs) {
81
+ if (!index.has(id)) index.set(id, new Set());
82
+ index.get(id).add(skill.name);
83
+ }
84
+ }
85
+ return index;
86
+ }
87
+
88
+ function rebuildCatalog(cfg, manifest) {
89
+ const filePath = path.join(DATA_DIR, cfg.file);
90
+ const catalog = readJson(filePath);
91
+ const index = buildReverseIndex(manifest.skills, cfg.forwardField);
92
+ let changed = 0;
93
+ let added = 0;
94
+ let removed = 0;
95
+ let unchanged = 0;
96
+ const orphans = []; // forward refs that don't resolve to a catalog entry
97
+ const seenIds = new Set();
98
+
99
+ for (const [id, entry] of Object.entries(catalog)) {
100
+ if (id === '_meta') continue;
101
+ if (typeof entry !== 'object' || entry === null) continue;
102
+ seenIds.add(id);
103
+ const before = Array.isArray(entry[cfg.reverseField])
104
+ ? [...entry[cfg.reverseField]]
105
+ : [];
106
+ const computed = index.has(id)
107
+ ? Array.from(index.get(id)).sort()
108
+ : [];
109
+ const beforeSet = new Set(before);
110
+ const computedSet = new Set(computed);
111
+ const sameLen = before.length === computed.length;
112
+ const sameContent =
113
+ sameLen && before.every((s, i) => s === computed[i]);
114
+ if (!sameContent) {
115
+ entry[cfg.reverseField] = computed;
116
+ changed += 1;
117
+ for (const s of computed) if (!beforeSet.has(s)) added += 1;
118
+ for (const s of before) if (!computedSet.has(s)) removed += 1;
119
+ } else {
120
+ unchanged += 1;
121
+ }
122
+ }
123
+
124
+ // Surface forward refs that point at catalog entries that don't exist.
125
+ // Not fatal here — that's a separate validation concern — but we report.
126
+ for (const id of index.keys()) {
127
+ if (!seenIds.has(id)) orphans.push(id);
128
+ }
129
+
130
+ if (changed > 0) {
131
+ fs.writeFileSync(filePath, JSON.stringify(catalog, null, 2) + '\n', 'utf8');
132
+ }
133
+
134
+ return {
135
+ file: cfg.file,
136
+ changed,
137
+ added,
138
+ removed,
139
+ unchanged,
140
+ orphans,
141
+ };
142
+ }
143
+
144
+ function main() {
145
+ const manifest = readJson(MANIFEST_PATH);
146
+ const results = [];
147
+ for (const cfg of CATALOGS) {
148
+ results.push(rebuildCatalog(cfg, manifest));
149
+ }
150
+ for (const r of results) {
151
+ process.stdout.write(
152
+ `${r.file}: ${r.changed} entries changed ` +
153
+ `(+${r.added} / -${r.removed} skill refs), ` +
154
+ `${r.unchanged} unchanged` +
155
+ (r.orphans.length
156
+ ? `, ${r.orphans.length} orphan forward ref(s) [${r.orphans.join(', ')}]`
157
+ : '') +
158
+ '\n',
159
+ );
160
+ }
161
+ }
162
+
163
+ module.exports = {
164
+ CATALOGS,
165
+ buildReverseIndex,
166
+ rebuildCatalog,
167
+ };
168
+
169
+ if (require.main === module) {
170
+ main();
171
+ }
@@ -15,12 +15,25 @@
15
15
  * timestamp) so reruns produce a new
16
16
  * UUID per refresh.
17
17
  * - metadata.timestamp ISO 8601 of generation
18
- * - metadata.tools hand-written generator
19
- * - metadata.component application entry for exceptd-skills
18
+ * - metadata.tools this script itself, version pulled
19
+ * from package.json at refresh time
20
+ * - metadata.component application entry for exceptd-skills,
21
+ * including a hashes[] bundle digest
22
+ * that operators can recompute from
23
+ * the per-file component list (see
24
+ * `bundleDigest` below for the exact
25
+ * canonical-input rule)
20
26
  * - metadata.properties catalog count, skill count, dataflow
21
27
  * inputs, and the per-skill Ed25519
22
28
  * integrity claim (lib/sign.js)
23
- * - components [] zero npm runtime deps
29
+ * - components vendored libraries + a `type: file`
30
+ * component per shipped file in the
31
+ * package.json `files` allowlist, each
32
+ * carrying its SHA-256 hash. Lets
33
+ * CycloneDX-aware vuln scanners verify
34
+ * individual files against the bundle
35
+ * without re-deriving the canonical
36
+ * list themselves.
24
37
  * - dependencies [] — nothing to depend on
25
38
  *
26
39
  * Run: node scripts/refresh-sbom.js
@@ -85,6 +98,128 @@ function loadVendorProvenance() {
85
98
  }
86
99
  }
87
100
 
101
+ /* Recursively expand a `package.json.files` allowlist entry into the
102
+ * concrete file list that npm pack would ship. The allowlist accepts
103
+ * either a file path or a directory path (with trailing slash convention
104
+ * inside this repo); directories expand to every regular file beneath
105
+ * them. Returned paths are POSIX-style relative to REPO_ROOT so the
106
+ * SHA-256 input is stable across operating systems.
107
+ *
108
+ * Mirrors npm's pack-time inclusion rules at the level of fidelity this
109
+ * SBOM needs (a deeper match — .npmignore, package-lock fields, npm-CLI
110
+ * defaults — is intentionally out of scope: any divergence here surfaces
111
+ * as a SHA mismatch on the predeploy verify-shipped-tarball gate, which
112
+ * is the authoritative consumer-side check).
113
+ */
114
+ function walkFiles(absDir) {
115
+ const out = [];
116
+ const entries = fs.readdirSync(absDir, { withFileTypes: true });
117
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
118
+ const abs = path.join(absDir, entry.name);
119
+ if (entry.isDirectory()) {
120
+ out.push(...walkFiles(abs));
121
+ } else if (entry.isFile()) {
122
+ out.push(abs);
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /* Files that cannot have a stable SHA inside the SBOM they belong to.
129
+ * `sbom.cdx.json` is the obvious self-reference: hashing it would always
130
+ * be stale the moment the SBOM gets written back. The bundle digest in
131
+ * metadata.component.hashes[] covers everything ELSE that ships and is
132
+ * the operator's verification anchor for the bundle as a whole. */
133
+ const SELF_EXCLUDED = new Set(['sbom.cdx.json']);
134
+
135
+ /* Path prefixes whose contents are derivable / cache-class artifacts.
136
+ * `data/_indexes/` is the pre-computed index cache that ships in the
137
+ * tarball but is regenerated by `npm run build-indexes`. The test suite
138
+ * deliberately mutates these files (build-incremental.test.js,
139
+ * indexes-v070.test.js), so per-file SHA verification would race against
140
+ * any test run that touches the cache between refresh-sbom and the
141
+ * verification gate. The bundle digest at metadata.component.hashes[] is
142
+ * computed from a SBOM-generation-time snapshot of all OTHER files; the
143
+ * cache is excluded from the per-file inventory entirely. Predeploy's
144
+ * `Pre-computed indexes freshness` gate is the authoritative consumer-
145
+ * side check for the cache. */
146
+ const DERIVABLE_PREFIXES = ['data/_indexes/'];
147
+
148
+ function isDerivable(rel) {
149
+ return DERIVABLE_PREFIXES.some((p) => rel === p.replace(/\/$/, '') || rel.startsWith(p));
150
+ }
151
+
152
+ function expandAllowlist(allowlist) {
153
+ const abs = [];
154
+ for (const entry of allowlist) {
155
+ const full = path.join(REPO_ROOT, entry);
156
+ if (!fs.existsSync(full)) continue; // tolerate a stale entry; predeploy gate flags
157
+ const stat = fs.statSync(full);
158
+ if (stat.isDirectory()) {
159
+ abs.push(...walkFiles(full));
160
+ } else if (stat.isFile()) {
161
+ abs.push(full);
162
+ }
163
+ }
164
+ // dedupe + sort by relative POSIX path for deterministic output;
165
+ // strip self-referential entries (see SELF_EXCLUDED) and derivable cache
166
+ // entries (see DERIVABLE_PREFIXES).
167
+ const rel = Array.from(new Set(abs.map((a) => toPosixRel(a))))
168
+ .filter((r) => !SELF_EXCLUDED.has(r))
169
+ .filter((r) => !isDerivable(r))
170
+ .sort();
171
+ return rel;
172
+ }
173
+
174
+ function toPosixRel(absPath) {
175
+ return path
176
+ .relative(REPO_ROOT, absPath)
177
+ .split(path.sep)
178
+ .join('/');
179
+ }
180
+
181
+ function sha256File(absPath) {
182
+ return crypto
183
+ .createHash('sha256')
184
+ .update(fs.readFileSync(absPath))
185
+ .digest('hex');
186
+ }
187
+
188
+ function fileComponents(allowlist) {
189
+ const rels = expandAllowlist(allowlist);
190
+ const out = [];
191
+ for (const rel of rels) {
192
+ const abs = path.join(REPO_ROOT, rel);
193
+ out.push({
194
+ 'bom-ref': `file:${rel}`,
195
+ type: 'file',
196
+ name: rel,
197
+ hashes: [{ alg: 'SHA-256', content: sha256File(abs) }],
198
+ });
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /* Bundle digest = SHA-256 over a deterministic newline-delimited
204
+ * "<sha256>\t<relpath>\n" stream of every shipped file, sorted by
205
+ * relpath. The same input shape an operator would assemble from the
206
+ * components[] list (`type: file` entries) lets them recompute and
207
+ * compare without trusting the SBOM's stored value blindly.
208
+ */
209
+ function bundleDigest(fileComps) {
210
+ const sorted = [...fileComps].sort((a, b) =>
211
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
212
+ );
213
+ const hash = crypto.createHash('sha256');
214
+ for (const c of sorted) {
215
+ hash.update(c.hashes[0].content);
216
+ hash.update('\t');
217
+ hash.update(c.name);
218
+ hash.update('\n');
219
+ }
220
+ return hash.digest('hex');
221
+ }
222
+
88
223
  function vendorComponents(prov) {
89
224
  if (!prov || !prov.files) return [];
90
225
  const out = [];
@@ -119,6 +254,14 @@ function buildSbom() {
119
254
  const catalogCount = catalogs.length;
120
255
  const vendorProv = loadVendorProvenance();
121
256
  const vendoredComponents = vendorComponents(vendorProv);
257
+ const fileComps = fileComponents(Array.isArray(pkg.files) ? pkg.files : []);
258
+ const bundleSha = bundleDigest(fileComps);
259
+
260
+ // Sort the union of vendor + file components by bom-ref for
261
+ // deterministic regeneration.
262
+ const allComponents = [...vendoredComponents, ...fileComps].sort((a, b) =>
263
+ a['bom-ref'] < b['bom-ref'] ? -1 : a['bom-ref'] > b['bom-ref'] ? 1 : 0,
264
+ );
122
265
 
123
266
  const serialNumber =
124
267
  'urn:uuid:' +
@@ -137,10 +280,9 @@ function buildSbom() {
137
280
  timestamp: timestamp,
138
281
  tools: [
139
282
  {
140
- name: 'hand-written',
141
- version: '0.1.0',
142
- description:
143
- 'SBOM generated from package.json + manual review (scripts/refresh-sbom.js).',
283
+ vendor: 'blamejs',
284
+ name: 'scripts/refresh-sbom.js',
285
+ version: pkg.version,
144
286
  },
145
287
  ],
146
288
  component: {
@@ -154,6 +296,11 @@ function buildSbom() {
154
296
  description: pkg.description,
155
297
  licenses: [{ license: { id: 'Apache-2.0' } }],
156
298
  purl: `pkg:npm/${pkg.name.replace('@', '%40')}@${pkg.version}`,
299
+ // Bundle digest over every shipped file (see bundleDigest above
300
+ // for the canonical-input rule). Operators can recompute this
301
+ // from the per-file components[] list and compare without
302
+ // re-deriving package.json.files themselves.
303
+ hashes: [{ alg: 'SHA-256', content: bundleSha }],
157
304
  externalReferences: [
158
305
  { type: 'distribution', url: `https://www.npmjs.com/package/${pkg.name}/v/${pkg.version}` },
159
306
  { type: 'vcs', url: (pkg.repository && pkg.repository.url) || 'https://github.com/blamejs/exceptd-skills' },
@@ -196,7 +343,7 @@ function buildSbom() {
196
343
  },
197
344
  ],
198
345
  },
199
- components: vendoredComponents,
346
+ components: allComponents,
200
347
  dependencies: [],
201
348
  };
202
349