@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.
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +30 -0
- package/README.md +43 -9
- package/bin/exceptd.js +148 -35
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/citation-hygiene.json +820 -0
- package/lib/citation-resolve.js +226 -0
- package/lib/collectors/cicd-pipeline-compromise.js +10 -1
- package/lib/collectors/citation-hygiene.js +465 -0
- package/lib/collectors/containers.js +12 -7
- package/lib/collectors/crypto-codebase.js +11 -5
- package/lib/collectors/library-author.js +82 -10
- package/lib/collectors/scan-excludes.js +85 -0
- package/lib/collectors/secrets.js +10 -6
- package/lib/cve-cli.js +51 -0
- package/lib/flag-suggest.js +2 -2
- package/lib/refresh-external.js +15 -0
- package/lib/rfc-cli.js +68 -0
- package/lib/schemas/cve-catalog.schema.json +13 -0
- package/lib/source-ghsa.js +3 -0
- package/lib/source-osv.js +4 -0
- package/lib/validate-package.js +7 -2
- package/manifest.json +44 -44
- package/package.json +1 -1
- package/sbom.cdx.json +134 -44
- package/scripts/check-agents-md-collectors.js +8 -0
- package/sources/validators/cve-validator.js +46 -1
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 `
|
|
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
|
-
|
|
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: "
|
|
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())
|
|
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 =
|
|
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
|
+
})();
|
package/lib/flag-suggest.js
CHANGED
|
@@ -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
|
});
|
package/lib/refresh-external.js
CHANGED
|
@@ -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,
|
package/lib/source-ghsa.js
CHANGED
|
@@ -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
|
};
|
package/lib/validate-package.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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",
|