@blamejs/exceptd-skills 0.13.125 → 0.14.0

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 });
@@ -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\`.