@blamejs/exceptd-skills 0.16.22 → 0.16.24
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/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +42 -0
- package/CONTEXT.md +9 -9
- package/README.md +3 -3
- package/agents/report-generator.md +2 -2
- package/agents/skill-updater.md +1 -1
- package/agents/source-validator.md +3 -4
- package/agents/threat-researcher.md +1 -1
- package/bin/exceptd.js +91 -32
- package/data/_indexes/_meta.json +10 -10
- package/data/_indexes/activity-feed.json +12 -12
- package/data/_indexes/chains.json +70435 -4026
- package/data/_indexes/frequency.json +492 -163
- package/data/_indexes/section-offsets.json +51 -51
- package/data/_indexes/summary-cards.json +272 -106
- package/data/_indexes/token-budget.json +10 -10
- package/data/_indexes/trigger-table.json +15 -6
- package/data/_indexes/xref.json +218 -26
- package/data/cve-catalog.json +10 -10
- package/data/cwe-catalog.json +1 -0
- package/lib/auto-discovery.js +39 -1
- package/lib/collectors/ai-api.js +112 -7
- package/lib/collectors/citation-hygiene.js +27 -0
- package/lib/collectors/crypto-codebase.js +25 -0
- package/lib/collectors/kernel.js +32 -2
- package/lib/collectors/library-author.js +30 -0
- package/lib/collectors/runtime.js +38 -3
- package/lib/collectors/sbom.js +21 -2
- package/lib/collectors/scan-excludes.js +4 -1
- package/lib/collectors/secrets.js +125 -0
- package/lib/cve-cli.js +9 -1
- package/lib/cve-curation.js +8 -1
- package/lib/cve-regression-watcher.js +5 -2
- package/lib/exit-codes.js +2 -0
- package/lib/flag-suggest.js +1 -1
- package/lib/lint-skills.js +70 -0
- package/lib/playbook-runner.js +75 -14
- package/lib/prefetch.js +24 -1
- package/lib/refresh-external.js +32 -3
- package/lib/rfc-cli.js +8 -1
- package/lib/scoring.js +36 -8
- package/lib/validate-cve-catalog.js +36 -14
- package/lib/validate-package.js +8 -0
- package/lib/validate-playbooks.js +42 -0
- package/lib/verify.js +4 -3
- package/manifest-snapshot.json +4 -2
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +57 -54
- package/orchestrator/README.md +1 -1
- package/orchestrator/index.js +65 -7
- package/orchestrator/scanner.js +53 -5
- package/package.json +1 -1
- package/sbom.cdx.json +110 -110
- package/scripts/build-indexes.js +42 -8
- package/scripts/builders/cwe-chains.js +1 -0
- package/scripts/builders/section-offsets.js +10 -2
- package/scripts/builders/token-budget.js +3 -3
- package/scripts/check-changelog-extract.js +38 -1
- package/scripts/check-sbom-currency.js +72 -0
- package/scripts/check-version-tags.js +5 -0
- package/scripts/release.js +22 -15
- package/skills/exploit-scoring/skill.md +8 -8
package/lib/collectors/ai-api.js
CHANGED
|
@@ -49,6 +49,16 @@ const AI_KEY_PATTERNS = [
|
|
|
49
49
|
{ id: "cohere", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?COHERE_API_KEY\s*[= ]\s*['"]?[A-Za-z0-9-]{30,}/m },
|
|
50
50
|
];
|
|
51
51
|
|
|
52
|
+
// Capture the exported value so the false_positive_checks_required entries
|
|
53
|
+
// (placeholder demotion, entropy floor) can be evaluated. The export
|
|
54
|
+
// patterns above end at the prefix; widen to grab the trailing token.
|
|
55
|
+
const AI_KEY_VALUE_RE = {
|
|
56
|
+
openai: /OPENAI_API_KEY\s*[= ]\s*['"]?(sk-[A-Za-z0-9_-]+)/,
|
|
57
|
+
anthropic: /ANTHROPIC_API_KEY\s*[= ]\s*['"]?(sk-ant-[A-Za-z0-9_-]+)/,
|
|
58
|
+
huggingface: /(?:HUGGINGFACE_TOKEN|HF_TOKEN)\s*[= ]\s*['"]?(hf_[A-Za-z0-9]+)/,
|
|
59
|
+
};
|
|
60
|
+
const PLACEHOLDER_RE = /placeholder|example|redacted|dummy|x{4,}|0{6,}|test-/i;
|
|
61
|
+
|
|
52
62
|
function scanShellRc(content) {
|
|
53
63
|
if (!content) return [];
|
|
54
64
|
const hits = [];
|
|
@@ -58,6 +68,31 @@ function scanShellRc(content) {
|
|
|
58
68
|
return hits;
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
// Deterministic false_positive_checks_required evaluation for
|
|
72
|
+
// cleartext-api-key-in-dotfile. Returns the satisfiable indices for the
|
|
73
|
+
// exports found across the canonical dotfiles (intersection — an index is
|
|
74
|
+
// only attested if every export satisfies it). Canonical home rc / dotfile
|
|
75
|
+
// paths are never under examples/tests/fixtures, so the path check [1] is
|
|
76
|
+
// always satisfied here.
|
|
77
|
+
function cleartextFpIndices(content) {
|
|
78
|
+
const sat = new Set(["0", "1", "2"]);
|
|
79
|
+
let sawAny = false;
|
|
80
|
+
for (const [vendor, re] of Object.entries(AI_KEY_VALUE_RE)) {
|
|
81
|
+
const m = content.match(re);
|
|
82
|
+
if (!m) continue;
|
|
83
|
+
sawAny = true;
|
|
84
|
+
const value = m[1];
|
|
85
|
+
// [0] not a documented placeholder / sk-test- fixture
|
|
86
|
+
if (PLACEHOLDER_RE.test(value)) sat.delete("0");
|
|
87
|
+
// [2] entropy floor: OpenAI sk-* >= 48 post-prefix, Anthropic sk-ant-* >= 40,
|
|
88
|
+
// HuggingFace hf_* >= 30.
|
|
89
|
+
const floor = vendor === "openai" ? 48 : vendor === "anthropic" ? 40 : 30;
|
|
90
|
+
const body = value.replace(/^sk-ant-(?:api03|admin01)-|^sk-(?:proj-|svcacct-|admin-)?|^hf_/, "");
|
|
91
|
+
if (body.length < floor) sat.delete("2");
|
|
92
|
+
}
|
|
93
|
+
return sawAny ? sat : new Set();
|
|
94
|
+
}
|
|
95
|
+
|
|
61
96
|
function parseAwsCredentials(content) {
|
|
62
97
|
if (!content) return { staticProfiles: [] };
|
|
63
98
|
const lines = content.split(/\r?\n/);
|
|
@@ -74,30 +109,43 @@ function parseAwsCredentials(content) {
|
|
|
74
109
|
profiles[current][kv[1].trim().toLowerCase()] = kv[2].trim();
|
|
75
110
|
}
|
|
76
111
|
const staticProfiles = [];
|
|
112
|
+
const accessKeyIds = [];
|
|
77
113
|
for (const [name, kv] of Object.entries(profiles)) {
|
|
78
114
|
// long-lived-aws-keys: aws_access_key_id present AND no
|
|
79
115
|
// aws_session_token sibling (STS temporary creds carry the
|
|
80
116
|
// session token; IAM-user long-lived keys do not).
|
|
81
117
|
if (kv["aws_access_key_id"] && !kv["aws_session_token"]) {
|
|
82
118
|
staticProfiles.push(name);
|
|
119
|
+
accessKeyIds.push(kv["aws_access_key_id"]);
|
|
83
120
|
}
|
|
84
121
|
}
|
|
85
|
-
return { staticProfiles };
|
|
122
|
+
return { staticProfiles, accessKeyIds };
|
|
86
123
|
}
|
|
87
124
|
|
|
125
|
+
// AWS-published sample credential pair — long-lived-aws-keys FP[0] demotes it.
|
|
126
|
+
const AWS_EXAMPLE_KEY_PARTS = new Set([
|
|
127
|
+
"AKIAIOSFODNN7EXAMPLE",
|
|
128
|
+
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
129
|
+
]);
|
|
130
|
+
|
|
88
131
|
function parseGcloudAdc(content) {
|
|
89
132
|
if (!content) return { hasServiceAccount: false };
|
|
90
133
|
try {
|
|
91
134
|
const j = JSON.parse(content);
|
|
92
|
-
|
|
135
|
+
const hasServiceAccount = j?.type === "service_account";
|
|
136
|
+
return {
|
|
137
|
+
hasServiceAccount,
|
|
138
|
+
privateKey: typeof j?.private_key === "string" ? j.private_key : "",
|
|
139
|
+
clientEmail: typeof j?.client_email === "string" ? j.client_email : "",
|
|
140
|
+
};
|
|
93
141
|
} catch { return { hasServiceAccount: false }; }
|
|
94
142
|
}
|
|
95
143
|
|
|
96
144
|
function parseKubeStaticToken(content) {
|
|
97
|
-
if (!content) return false;
|
|
145
|
+
if (!content) return { found: false };
|
|
98
146
|
// Same shape as cred-stores: token under user:, not auth-provider.
|
|
99
147
|
const userKvRe = /^(\s+)(token|token-data)\s*:\s*(\S[^\n]*)/gm;
|
|
100
|
-
let
|
|
148
|
+
let tokenValue = null;
|
|
101
149
|
for (const m of content.matchAll(userKvRe)) {
|
|
102
150
|
const upto = content.slice(0, m.index);
|
|
103
151
|
const lastUserAt = upto.lastIndexOf("\n user:");
|
|
@@ -105,12 +153,17 @@ function parseKubeStaticToken(content) {
|
|
|
105
153
|
if (lastAuthProviderAt > lastUserAt) continue;
|
|
106
154
|
const value = m[3];
|
|
107
155
|
if (!value || value.startsWith("null")) continue;
|
|
108
|
-
|
|
156
|
+
tokenValue = value.trim();
|
|
109
157
|
break;
|
|
110
158
|
}
|
|
111
|
-
|
|
159
|
+
// Cluster server URL — FP[0] demotes local-only clusters.
|
|
160
|
+
const serverM = content.match(/^\s*server:\s*(\S+)/m);
|
|
161
|
+
return { found: tokenValue !== null, tokenValue, serverUrl: serverM ? serverM[1] : "" };
|
|
112
162
|
}
|
|
113
163
|
|
|
164
|
+
const LOCAL_CLUSTER_RE = /https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])[:/]|\.kind\b|minikube|k3d|docker-for-desktop|docker-desktop/i;
|
|
165
|
+
const CI_RUNNER_PATH_RE = /(?:^|[\\/])(?:home[\\/]runner|github[\\/]workspace|builds|workspace)[\\/]/i;
|
|
166
|
+
|
|
114
167
|
function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
115
168
|
const errors = [];
|
|
116
169
|
const startTime = Date.now();
|
|
@@ -140,6 +193,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
140
193
|
|
|
141
194
|
const allKeyCarriers = [...shellRcs, ...dotfileKeys];
|
|
142
195
|
const cleartextHitsByFile = {};
|
|
196
|
+
let cleartextFp = null;
|
|
143
197
|
for (const p of allKeyCarriers) {
|
|
144
198
|
if (!fileExists(p)) continue;
|
|
145
199
|
const c = readSafe(p);
|
|
@@ -147,6 +201,11 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
147
201
|
const hits = scanShellRc(c);
|
|
148
202
|
if (hits.length > 0) {
|
|
149
203
|
cleartextHitsByFile[path.relative(home, p)] = hits;
|
|
204
|
+
const fp = cleartextFpIndices(c);
|
|
205
|
+
if (fp.size) {
|
|
206
|
+
if (cleartextFp === null) cleartextFp = new Set(fp);
|
|
207
|
+
else for (const idx of [...cleartextFp]) if (!fp.has(idx)) cleartextFp.delete(idx);
|
|
208
|
+
}
|
|
150
209
|
}
|
|
151
210
|
}
|
|
152
211
|
const cleartextAnyHit = Object.keys(cleartextHitsByFile).length > 0;
|
|
@@ -163,7 +222,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
163
222
|
|
|
164
223
|
const kubeCfgPath = (env && env.KUBECONFIG) || path.join(home, ".kube", "config");
|
|
165
224
|
const kubeContent = fileExists(kubeCfgPath) ? readSafe(kubeCfgPath) : null;
|
|
166
|
-
const
|
|
225
|
+
const kubeParsed = parseKubeStaticToken(kubeContent);
|
|
226
|
+
const kubeStaticToken = kubeParsed.found;
|
|
167
227
|
|
|
168
228
|
const signal_overrides = {
|
|
169
229
|
"cleartext-api-key-in-dotfile": cleartextAnyHit ? "hit" : "miss",
|
|
@@ -172,6 +232,51 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
172
232
|
"kubeconfig-with-static-token": kubeStaticToken ? "hit" : "miss",
|
|
173
233
|
};
|
|
174
234
|
|
|
235
|
+
// Per-indicator __fp_checks attestation. Each canonical-path credential
|
|
236
|
+
// store the collector reads is never under an examples/tests/fixtures path,
|
|
237
|
+
// so the path-based FP checks are satisfied; value-based checks (placeholder,
|
|
238
|
+
// entropy, sample-credential, cluster-locality) are evaluated deterministically.
|
|
239
|
+
// Network / sts-validity checks are left unattested so the runner still
|
|
240
|
+
// downgrades those. Without this, a real cleartext key or static token
|
|
241
|
+
// surfaced by `collect` is downgraded to inconclusive after `run`.
|
|
242
|
+
if (cleartextAnyHit && cleartextFp && cleartextFp.size) {
|
|
243
|
+
const att = {};
|
|
244
|
+
for (const idx of cleartextFp) att[idx] = true;
|
|
245
|
+
signal_overrides["cleartext-api-key-in-dotfile__fp_checks"] = att;
|
|
246
|
+
}
|
|
247
|
+
if (longLivedAws) {
|
|
248
|
+
const att = {};
|
|
249
|
+
// [0] none of the access-key ids are the AWS-published sample pair
|
|
250
|
+
if (!(awsParsed.accessKeyIds || []).some((k) => AWS_EXAMPLE_KEY_PARTS.has(k))) att["0"] = true;
|
|
251
|
+
// [1] ~/.aws/credentials is a canonical home path, not an examples/test path
|
|
252
|
+
att["1"] = true;
|
|
253
|
+
// [2] sts get-caller-identity needs network — left unattested.
|
|
254
|
+
if (Object.keys(att).length) signal_overrides["long-lived-aws-keys__fp_checks"] = att;
|
|
255
|
+
}
|
|
256
|
+
if (gcloudParsed.hasServiceAccount) {
|
|
257
|
+
const att = {};
|
|
258
|
+
// [0] private_key is a real PEM body (>= 1000 chars), not PLACEHOLDER/REDACTED
|
|
259
|
+
const pk = gcloudParsed.privateKey || "";
|
|
260
|
+
if (pk.length >= 1000 && !/PLACEHOLDER|REDACTED/i.test(pk)) att["0"] = true;
|
|
261
|
+
// [1] client_email is a real *@*.gserviceaccount.com (not example/test)
|
|
262
|
+
const ce = gcloudParsed.clientEmail || "";
|
|
263
|
+
if (/@[^@\s]+\.gserviceaccount\.com$/i.test(ce) && !/@example\.com$|@test\./i.test(ce)) att["1"] = true;
|
|
264
|
+
// [2] canonical ADC path (not under examples/) AND no GOOGLE_APPLICATION_CREDENTIALS
|
|
265
|
+
// redirecting away from it
|
|
266
|
+
if (!(env && env.GOOGLE_APPLICATION_CREDENTIALS)) att["2"] = true;
|
|
267
|
+
if (Object.keys(att).length) signal_overrides["gcp-service-account-json__fp_checks"] = att;
|
|
268
|
+
}
|
|
269
|
+
if (kubeStaticToken) {
|
|
270
|
+
const att = {};
|
|
271
|
+
// [0] cluster server URL is not a local-only dev cluster
|
|
272
|
+
if (kubeParsed.serverUrl && !LOCAL_CLUSTER_RE.test(kubeParsed.serverUrl)) att["0"] = true;
|
|
273
|
+
// [1] token is not a short kind/minikube bootstrap-token shape
|
|
274
|
+
if (kubeParsed.tokenValue && kubeParsed.tokenValue.length >= 40 && !/^[a-z0-9]{6}\.[a-z0-9]{16}$/.test(kubeParsed.tokenValue)) att["1"] = true;
|
|
275
|
+
// [2] kubeconfig is not inside a CI runner workspace
|
|
276
|
+
if (!CI_RUNNER_PATH_RE.test(kubeCfgPath)) att["2"] = true;
|
|
277
|
+
if (Object.keys(att).length) signal_overrides["kubeconfig-with-static-token__fp_checks"] = att;
|
|
278
|
+
}
|
|
279
|
+
|
|
175
280
|
const artifacts = {
|
|
176
281
|
"shell-rc-files": {
|
|
177
282
|
value: cleartextAnyHit
|
|
@@ -421,6 +421,33 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
421
421
|
signal_overrides["cve-citation-needs-external-verification"] = "inconclusive";
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
+
// __fp_checks attestation for the FP-gated indicators the collector decides
|
|
425
|
+
// deterministically. Each hit already excludes illustrative (template /
|
|
426
|
+
// fixture / doc-snippet) paths and is keyed off the shipped catalogs, so the
|
|
427
|
+
// path / catalog-cross-reference / same-citation checks the collector ran
|
|
428
|
+
// are attested; surrounding-text-acknowledgement remains operator judgement.
|
|
429
|
+
// Without this the runner downgrades a real bad citation to inconclusive.
|
|
430
|
+
if (signal_overrides["fabricated-cve-id"] === "hit") {
|
|
431
|
+
// [0] not under a fixture / regex-example / doc-snippet path (illustrative
|
|
432
|
+
// paths are excluded before the hit). [1] placeholder forms (CVE-TBD /
|
|
433
|
+
// pending) never match the numeric citation regex, so a fired hit is
|
|
434
|
+
// not a placeholder.
|
|
435
|
+
signal_overrides["fabricated-cve-id__fp_checks"] = { "0": true, "1": true };
|
|
436
|
+
}
|
|
437
|
+
if (signal_overrides["rejected-or-disputed-cve"] === "hit") {
|
|
438
|
+
// [1] the catalog note marks THIS exact identifier rejected/disputed.
|
|
439
|
+
// [2] the identifier is present in the catalog (absence does not fire).
|
|
440
|
+
// [0] inline dispute-acknowledgement in surrounding prose is operator
|
|
441
|
+
// judgement — left unattested.
|
|
442
|
+
signal_overrides["rejected-or-disputed-cve__fp_checks"] = { "1": true, "2": true };
|
|
443
|
+
}
|
|
444
|
+
if (signal_overrides["rfc-number-title-mismatch"] === "hit") {
|
|
445
|
+
// [0] a paraphrase / nickname (no title claim) does not fire. [1] numbers
|
|
446
|
+
// absent from the shipped RFC index do not fire. [2] the stated title is
|
|
447
|
+
// extracted from the SAME citation line.
|
|
448
|
+
signal_overrides["rfc-number-title-mismatch__fp_checks"] = { "0": true, "1": true, "2": true };
|
|
449
|
+
}
|
|
450
|
+
|
|
424
451
|
const summarize = (list) => {
|
|
425
452
|
if (list.length === 0) return "0 hits";
|
|
426
453
|
const head = list.slice(0, 5).map((h) => {
|
|
@@ -405,6 +405,31 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
405
405
|
if (noMlKemImpl !== undefined) signal_overrides["no-ml-kem-implementation"] = noMlKemImpl;
|
|
406
406
|
if (fipsTheater !== undefined) signal_overrides["fips-claim-without-runtime-activation"] = fipsTheater;
|
|
407
407
|
|
|
408
|
+
// Per-indicator __fp_checks attestation for the FP-gated call-site
|
|
409
|
+
// indicators. Every surviving hit is in a non-test source file (isTest is
|
|
410
|
+
// excluded before the scan) and, for weak-hash, flows into a security sink
|
|
411
|
+
// (scanWeakHash). The remaining false_positive_checks_required entries are
|
|
412
|
+
// legacy-protocol-shim / feature-flag / later-override judgements the
|
|
413
|
+
// collector does not make, so they stay unattested and the runner keeps
|
|
414
|
+
// those indicators inconclusive. Without attesting what it DID check, the
|
|
415
|
+
// collector's real call-site hits are downgraded to inconclusive after run.
|
|
416
|
+
if (signal_overrides["weak-hash-import"] === "hit") {
|
|
417
|
+
// [0] not under test + non-security-file demotion; [2] hash flows to an
|
|
418
|
+
// authn/integrity sink. [1] legacy-protocol shim is operator judgement.
|
|
419
|
+
signal_overrides["weak-hash-import__fp_checks"] = { "0": true, "2": true };
|
|
420
|
+
}
|
|
421
|
+
if (signal_overrides["weak-cipher-mode"] === "hit") {
|
|
422
|
+
// [0] not under test/KAT-vector path; [2] construction is in a scanned
|
|
423
|
+
// (production) source file. [1] legacy-protocol-parser scope is operator.
|
|
424
|
+
signal_overrides["weak-cipher-mode__fp_checks"] = { "0": true, "2": true };
|
|
425
|
+
}
|
|
426
|
+
if (signal_overrides["tls-old-protocol"] === "hit") {
|
|
427
|
+
// [0] the hit is in non-test production code, not a test asserting the
|
|
428
|
+
// library REJECTS the legacy protocol. [1] feature-flag-default-off and
|
|
429
|
+
// [2] later-override are not inspected by the collector.
|
|
430
|
+
signal_overrides["tls-old-protocol__fp_checks"] = { "0": true };
|
|
431
|
+
}
|
|
432
|
+
|
|
408
433
|
const summarize = (id) => {
|
|
409
434
|
const list = hits[id];
|
|
410
435
|
if (list.length === 0) return "0 hits";
|
package/lib/collectors/kernel.js
CHANGED
|
@@ -109,6 +109,18 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// Running-kernel build config (/boot/config-$(uname -r)) backs the
|
|
113
|
+
// CONFIG_* false_positive_checks_required entries: a sysctl is moot if the
|
|
114
|
+
// feature is compiled out of the kernel. Best-effort — unreadable on many
|
|
115
|
+
// hardened hosts.
|
|
116
|
+
let kernelConfig = null;
|
|
117
|
+
if (linuxPlatform && unameR.ok) {
|
|
118
|
+
try {
|
|
119
|
+
const fs = require("node:fs");
|
|
120
|
+
kernelConfig = fs.readFileSync(`/boot/config-${unameR.value}`, "utf8");
|
|
121
|
+
} catch { /* config not readable — CONFIG_* checks stay unattested */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
// Signal overrides: we can't decide kver-in-affected-range without
|
|
113
125
|
// the CVE-affected-version catalog (the runner does that
|
|
114
126
|
// correlation). But we CAN flip the deterministic indicators that
|
|
@@ -127,13 +139,31 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
127
139
|
// unpriv-userns-enabled: clone == 1 means enabled (risky).
|
|
128
140
|
if (parsed.unprivileged_userns_clone != null) {
|
|
129
141
|
const v = parseInt(parsed.unprivileged_userns_clone, 10);
|
|
130
|
-
|
|
142
|
+
const hit = v === 1;
|
|
143
|
+
signal_overrides["unpriv-userns-enabled"] = hit ? "hit" : "miss";
|
|
144
|
+
// FP[1]: CONFIG_USER_NS=y — the sysctl is live only when userns is
|
|
145
|
+
// compiled in. Attested when /boot/config confirms it. FP[0]
|
|
146
|
+
// (rootless-runtime exception + LSM enforcement) is operator
|
|
147
|
+
// judgement and stays unattested.
|
|
148
|
+
if (hit && kernelConfig && /^CONFIG_USER_NS=y$/m.test(kernelConfig)) {
|
|
149
|
+
signal_overrides["unpriv-userns-enabled__fp_checks"] = { "1": true };
|
|
150
|
+
}
|
|
131
151
|
}
|
|
132
152
|
// unpriv-bpf-allowed: bpf_disabled == 0 means unprivileged BPF
|
|
133
153
|
// is allowed (risky).
|
|
134
154
|
if (parsed.unprivileged_bpf_disabled != null) {
|
|
135
155
|
const v = parseInt(parsed.unprivileged_bpf_disabled, 10);
|
|
136
|
-
|
|
156
|
+
const hit = v === 0;
|
|
157
|
+
signal_overrides["unpriv-bpf-allowed"] = hit ? "hit" : "miss";
|
|
158
|
+
// FP[0]: CONFIG_BPF_SYSCALL=y AND CONFIG_BPF_JIT=y — the sysctl is
|
|
159
|
+
// moot if BPF is compiled out. Attested when /boot/config confirms
|
|
160
|
+
// both. FP[1] (enforcing LSM bpf() restriction) is operator
|
|
161
|
+
// judgement and stays unattested.
|
|
162
|
+
if (hit && kernelConfig &&
|
|
163
|
+
/^CONFIG_BPF_SYSCALL=y$/m.test(kernelConfig) &&
|
|
164
|
+
/^CONFIG_BPF_JIT=y$/m.test(kernelConfig)) {
|
|
165
|
+
signal_overrides["unpriv-bpf-allowed__fp_checks"] = { "0": true };
|
|
166
|
+
}
|
|
137
167
|
}
|
|
138
168
|
}
|
|
139
169
|
}
|
|
@@ -496,6 +496,36 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
496
496
|
signal_overrides["lockfile-missing-integrity"] = lockfileMissingIntegrity;
|
|
497
497
|
}
|
|
498
498
|
|
|
499
|
+
// __fp_checks attestation for publish-workflow-action-refs-mutable. Both
|
|
500
|
+
// false_positive_checks_required entries are deterministic from the repo:
|
|
501
|
+
// [0] Dependabot configured for github-actions on a weekly+ schedule
|
|
502
|
+
// demotes the finding — attest survival when no such config exists.
|
|
503
|
+
// [1] every mutable ref pointing to a github-owned action is lower risk —
|
|
504
|
+
// attest survival when at least one mutable ref is third-party.
|
|
505
|
+
if (signal_overrides["publish-workflow-action-refs-mutable"] === "hit") {
|
|
506
|
+
let dependabotActions = false;
|
|
507
|
+
try {
|
|
508
|
+
const dbContent =
|
|
509
|
+
readSafe(path.join(root, ".github", "dependabot.yml")) ||
|
|
510
|
+
readSafe(path.join(root, ".github", "dependabot.yaml")) || "";
|
|
511
|
+
dependabotActions = /package-ecosystem:\s*['"]?github-actions/i.test(dbContent) &&
|
|
512
|
+
/\binterval:\s*['"]?(?:daily|weekly)/i.test(dbContent);
|
|
513
|
+
} catch { /* no dependabot config */ }
|
|
514
|
+
const mutableRefSnippets = (workflowHits["publish-workflow-action-refs-mutable"] || []).map(h => h.snippet || "");
|
|
515
|
+
const refOf = (s) => {
|
|
516
|
+
const m = s.match(/uses:\s*['"]?([^'"\s]+)/);
|
|
517
|
+
return m ? m[1] : "";
|
|
518
|
+
};
|
|
519
|
+
const anyThirdParty = mutableRefSnippets.some(s => {
|
|
520
|
+
const r = refOf(s);
|
|
521
|
+
return r && !/^(?:actions|github)\//i.test(r);
|
|
522
|
+
});
|
|
523
|
+
const att = {};
|
|
524
|
+
if (!dependabotActions) att["0"] = true;
|
|
525
|
+
if (anyThirdParty) att["1"] = true;
|
|
526
|
+
if (Object.keys(att).length) signal_overrides["publish-workflow-action-refs-mutable__fp_checks"] = att;
|
|
527
|
+
}
|
|
528
|
+
|
|
499
529
|
// Per-indicator file locations for the publish-workflow indicators
|
|
500
530
|
// flipped to "hit", so a SARIF result points at the workflow file (and,
|
|
501
531
|
// for mutable action refs, the offending `uses:` line). The other
|
|
@@ -106,6 +106,21 @@ function isWorldWritable(p) {
|
|
|
106
106
|
} catch { return false; }
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Classify a world-writable hit against the two deterministic
|
|
110
|
+
// false_positive_checks_required entries for world-writable-in-trusted-path:
|
|
111
|
+
// [0] sticky-bit (1777-style) dirs/files intentionally permit per-user write
|
|
112
|
+
// [1] 0-byte stamp / unix-socket / FIFO documented for the application
|
|
113
|
+
// Returns { stickyBit, special } so the caller can both keep only genuine
|
|
114
|
+
// hits and attest exactly the checks it ran.
|
|
115
|
+
function classifyWorldWritable(p) {
|
|
116
|
+
try {
|
|
117
|
+
const s = fs.lstatSync(p);
|
|
118
|
+
const stickyBit = (s.mode & 0o1000) !== 0;
|
|
119
|
+
const special = s.isSocket() || s.isFIFO() || (s.isFile() && s.size === 0);
|
|
120
|
+
return { stickyBit, special };
|
|
121
|
+
} catch { return { stickyBit: false, special: false }; }
|
|
122
|
+
}
|
|
123
|
+
|
|
109
124
|
function readProcPid(pid, procRoot) {
|
|
110
125
|
// Returns { pid, ppid, uid, exe } or null.
|
|
111
126
|
try {
|
|
@@ -240,11 +255,22 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
240
255
|
const passwdContent = readFileSafe(P.passwd);
|
|
241
256
|
const uid0 = passwdContent ? parsePasswdUidZero(passwdContent) : null;
|
|
242
257
|
|
|
243
|
-
// World-writable files under trusted paths.
|
|
258
|
+
// World-writable files under trusted paths. Split into genuine hits
|
|
259
|
+
// (regular non-empty files without the sticky bit) and benign carriers
|
|
260
|
+
// the two false_positive_checks_required entries demote (sticky-bit
|
|
261
|
+
// per-user-write dirs; 0-byte stamps / sockets / FIFOs). Only the
|
|
262
|
+
// genuine hits flip the indicator; the split records which FP checks
|
|
263
|
+
// the collector deterministically ran.
|
|
244
264
|
const worldWritableFiles = [];
|
|
265
|
+
let sawStickyBitCarrier = false;
|
|
266
|
+
let sawSpecialCarrier = false;
|
|
245
267
|
for (const tp of P.trustedPaths) {
|
|
246
268
|
for (const f of walkShallow(tp, TRUSTED_PATH_MAX_DEPTH)) {
|
|
247
|
-
if (isWorldWritable(f))
|
|
269
|
+
if (!isWorldWritable(f)) continue;
|
|
270
|
+
const { stickyBit, special } = classifyWorldWritable(f);
|
|
271
|
+
if (stickyBit) { sawStickyBitCarrier = true; continue; }
|
|
272
|
+
if (special) { sawSpecialCarrier = true; continue; }
|
|
273
|
+
worldWritableFiles.push(f);
|
|
248
274
|
}
|
|
249
275
|
}
|
|
250
276
|
|
|
@@ -266,7 +292,16 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
266
292
|
try { fs.readdirSync(tp); return true; } catch { return false; }
|
|
267
293
|
});
|
|
268
294
|
if (anyTpReadable) {
|
|
269
|
-
|
|
295
|
+
const wwHit = worldWritableFiles.length > 0;
|
|
296
|
+
signal_overrides["world-writable-in-trusted-path"] = wwHit ? "hit" : "miss";
|
|
297
|
+
// Attest the false_positive_checks_required entries the collector
|
|
298
|
+
// ran against every flagged file: [0] sticky-bit carriers and [1]
|
|
299
|
+
// 0-byte/socket/FIFO carriers were both stat-inspected and excluded,
|
|
300
|
+
// so a surviving hit satisfies both. Without this attestation the
|
|
301
|
+
// runner downgrades a real world-writable hit to inconclusive.
|
|
302
|
+
if (wwHit) {
|
|
303
|
+
signal_overrides["world-writable-in-trusted-path__fp_checks"] = { "0": true, "1": true };
|
|
304
|
+
}
|
|
270
305
|
}
|
|
271
306
|
// orphan-privileged-process: only emit when /proc was walkable
|
|
272
307
|
// AND the scan had enough exe-link visibility to reach a verdict.
|
package/lib/collectors/sbom.js
CHANGED
|
@@ -354,6 +354,12 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
354
354
|
const j = JSON.parse(fs.readFileSync(npmLockfile.path, "utf8"));
|
|
355
355
|
let withIntegrity = 0;
|
|
356
356
|
let withoutIntegrity = 0;
|
|
357
|
+
// Track whether any integrity-less entry is a local-path / workspace /
|
|
358
|
+
// git ref. lockfile-no-integrity FP[0] demotes those — they legitimately
|
|
359
|
+
// have no registry integrity hash. A remote-registry tarball without
|
|
360
|
+
// integrity is the genuine finding.
|
|
361
|
+
let withoutIntegrityLocalOnly = true;
|
|
362
|
+
const LOCAL_REF_RE = /^(?:file:|link:|workspace:|git\+ssh:|git\+https:|git:|github:|portal:)/i;
|
|
357
363
|
const walk = (obj) => {
|
|
358
364
|
if (!obj || typeof obj !== "object") return;
|
|
359
365
|
// Only remote-tarball entries (those with a `resolved` URL) are
|
|
@@ -362,8 +368,12 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
362
368
|
// `integrity`, so keying off `version` would false-positive on
|
|
363
369
|
// every clean lockfile. Mirror library-author.js's guard.
|
|
364
370
|
if (obj.resolved != null) {
|
|
365
|
-
if (obj.integrity != null)
|
|
366
|
-
|
|
371
|
+
if (obj.integrity != null) {
|
|
372
|
+
withIntegrity++;
|
|
373
|
+
} else {
|
|
374
|
+
withoutIntegrity++;
|
|
375
|
+
if (!LOCAL_REF_RE.test(String(obj.resolved))) withoutIntegrityLocalOnly = false;
|
|
376
|
+
}
|
|
367
377
|
}
|
|
368
378
|
for (const v of Object.values(obj)) if (v && typeof v === "object") walk(v);
|
|
369
379
|
};
|
|
@@ -373,6 +383,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
373
383
|
// class, not full coverage.
|
|
374
384
|
if (withoutIntegrity > 0) {
|
|
375
385
|
signal_overrides["lockfile-no-integrity"] = "hit";
|
|
386
|
+
// __fp_checks attestation. [0]: at least one integrity-less entry is a
|
|
387
|
+
// remote-registry tarball (not exclusively local-path/workspace/git
|
|
388
|
+
// refs). [1]: the lockfile is the canonical root package-lock.json the
|
|
389
|
+
// build consumes, not a stale copy under archive/ pre-migration/.
|
|
390
|
+
const att = {};
|
|
391
|
+
if (!withoutIntegrityLocalOnly) att["0"] = true;
|
|
392
|
+
const rel = (npmLockfile.path || "").replace(/\\/g, "/");
|
|
393
|
+
if (!/\/(?:archive|pre-migration|old|backup|legacy)\//i.test(rel)) att["1"] = true;
|
|
394
|
+
if (Object.keys(att).length) signal_overrides["lockfile-no-integrity__fp_checks"] = att;
|
|
376
395
|
} else if (withIntegrity > 0) {
|
|
377
396
|
signal_overrides["lockfile-no-integrity"] = "miss";
|
|
378
397
|
}
|
|
@@ -148,7 +148,10 @@ function walkTree(root, opts = {}) {
|
|
|
148
148
|
// `seen` check is needed. This is the hot path — it runs once per
|
|
149
149
|
// file in the tree and is now syscall-free beyond the parent
|
|
150
150
|
// readdir.
|
|
151
|
-
|
|
151
|
+
// Emit forward-slash rel paths on every platform so artifact
|
|
152
|
+
// summaries match the SARIF evidence_locations (which normalize the
|
|
153
|
+
// same way) — on Windows path.relative returns backslash separators.
|
|
154
|
+
out.push({ full, rel: path.relative(root, full).split(path.sep).join("/"), name: entry.name });
|
|
152
155
|
}
|
|
153
156
|
}
|
|
154
157
|
}
|
|
@@ -112,6 +112,98 @@ const AWS_EXAMPLE_ACCESS_KEY_IDS = new Set([
|
|
|
112
112
|
"AKIAIOSFODNN7EXAMPLE",
|
|
113
113
|
]);
|
|
114
114
|
|
|
115
|
+
// AWS-published sample secret-access-key (paired with AKIAIOSFODNN7EXAMPLE
|
|
116
|
+
// throughout the AWS docs). aws-secret-access-key false_positive_checks_required[1]
|
|
117
|
+
// demotes this exact value.
|
|
118
|
+
const AWS_EXAMPLE_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
|
|
119
|
+
|
|
120
|
+
// Placeholder/fixture substrings the API-key indicators' FP[0] checks demote.
|
|
121
|
+
// A literal `PLACEHOLDER` / `EXAMPLE` / `XXXX` run / a `dummy`/`test` infix
|
|
122
|
+
// marks documentation material rather than a live credential.
|
|
123
|
+
const PLACEHOLDER_RE = /placeholder|example|redacted|dummy|x{4,}|0{6,}|1234567890/i;
|
|
124
|
+
|
|
125
|
+
// Path segments the per-indicator FP path checks treat as documentation /
|
|
126
|
+
// fixture material in addition to TEST_PATH_SEGMENTS (which already covers
|
|
127
|
+
// /examples/, /fixtures/, /test/ etc.). The secret indicators' FP prose adds
|
|
128
|
+
// /docs/ and quickstart/snippet paths.
|
|
129
|
+
const DOC_PATH_SEGMENTS = [
|
|
130
|
+
"/docs/", "/doc/", "/sdk-quickstart/", "/quickstart/", "/docs-snippet/",
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
function isDocOrTestPath(rel) {
|
|
134
|
+
if (isTestPath(rel)) return true;
|
|
135
|
+
const norm = "/" + rel.replace(/\\/g, "/").toLowerCase() + "/";
|
|
136
|
+
return DOC_PATH_SEGMENTS.some((seg) => norm.includes(seg));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Per-indicator deterministic false_positive_checks_required evaluation.
|
|
140
|
+
// Returns the set of FP-check indices (as strings) the collector can attest
|
|
141
|
+
// for a single hit — i.e. the checks it actually ran and that the hit
|
|
142
|
+
// survives. Indices requiring network reachability or operator judgement are
|
|
143
|
+
// deliberately omitted so the runner honestly downgrades to inconclusive.
|
|
144
|
+
// `value` is the matched credential, `file` the relative path, `window` a
|
|
145
|
+
// few-line context slice around the match.
|
|
146
|
+
function fpIndicesSatisfied(indicatorId, value, file, window) {
|
|
147
|
+
const sat = new Set();
|
|
148
|
+
const notDocPath = !isDocOrTestPath(file);
|
|
149
|
+
switch (indicatorId) {
|
|
150
|
+
case "aws-secret-access-key": {
|
|
151
|
+
// [0] co-occurrence with an AKIA*/ASIA*/AGPA*/AIDA* id in a 10-line window
|
|
152
|
+
if (/\b(?:AKIA|ASIA|AGPA|AIDA)[0-9A-Z]{12,}\b/.test(window)) sat.add("0");
|
|
153
|
+
// [1] not the AWS-published sample secret
|
|
154
|
+
if (value !== AWS_EXAMPLE_SECRET_KEY) sat.add("1");
|
|
155
|
+
// [2] not under examples/ docs/ fixtures/ / a test snapshot
|
|
156
|
+
if (notDocPath) sat.add("2");
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case "slack-bot-or-user-token": {
|
|
160
|
+
// [0] not a placeholder / published doc fixture
|
|
161
|
+
if (!PLACEHOLDER_RE.test(value)) sat.add("0");
|
|
162
|
+
// [1] conforms to a current Slack token shape: at least three
|
|
163
|
+
// dash-separated segments after the xox? prefix
|
|
164
|
+
if (value.split("-").length >= 4) sat.add("1");
|
|
165
|
+
// [2] not under examples/ docs/ fixtures/
|
|
166
|
+
if (notDocPath) sat.add("2");
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case "stripe-secret-key": {
|
|
170
|
+
// [0] not a sk_test_ published sample (deterministic only for the
|
|
171
|
+
// test prefix; live keys are handled by [2])
|
|
172
|
+
if (!(value.startsWith("sk_test_") && PLACEHOLDER_RE.test(value))) sat.add("0");
|
|
173
|
+
// [1] not under examples/ fixtures/ docs/ / a quickstart template
|
|
174
|
+
if (notDocPath) sat.add("1");
|
|
175
|
+
// [2] the live-validity probe applies only to sk_live_*; a sk_test_
|
|
176
|
+
// key carries no live financial exposure, so the check is moot and
|
|
177
|
+
// the collector can attest it deterministically. sk_live_* still
|
|
178
|
+
// needs operator-authorised network validation — left unattested.
|
|
179
|
+
if (value.startsWith("sk_test_") || value.startsWith("rk_test_")) sat.add("2");
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case "openai-api-key": {
|
|
183
|
+
// [0] not a placeholder / sk-test- / sk-dummy- fixture
|
|
184
|
+
if (!PLACEHOLDER_RE.test(value) && !/^sk-(?:test|dummy)-/i.test(value)) sat.add("0");
|
|
185
|
+
// [1] post-prefix length meets the entropy floor (>= 48 chars)
|
|
186
|
+
if (value.replace(/^sk-(?:proj-|svcacct-|admin-)?/, "").length >= 48) sat.add("1");
|
|
187
|
+
// [2] vendor disambiguation — sk-ant-* is Anthropic, not OpenAI
|
|
188
|
+
if (!/^sk-ant-/i.test(value)) sat.add("2");
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case "anthropic-api-key": {
|
|
192
|
+
// [0] not a placeholder / sk-ant-test- fixture
|
|
193
|
+
if (!PLACEHOLDER_RE.test(value) && !/^sk-ant-test-/i.test(value)) sat.add("0");
|
|
194
|
+
// [1] not under examples/ fixtures/ sdk-quickstart/ docs-snippet/
|
|
195
|
+
if (notDocPath) sat.add("1");
|
|
196
|
+
// [2] post-prefix length meets the entropy floor (>= 80 chars after
|
|
197
|
+
// sk-ant-(api03|admin01)-)
|
|
198
|
+
if (value.replace(/^sk-ant-(?:api03|admin01)-/, "").length >= 80) sat.add("2");
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
default:
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
return sat;
|
|
205
|
+
}
|
|
206
|
+
|
|
115
207
|
const INDICATOR_PATTERNS = [
|
|
116
208
|
{ id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
117
209
|
{ id: "aws-secret-access-key", re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi },
|
|
@@ -209,6 +301,13 @@ function scanContent(full, rel) {
|
|
|
209
301
|
// Demote AWS-published example access-key IDs (e.g. the docs' canonical
|
|
210
302
|
// AKIAIOSFODNN7EXAMPLE). A README quoting the AWS docs must not hit.
|
|
211
303
|
if (p.id === "aws-access-key-id" && AWS_EXAMPLE_ACCESS_KEY_IDS.has(m[0])) continue;
|
|
304
|
+
// The captured credential is group 1 when the pattern brackets it
|
|
305
|
+
// (aws_secret_access_key=<value>); otherwise the whole match.
|
|
306
|
+
const value = m[1] != null ? m[1] : m[0];
|
|
307
|
+
// ±600-byte context window (a deterministic proxy for "nearby lines")
|
|
308
|
+
// used by the co-occurrence FP check.
|
|
309
|
+
const winStart = Math.max(0, m.index - 600);
|
|
310
|
+
const window = buf.slice(winStart, m.index + 600);
|
|
212
311
|
hits.push({
|
|
213
312
|
indicator_id: p.id,
|
|
214
313
|
file: rel,
|
|
@@ -217,6 +316,9 @@ function scanContent(full, rel) {
|
|
|
217
316
|
// (SARIF startLine) instead of a bare file-level location.
|
|
218
317
|
line: lineFromOffset(buf, m.index),
|
|
219
318
|
redacted_match: redactMatch(m[0]),
|
|
319
|
+
// The false_positive_checks_required indices this hit deterministically
|
|
320
|
+
// survives — attested so the runner doesn't downgrade hit → inconclusive.
|
|
321
|
+
fp_satisfied: fpIndicesSatisfied(p.id, value, rel, window),
|
|
220
322
|
});
|
|
221
323
|
if (++count >= 5) break; // cap per-indicator-per-file
|
|
222
324
|
}
|
|
@@ -310,6 +412,29 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
310
412
|
for (const p of INDICATOR_PATTERNS) {
|
|
311
413
|
signal_overrides[p.id] = prodHitsByIndicator[p.id] && prodHitsByIndicator[p.id].length > 0 ? "hit" : "miss";
|
|
312
414
|
}
|
|
415
|
+
// Per-indicator __fp_checks attestation. For each FP-gated indicator that
|
|
416
|
+
// fired, attest the false_positive_checks_required indices the collector
|
|
417
|
+
// deterministically ran AND that EVERY surviving hit satisfies (the
|
|
418
|
+
// intersection — an index is only universally true if no hit fails it).
|
|
419
|
+
// Network / operator-judgement indices are never in the set, so the runner
|
|
420
|
+
// still downgrades indicators that carry one. Without this, a real secret
|
|
421
|
+
// surfaced by `collect` is downgraded to inconclusive after `run`.
|
|
422
|
+
for (const p of INDICATOR_PATTERNS) {
|
|
423
|
+
if (signal_overrides[p.id] !== "hit") continue;
|
|
424
|
+
const hits = prodHitsByIndicator[p.id] || [];
|
|
425
|
+
if (!hits.length || !hits[0].fp_satisfied) continue;
|
|
426
|
+
let common = null;
|
|
427
|
+
for (const h of hits) {
|
|
428
|
+
const s = h.fp_satisfied || new Set();
|
|
429
|
+
if (common === null) { common = new Set(s); continue; }
|
|
430
|
+
for (const idx of [...common]) if (!s.has(idx)) common.delete(idx);
|
|
431
|
+
}
|
|
432
|
+
if (common && common.size) {
|
|
433
|
+
const att = {};
|
|
434
|
+
for (const idx of common) att[idx] = true;
|
|
435
|
+
signal_overrides[`${p.id}__fp_checks`] = att;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
313
438
|
// ssh-private-key-block is also flipped by file presence (a private
|
|
314
439
|
// key file with the matching magic bytes counts even without a
|
|
315
440
|
// content scan match — e.g. binary-only key formats). Re-flip when
|
package/lib/cve-cli.js
CHANGED
|
@@ -69,4 +69,12 @@ const { resolveCve } = require("./citation-resolve.js");
|
|
|
69
69
|
if (fails) {
|
|
70
70
|
process.exitCode = 2;
|
|
71
71
|
}
|
|
72
|
-
})()
|
|
72
|
+
})().catch((err) => {
|
|
73
|
+
// A corrupt/unreadable catalog (or any unexpected throw inside the async
|
|
74
|
+
// body) becomes a rejected promise. Emit the same single-line
|
|
75
|
+
// {ok:false,error} envelope the verb promises rather than crashing with a
|
|
76
|
+
// raw stack trace, and signal failure via exitCode so the event loop drains
|
|
77
|
+
// stderr before exit.
|
|
78
|
+
process.stderr.write(JSON.stringify({ ok: false, verb: "cve", error: String((err && err.message) || err) }) + "\n");
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
});
|