@blamejs/exceptd-skills 0.13.126 → 0.14.1

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.
@@ -31,15 +31,16 @@
31
31
 
32
32
  const fs = require("node:fs");
33
33
  const path = require("node:path");
34
+ const { codeExcludeSet, isLinkedWorktreeDir } = require("./scan-excludes");
34
35
 
35
36
  const COLLECTOR_ID = "library-author";
36
37
 
37
38
  const DEFAULT_MAX_DEPTH = 6;
38
- const DEFAULT_EXCLUDES = new Set([
39
- "node_modules", ".git", "dist", "build", "out",
40
- ".venv", "venv", "__pycache__", ".pytest_cache",
41
- "target", ".idea", ".vscode",
42
- ]);
39
+ // Shared code-scope exclusions (dependency caches, build output, VCS +
40
+ // agent/editor scratch including `.claude/`). Applied to the vendor-tree
41
+ // walk so a linked worktree or dependency cache nested under `vendor/`
42
+ // isn't mistaken for vendored provenance state.
43
+ const DEFAULT_EXCLUDES = codeExcludeSet();
43
44
 
44
45
  function readSafe(full, max = 512 * 1024) {
45
46
  try {
@@ -112,6 +113,18 @@ function looksLikePublishWorkflow(name, content) {
112
113
  return false;
113
114
  }
114
115
 
116
+ // `id-token: write` grants the OIDC token used for npm provenance and
117
+ // sigstore keyless signing. It is valid at workflow-level permissions or at
118
+ // a specific job's `permissions:` block, so any occurrence of the
119
+ // `id-token: write` token within the publish workflow file counts as the
120
+ // capability being present — this is what lets a job-scoped declaration
121
+ // (not just a workflow-level one) satisfy the check. The match is
122
+ // deliberately scoped to the file being scanned, not repo-wide: a sibling
123
+ // workflow's OIDC does not grant it to the publish job.
124
+ function hasIdTokenWriteAnyScope(content) {
125
+ return /\bid-token:\s*write\b/.test(content);
126
+ }
127
+
115
128
  function scanPublishWorkflow(content, rel) {
116
129
  const hits = {
117
130
  "publish-workflow-uses-static-token": [],
@@ -122,18 +135,21 @@ function scanPublishWorkflow(content, rel) {
122
135
  };
123
136
 
124
137
  // static-token: workflow references a publish-credential secret
125
- // without a corresponding `permissions: id-token: write`. The
126
- // predicate (per data/playbooks/library-author.json) lists
138
+ // without a corresponding `id-token: write` permission (at any scope).
139
+ // The predicate (per data/playbooks/library-author.json) lists
127
140
  // NPM_TOKEN / PYPI_TOKEN / CARGO_TOKEN / RUBYGEMS_API_KEY /
128
141
  // GEM_HOST_API_KEY; expand to cover the common variants for each
129
142
  // ecosystem.
130
143
  const usesStaticToken = /\bsecrets\.(NPM_TOKEN|PYPI_TOKEN|PYPI_API_TOKEN|CARGO_TOKEN|CARGO_REGISTRY_TOKEN|RUBYGEMS_API_KEY|GEM_HOST_API_KEY|MAVEN_TOKEN|MAVEN_CENTRAL_TOKEN|GH_TOKEN)\b/.test(content);
131
- const hasIdTokenWrite = /\bid-token:\s*write\b/.test(content);
144
+ // OIDC is available when THIS publish file declares `id-token: write` at
145
+ // any scope (workflow or job). Scoped to the file by design — a sibling
146
+ // workflow's OIDC does not authenticate this publish job.
147
+ const hasIdTokenWrite = hasIdTokenWriteAnyScope(content);
132
148
  if (usesStaticToken && !hasIdTokenWrite) {
133
149
  hits["publish-workflow-uses-static-token"].push({ file: rel, line: 0, snippet: "publish workflow uses a static long-lived token (NPM_TOKEN / PYPI / Cargo / Maven) without id-token: write for OIDC" });
134
150
  }
135
151
  if (!hasIdTokenWrite) {
136
- hits["publish-workflow-no-id-token-write"].push({ file: rel, line: 0, snippet: "permissions block lacks id-token: write — npm provenance / sigstore signing unavailable" });
152
+ hits["publish-workflow-no-id-token-write"].push({ file: rel, line: 0, snippet: "no id-token: write at any scope (workflow or job) — npm provenance / sigstore signing unavailable" });
137
153
  }
138
154
 
139
155
  // action-refs-mutable: any `uses: <action>@<ref>` where ref is NOT
@@ -212,6 +228,13 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
212
228
  "release-workflow-non-frozen-install": [],
213
229
  "publish-workflow-runs-on-self-hosted": [],
214
230
  };
231
+ // OIDC capability is evaluated per publish workflow, NOT repo-wide: a
232
+ // sibling docs/deploy workflow declaring `id-token: write` does not give
233
+ // OIDC to a release job that still publishes with a long-lived
234
+ // `secrets.NPM_TOKEN`. Treating it repo-wide would mask exactly the
235
+ // static-token publisher-takeover case this indicator exists to catch.
236
+ // Job-scoped `id-token: write` inside the publish file still counts —
237
+ // hasIdTokenWriteAnyScope matches any scope within the scanned file.
215
238
  for (const w of publishWorkflows) {
216
239
  const h = scanPublishWorkflow(w.content, w.rel);
217
240
  for (const [id, list] of Object.entries(h)) workflowHits[id].push(...list);
@@ -367,10 +390,50 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
367
390
  for (const f of ["sbom.cdx.json", "sbom.json", "bom.json", "sbom.spdx.json", "sbom.cdx.xml"]) {
368
391
  if (fs.existsSync(path.join(root, f))) { sbomFile = f; break; }
369
392
  }
393
+ // Many publishers don't commit a static SBOM; they generate a
394
+ // cosign-signed CycloneDX/SPDX SBOM (and provenance attestation) at
395
+ // release time inside the publish workflow. Those assets never land in
396
+ // the committed tree, so a repo-state-only check can't see them. Detect
397
+ // the *capability* in the publish workflows: an SBOM-generation step
398
+ // (cyclonedx / syft / anchore-sbom-action / trivy / `npm sbom`), npm
399
+ // provenance (`--provenance` flag or `publishConfig.provenance: true`),
400
+ // or a sigstore/cosign signing step. If any is present, the SBOM /
401
+ // signed-attestation capability exists at release and the indicator
402
+ // should not fire on the absence of a committed artifact.
403
+ const releaseSbomCapable = publishWorkflows.some(w => {
404
+ const c = w.content;
405
+ return (
406
+ // SBOM-generation tooling invoked in the workflow.
407
+ /cyclonedx/i.test(c) ||
408
+ /\bsyft\b/i.test(c) ||
409
+ /anchore\/sbom-action/i.test(c) ||
410
+ /\btrivy\b[^\n]*\bsbom\b/i.test(c) ||
411
+ /\bnpm\s+sbom\b/.test(c) ||
412
+ /spdx-sbom-generator/i.test(c) ||
413
+ // npm provenance (signed build provenance attestation at publish).
414
+ /npm\s+publish[^\n]*--provenance\b/.test(c) ||
415
+ // sigstore / cosign signing of release artifacts.
416
+ /\bcosign\s+(?:sign|attest)\b/.test(c) ||
417
+ /sigstore\//i.test(c) ||
418
+ /gh-action-sigstore-python/i.test(c)
419
+ );
420
+ });
421
+ // package.json publishConfig.provenance opt-in also signals a signed
422
+ // provenance attestation is produced at publish time.
423
+ const manifestProvenanceOptIn = (() => {
424
+ if (!pkgManifest || !pkgManifest.content) return false;
425
+ try { return JSON.parse(pkgManifest.content)?.publishConfig?.provenance === true; }
426
+ catch { return false; }
427
+ })();
370
428
  let sbomAbsentOrUnsigned = "hit";
371
429
  if (sbomFile) {
372
430
  const sigPath = path.join(root, `${sbomFile}.sig`);
373
431
  sbomAbsentOrUnsigned = fs.existsSync(sigPath) ? "miss" : "hit";
432
+ } else if (releaseSbomCapable || manifestProvenanceOptIn) {
433
+ // No committed SBOM, but the release pipeline generates / signs one
434
+ // (or emits a signed provenance attestation). Treat the capability
435
+ // as present-at-release rather than reporting it absent.
436
+ sbomAbsentOrUnsigned = "miss";
374
437
  }
375
438
 
376
439
  // vendored-no-provenance: vendor/ directory exists without a
@@ -385,7 +448,16 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
385
448
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
386
449
  for (const e of entries) {
387
450
  if (e.name === "_PROVENANCE.json") { foundProvenance = true; return; }
388
- if (e.isDirectory()) walkVendor(path.join(dir, e.name), depth + 1);
451
+ if (e.isDirectory()) {
452
+ if (DEFAULT_EXCLUDES.has(e.name)) continue;
453
+ const sub = path.join(dir, e.name);
454
+ // Don't descend into a linked git worktree nested under
455
+ // vendor/ — its `.git` is a gitdir pointer file, and a repo
456
+ // copy stamped by agent tooling carries unrelated provenance
457
+ // state that shouldn't count toward this tree's vendoring.
458
+ if (isLinkedWorktreeDir(sub)) continue;
459
+ walkVendor(sub, depth + 1);
460
+ }
389
461
  }
390
462
  };
391
463
  walkVendor(vendorDir, 0);
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/collectors/scan-excludes.js
5
+ *
6
+ * Shared directory-walk exclusion policy for every code-scope collector.
7
+ *
8
+ * Before this existed, each collector hard-coded its own exclude Set. None
9
+ * skipped agent/editor worktree copies (`.claude/worktrees/`), so a tree
10
+ * holding N detached worktrees scanned each one as a full repo — inflating
11
+ * hit counts to N+1 duplicates of the same source and forcing manual dedup.
12
+ *
13
+ * Two layers:
14
+ * 1. NAME exclusions — directory basenames never worth descending into
15
+ * (dependency caches, build output, VCS metadata, agent scratch).
16
+ * 2. A LINKED-WORKTREE predicate — a directory that is itself a git
17
+ * worktree distinct from the scan root. A `git worktree add` target
18
+ * carries a `.git` *file* (a gitdir pointer) rather than a `.git`
19
+ * directory; agent tools frequently stamp full repo copies under
20
+ * `.claude/worktrees/`. Skipping these keeps a scan to the one tree
21
+ * the operator actually pointed at.
22
+ *
23
+ * Collectors apply both: spread DEFAULT_CODE_EXCLUDES into their exclude
24
+ * Set, and call isLinkedWorktreeDir(fullPath) before descending into a
25
+ * subdirectory.
26
+ */
27
+
28
+ const fs = require("node:fs");
29
+ const path = require("node:path");
30
+
31
+ // Directory basenames excluded from every code-scope walk. Superset of the
32
+ // per-collector lists that predated this module, plus agent/editor scratch
33
+ // (`.claude`) and additional dependency/build caches that only ever hold
34
+ // generated or third-party content.
35
+ const DEFAULT_CODE_EXCLUDES = Object.freeze([
36
+ // VCS + agent/editor scratch
37
+ ".git", ".hg", ".svn", ".claude", ".idea", ".vscode",
38
+ // dependency trees / package caches
39
+ "node_modules", ".pnpm-store", "bower_components",
40
+ ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache",
41
+ ".tox", ".gradle", ".m2",
42
+ // build output
43
+ "dist", "build", "out", "target", "coverage",
44
+ ".next", ".nuxt", ".svelte-kit", ".turbo", ".cache",
45
+ ]);
46
+
47
+ /**
48
+ * Build an exclude Set for a collector. Pass any collector-specific extra
49
+ * basenames; they are merged with the shared defaults.
50
+ *
51
+ * @param {Iterable<string>} [extra] additional basenames to exclude
52
+ * @returns {Set<string>}
53
+ */
54
+ function codeExcludeSet(extra = []) {
55
+ return new Set([...DEFAULT_CODE_EXCLUDES, ...extra]);
56
+ }
57
+
58
+ /**
59
+ * True when `dir` is a git worktree linked to a repository elsewhere — i.e.
60
+ * its `.git` entry is a file (a `gitdir: …` pointer) rather than a directory.
61
+ * These are detached copies created by `git worktree add` (commonly under
62
+ * `.claude/worktrees/<id>/`); descending into them rescans unrelated repo
63
+ * state. A normal repo root has a `.git` *directory* and is NOT skipped.
64
+ *
65
+ * Cheap and synchronous: one lstat. Returns false on any error so a walk
66
+ * never aborts on a permission/race issue.
67
+ *
68
+ * @param {string} dir absolute path to a candidate directory
69
+ * @returns {boolean}
70
+ */
71
+ function isLinkedWorktreeDir(dir) {
72
+ try {
73
+ const gitPath = path.join(dir, ".git");
74
+ const st = fs.lstatSync(gitPath);
75
+ return st.isFile();
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ module.exports = {
82
+ DEFAULT_CODE_EXCLUDES,
83
+ codeExcludeSet,
84
+ isLinkedWorktreeDir,
85
+ };
@@ -18,17 +18,16 @@
18
18
 
19
19
  const fs = require("node:fs");
20
20
  const path = require("node:path");
21
+ const { codeExcludeSet, isLinkedWorktreeDir } = require("./scan-excludes");
21
22
 
22
23
  const COLLECTOR_ID = "secrets";
23
24
 
24
25
  // Walk depth + exclusion list mirrors the secrets playbook's
25
- // `look.artifacts[repo-tree].source` declaration.
26
+ // `look.artifacts[repo-tree].source` declaration. Exclusions come from
27
+ // the shared code-scope policy (dependency caches, build output, VCS +
28
+ // agent/editor scratch including `.claude/`); no secrets-specific extras.
26
29
  const DEFAULT_MAX_DEPTH = 6;
27
- const DEFAULT_EXCLUDES = new Set([
28
- "node_modules", ".git", "dist", "build", "out",
29
- ".venv", "venv", "__pycache__", ".pytest_cache",
30
- "target", ".idea", ".vscode",
31
- ]);
30
+ const DEFAULT_EXCLUDES = codeExcludeSet();
32
31
 
33
32
  // Path segments that denote test / fixture / example material. Hits
34
33
  // scoped exclusively to these paths are downgraded — a private-key
@@ -127,6 +126,11 @@ function walkTree(root, opts = {}) {
127
126
  if (seen.has(real)) continue;
128
127
  seen.add(real);
129
128
  if (entry.isDirectory()) {
129
+ // Skip linked git worktrees (their `.git` is a gitdir pointer
130
+ // file). Agent tooling stamps full repo copies under
131
+ // `.claude/worktrees/<id>/`; descending into them rescans the
132
+ // same files and inflates secret-carrier hit counts.
133
+ if (isLinkedWorktreeDir(full)) continue;
130
134
  walk(full, depth + 1);
131
135
  } else if (entry.isFile()) {
132
136
  out.push({ full, rel: path.relative(root, full), name: entry.name });
package/lib/cve-cli.js ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * lib/cve-cli.js — `exceptd cve <CVE-ID>` resolver.
6
+ *
7
+ * Catalog -> resolved cache -> one NVD lookup (cached). Tells an agent whether
8
+ * a cited CVE is published / rejected / disputed / fabricated / nonexistent
9
+ * without it researching NVD by hand. Network is opt-out (--air-gap /
10
+ * --no-network / EXCEPTD_AIR_GAP=1).
11
+ */
12
+
13
+ const { resolveCve } = require("./citation-resolve.js");
14
+
15
+ (async () => {
16
+ const argv = process.argv.slice(2);
17
+ const flags = new Set(argv.filter((a) => a.startsWith("--")));
18
+ const id = argv.find((a) => !a.startsWith("--"));
19
+ const pretty = flags.has("--pretty");
20
+ const json = flags.has("--json") || pretty;
21
+
22
+ if (!id) {
23
+ process.stderr.write(
24
+ JSON.stringify({ ok: false, verb: "cve", error: "usage: exceptd cve <CVE-ID> [--json|--pretty] [--air-gap|--no-network]" }) + "\n"
25
+ );
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+
30
+ const r = await resolveCve(id, { airGap: flags.has("--air-gap"), noNetwork: flags.has("--no-network") });
31
+ const body = { ok: true, verb: "cve", ...r };
32
+
33
+ if (json) {
34
+ process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
35
+ } else {
36
+ const bits = [];
37
+ bits.push(`${r.id}: ${String(r.status).toUpperCase()}`);
38
+ if (r.cvss != null) bits.push(`CVSS ${r.cvss}`);
39
+ if (r.kev != null) bits.push(`KEV=${r.kev}`);
40
+ if (r.product) bits.push(r.product);
41
+ let line = bits.join(" · ") + ` (${r.from})`;
42
+ if (r.nvd_vuln_status) line += `\n NVD vulnStatus: ${r.nvd_vuln_status}`;
43
+ if (Array.isArray(r.cve_tags) && r.cve_tags.length) line += `\n NVD tags: ${r.cve_tags.join(", ")}`;
44
+ if (r.reason) line += `\n ${r.reason}`;
45
+ process.stdout.write(line + "\n");
46
+ }
47
+ // A citation that won't stand up is a non-zero exit so a CI/script gate trips.
48
+ if (r.status === "rejected" || r.status === "fabricated" || r.status === "nonexistent" || r.status === "withdrawn") {
49
+ process.exitCode = 2;
50
+ }
51
+ })();
@@ -107,7 +107,7 @@ const VERB_FLAG_ALLOWLIST = Object.freeze({
107
107
  ask: [],
108
108
  attest: [
109
109
  'against', 'playbook', 'since', 'latest', 'format', 'force', 'dry-run',
110
- 'all-older-than',
110
+ 'all-older-than', 'limit',
111
111
  ],
112
112
  reattest: [
113
113
  'playbook', 'since', 'latest', 'force-replay', 'attestation-root',
@@ -117,7 +117,7 @@ const VERB_FLAG_ALLOWLIST = Object.freeze({
117
117
  collect: ['cwd', 'attest-ownership'],
118
118
  refresh: [
119
119
  'apply', 'dry-run', 'from-cache', 'from-fixture', 'network', 'source',
120
- 'advisory', 'force-stale', 'force-stale-acked', 'air-gap', 'swarm',
120
+ 'advisory', 'check-advisories', 'force-stale', 'force-stale-acked', 'air-gap', 'swarm',
121
121
  ],
122
122
  prefetch: ['source', 'cache-dir', 'max-age', 'force', 'no-network', 'quiet'],
123
123
  });
@@ -79,6 +79,12 @@ function parseArgs(argv) {
79
79
  else if (a === "--help" || a === "-h") out.help = true;
80
80
  else if (a === "--advisory") { out.advisory = argv[++i]; }
81
81
  else if (a.startsWith("--advisory=")) { out.advisory = a.slice("--advisory=".length); }
82
+ // --check-advisories polls the primary-source advisory feeds (Qualys TRU,
83
+ // RHSA, USN, ZDI, kernel.org, oss-security, vendor research blogs) and
84
+ // reports newly-seen CVE IDs ahead of NVD enrichment. Report-only: it
85
+ // selects the `advisories` source and never applies — operators triage the
86
+ // diffs[] and seed promising IDs via `refresh --advisory <id> --apply`.
87
+ else if (a === "--check-advisories") { out.source = "advisories"; out.apply = false; out.checkAdvisories = true; }
82
88
  else if (a === "--catalog") { out.catalog = argv[++i]; }
83
89
  else if (a.startsWith("--catalog=")) { out.catalog = a.slice("--catalog=".length); }
84
90
  else if (a === "--from-cache") {
@@ -105,6 +111,9 @@ function parseArgs(argv) {
105
111
  else if (a === "--force-stale") out.forceStale = true;
106
112
  }
107
113
  if (process.env.EXCEPTD_FORCE_STALE === "1") out.forceStale = true;
114
+ // Report-only is intrinsic to the advisory poll regardless of flag order —
115
+ // a trailing --apply must not turn it into a catalog mutation.
116
+ if (out.checkAdvisories) out.apply = false;
108
117
  return out;
109
118
  }
110
119
 
@@ -129,6 +138,12 @@ Modes:
129
138
  Combine with --apply to upsert against cached data
130
139
  entirely offline. Cache must be pre-populated via --prefetch.
131
140
  --source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins|ghsa|osv)
141
+ --check-advisories poll primary-source advisory feeds (Qualys TRU, RHSA, USN,
142
+ ZDI, kernel.org, oss-security, vendor research blogs) and
143
+ report newly-seen CVE IDs ahead of NVD enrichment.
144
+ Report-only — emits diffs[]; never mutates the catalog.
145
+ Triage the output and seed promising IDs with
146
+ \`exceptd refresh --advisory <id> --apply\`.
132
147
  --from-fixture <p> use frozen fixture payloads (tests use this path)
133
148
  --indexes-only rebuild data/_indexes/ only; no network. Equivalent to
134
149
  \`exceptd refresh --indexes-only\`.
package/lib/rfc-cli.js ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * lib/rfc-cli.js — `exceptd rfc <number>` resolver.
6
+ *
7
+ * Local index (whole current RFC series, offline) -> resolved cache -> one
8
+ * datatracker lookup to disambiguate obsoleted-vs-nonexistent. Resolves an RFC
9
+ * number to its title + status so an agent can confirm a citation (e.g. "is
10
+ * RFC 9404 the Sieve spec?") without the datatracker. Optional --check
11
+ * "<claimed title>" reports whether the claimed title matches.
12
+ */
13
+
14
+ const { resolveRfc } = require("./citation-resolve.js");
15
+
16
+ (async () => {
17
+ const argv = process.argv.slice(2);
18
+ const flags = new Set(argv.filter((a) => a.startsWith("--")));
19
+ const positionals = argv.filter((a) => !a.startsWith("--"));
20
+ const id = positionals[0];
21
+ const pretty = flags.has("--pretty");
22
+ const json = flags.has("--json") || pretty;
23
+
24
+ // --check "<claimed title>" : the next non-flag token after the number.
25
+ let claimedTitle = null;
26
+ const checkIdx = argv.indexOf("--check");
27
+ if (checkIdx !== -1 && argv[checkIdx + 1] && !argv[checkIdx + 1].startsWith("--")) {
28
+ claimedTitle = argv[checkIdx + 1];
29
+ }
30
+
31
+ if (!id) {
32
+ process.stderr.write(
33
+ JSON.stringify({ ok: false, verb: "rfc", error: "usage: exceptd rfc <number> [--check \"<claimed title>\"] [--json|--pretty] [--air-gap|--no-network]" }) + "\n"
34
+ );
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+
39
+ const r = await resolveRfc(id, { airGap: flags.has("--air-gap"), noNetwork: flags.has("--no-network") });
40
+
41
+ let titleMatch = null;
42
+ if (claimedTitle && r.title) {
43
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
44
+ const a = norm(claimedTitle), b = norm(r.title);
45
+ titleMatch = a.length > 0 && (b.includes(a) || a.includes(b));
46
+ }
47
+ const body = { ok: true, verb: "rfc", ...r, ...(claimedTitle ? { claimed_title: claimedTitle, title_match: titleMatch } : {}) };
48
+
49
+ if (json) {
50
+ process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
51
+ } else {
52
+ let line;
53
+ if (r.found && r.title) {
54
+ line = `RFC ${r.number}: ${r.title}`;
55
+ if (r.rfc_status) line += ` (${r.rfc_status})`;
56
+ if (r.obsoleted_by) line += `\n obsoleted by: ${r.obsoleted_by}`;
57
+ if (claimedTitle) line += `\n claimed "${claimedTitle}" -> ${titleMatch ? "MATCH" : "MISMATCH"}`;
58
+ } else {
59
+ line = `RFC ${r.number ?? r.id}: ${String(r.status).toUpperCase()}`;
60
+ if (r.note) line += `\n ${r.note}`;
61
+ if (r.reason) line += `\n ${r.reason}`;
62
+ }
63
+ line += ` (${r.from})`;
64
+ process.stdout.write(line + "\n");
65
+ }
66
+ // A mismatched or nonexistent citation is a non-zero exit for gates.
67
+ if (r.status === "nonexistent" || titleMatch === false) process.exitCode = 2;
68
+ })();
@@ -92,6 +92,19 @@
92
92
  "enum": ["confirmed", "suspected", "theoretical", "none", "unknown"],
93
93
  "description": "v0.13.5: enum reconciled with the _meta.active_exploitation_vocabulary block (5 values). 'theoretical' added — distinct from 'suspected' because it captures the 'PoC exists but no observation in the wild' state without committing to a probability claim."
94
94
  },
95
+ "status": {
96
+ "type": "string",
97
+ "enum": ["published", "rejected", "disputed", "withdrawn", "reserved", "unknown"],
98
+ "description": "Assignment lifecycle status (distinct from active_exploitation). 'rejected'/'disputed' come from NVD vulnStatus/cveTags; 'withdrawn' from OSV/GHSA. Optional — absent means 'published' is assumed. Lets a citation check read a structured field instead of grepping free-text notes. Pair with status_source + status_verified for provenance."
99
+ },
100
+ "status_source": {
101
+ "type": "string",
102
+ "description": "Provenance of `status` (e.g. 'nvd:vulnStatus', 'osv:withdrawn', 'ghsa:withdrawn_at', 'curated')."
103
+ },
104
+ "status_verified": {
105
+ "type": "string",
106
+ "description": "ISO-8601 timestamp the status was last confirmed against its source."
107
+ },
95
108
  "affected": {
96
109
  "type": "string",
97
110
  "minLength": 1,
@@ -364,6 +364,9 @@ function normalizeAdvisory(adv) {
364
364
  _draft_reason: "Imported from GHSA on " + new Date().toISOString().slice(0, 10) + ". Editorial fields (framework_control_gaps, atlas_refs, attack_refs, iocs, vector, complexity, rwep_factors) require human review. Run `exceptd run sbom --evidence -` against an affected repo to gather IoCs; consult MITRE ATLAS + ATT&CK catalogs for refs.",
365
365
  _source_ghsa_id: adv.ghsa_id || null,
366
366
  _source_published_at: adv.published_at || null,
367
+ // GitHub sets `withdrawn_at` when an advisory is retracted. Surface it as
368
+ // structured status so a withdrawn advisory is flagged, not imported as live.
369
+ ...(adv.withdrawn_at ? { status: "withdrawn", status_source: "ghsa:withdrawn_at", status_verified: new Date().toISOString().slice(0, 10) } : {}),
367
370
  last_updated: new Date().toISOString().slice(0, 10),
368
371
  },
369
372
  };
package/lib/source-osv.js CHANGED
@@ -810,6 +810,10 @@ function normalizeAdvisory(rec) {
810
810
  _draft_reason: "Imported from OSV.dev on " + today + ". Editorial fields (framework_control_gaps, atlas_refs, attack_refs, iocs, vector, complexity, rwep_factors) require human review. Run `exceptd run sbom --evidence -` against an affected repo to gather IoCs; consult MITRE ATLAS + ATT&CK catalogs for refs.",
811
811
  _source_osv_id: rec.id,
812
812
  _source_published_at: rec.published || null,
813
+ // OSV sets a top-level `withdrawn` timestamp when a record is retracted.
814
+ // Surface it as structured status so a citation check (and the resolver)
815
+ // can flag a withdrawn advisory instead of importing it as if live.
816
+ ...(rec.withdrawn ? { status: "withdrawn", status_source: "osv:withdrawn", status_verified: today } : {}),
813
817
  last_updated: modified || today,
814
818
  },
815
819
  };
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * - includes every required file from package.json `files`
9
9
  * - excludes every forbidden file (secrets, tests, caches, dev artifacts)
10
- * - is under the size budget (currently 5 MB)
10
+ * - is under the size budget (currently 7 MB)
11
11
  * - `bin/exceptd.js` has the expected shebang
12
12
  * - the bin target listed in package.json exists on disk
13
13
  *
@@ -22,7 +22,12 @@ const { spawnSync } = require("child_process");
22
22
 
23
23
  const ROOT = path.join(__dirname, "..");
24
24
  const ABS = (p) => path.join(ROOT, p);
25
- const SIZE_BUDGET_BYTES = 5 * 1024 * 1024; // 5 MB published-tarball cap
25
+ // Published-tarball cap. Guards against accidental bloat (a vendored
26
+ // node_modules, a committed binary — tens of MB), not the curated data that
27
+ // legitimately grows each release: the CVE catalog gains entries and the RFC
28
+ // index spans the full series. Packed size crossed 5 MB through that gradual
29
+ // growth; 7 MB restores headroom while still catching a gross-bloat accident.
30
+ const SIZE_BUDGET_BYTES = 7 * 1024 * 1024;
26
31
 
27
32
  const REQUIRED_PATHS = [
28
33
  "package.json",