@blamejs/exceptd-skills 0.19.32 → 0.19.34
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/CHANGELOG.md +22 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +7 -7
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +9991 -3
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8330 -1
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +2 -2
- package/sbom.cdx.json +253 -253
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
package/lib/collectors/mcp.js
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Companion collector for the `mcp` playbook. Reads MCP client
|
|
7
|
-
* config files (Cursor, Claude Code, Windsurf, VS Code Copilot,
|
|
8
|
-
* Gemini CLI) and tool-response logs, flipping deterministic
|
|
9
|
-
* indicators related to:
|
|
10
|
-
* - mcp-version-without-integrity (pinned version without
|
|
11
|
-
* sha256 / sri-integrity sibling)
|
|
12
|
-
* - copilot-yolo-mode-flag (chat.tools.autoApprove true)
|
|
13
|
-
* - mcp-response-ansi-escape (0x1B in tool response)
|
|
14
|
-
* - mcp-response-unicode-tag-smuggling (U+E0000..U+E007F)
|
|
15
|
-
*
|
|
16
|
-
* Defers:
|
|
17
|
-
* - unsigned-mcp-manifest (needs npm/pip package directory walk
|
|
18
|
-
* + sigstore lookup; out of stdlib scope)
|
|
19
|
-
* - vulnerable-windsurf-version (needs Windsurf install detection)
|
|
20
|
-
* - mcp-server-running-as-root (needs ps + capabilities)
|
|
21
|
-
* - mcp-server-invoked-from-ci-pipeline (needs process-tree
|
|
22
|
-
* env-var inspection)
|
|
4
|
+
* Companion collector for the `mcp` playbook: reads MCP client configs and
|
|
5
|
+
* tool-response logs, flipping only the indicators decidable from those.
|
|
23
6
|
*
|
|
24
7
|
* Interface: see lib/collectors/README.md
|
|
25
8
|
*/
|
|
@@ -36,10 +19,8 @@ function readSafe(full, max = 1024 * 1024) {
|
|
|
36
19
|
fd = fs.openSync(full, "r");
|
|
37
20
|
const s = fs.fstatSync(fd);
|
|
38
21
|
if (s.size > max) return null;
|
|
39
|
-
// readFileSync(fd) loops read() to EOF
|
|
40
|
-
//
|
|
41
|
-
// leave the buffer tail NUL-filled and silently drop trailing content.
|
|
42
|
-
// Reading via the already-open fd keeps the fstat-then-read TOCTOU-free.
|
|
22
|
+
// readFileSync(fd) loops read() to EOF, where one readSync can return short on
|
|
23
|
+
// a network/FUSE fd and NUL-fill the tail; via the fd it is also TOCTOU-free.
|
|
43
24
|
return fs.readFileSync(fd, "utf8");
|
|
44
25
|
} catch { return null; }
|
|
45
26
|
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
|
|
@@ -55,9 +36,8 @@ function fileExists(full) {
|
|
|
55
36
|
try { return fs.statSync(full).isFile(); } catch { return false; }
|
|
56
37
|
}
|
|
57
38
|
|
|
58
|
-
// Per-vendor config locations
|
|
59
|
-
//
|
|
60
|
-
// collector actually exercised.
|
|
39
|
+
// Per-vendor config locations; presence is recorded so the artifact shows the
|
|
40
|
+
// scope the collector actually exercised.
|
|
61
41
|
function vendorConfigPaths(home) {
|
|
62
42
|
return {
|
|
63
43
|
cursor: [path.join(home, ".cursor", "mcp.json")],
|
|
@@ -67,7 +47,6 @@ function vendorConfigPaths(home) {
|
|
|
67
47
|
],
|
|
68
48
|
windsurf: [path.join(home, ".codeium", "windsurf", "mcp_config.json")],
|
|
69
49
|
"vscode-copilot": [
|
|
70
|
-
// user-global locations
|
|
71
50
|
path.join(home, ".config", "Code", "User", "settings.json"),
|
|
72
51
|
path.join(home, "Library", "Application Support", "Code", "User", "settings.json"),
|
|
73
52
|
path.join(home, "AppData", "Roaming", "Code", "User", "settings.json"),
|
|
@@ -85,9 +64,8 @@ function collectMcpServers(configsByVendor) {
|
|
|
85
64
|
for (const f of files) {
|
|
86
65
|
const j = readJson(f);
|
|
87
66
|
if (!j) continue;
|
|
88
|
-
// Cursor / Claude Code / Windsurf
|
|
89
|
-
// VS Code Copilot
|
|
90
|
-
// Gemini: { mcpServers: { <name>: {...} } }
|
|
67
|
+
// Cursor / Claude Code / Windsurf / Gemini nest servers under
|
|
68
|
+
// `mcpServers`; VS Code Copilot under `chat.mcp.servers`, flat or nested.
|
|
91
69
|
const containers = [
|
|
92
70
|
j.mcpServers,
|
|
93
71
|
j["chat.mcp.servers"],
|
|
@@ -109,10 +87,8 @@ function collectMcpServers(configsByVendor) {
|
|
|
109
87
|
return out;
|
|
110
88
|
}
|
|
111
89
|
|
|
112
|
-
// A pinned version is the
|
|
113
|
-
//
|
|
114
|
-
// downstream operators care about: `npx -y @vendor/pkg@1.2.3`,
|
|
115
|
-
// `uvx pkg==1.2.3`, `pip install pkg==1.2.3`, etc.
|
|
90
|
+
// A pinned version is the `@1.2.3` / `==1.2.3` trailer on a package spec in
|
|
91
|
+
// args[] or command — `npx -y @vendor/pkg@1.2.3`, `uvx pkg==1.2.3`.
|
|
116
92
|
function isPinnedNoIntegrity(server) {
|
|
117
93
|
if (server.integrity) return false;
|
|
118
94
|
const tokens = [server.command, ...server.args].filter(Boolean);
|
|
@@ -120,20 +96,14 @@ function isPinnedNoIntegrity(server) {
|
|
|
120
96
|
if (typeof tok !== "string") continue;
|
|
121
97
|
// npm package@version (excluding scope name@version)
|
|
122
98
|
if (/@[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@\d+\.\d+\.\d+\b/.test(tok)) return true;
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
// `sh -c "pip install some-mcp==1.2.3"` carries the whole pip invocation in
|
|
126
|
-
// one token, so testing the token for `pip install` and `continue`-ing here
|
|
127
|
-
// skipped the `==version` check below for exactly the shape the comment
|
|
128
|
-
// says operators care about. A bare `pip install requests` (no `==`) can't
|
|
129
|
-
// match the pin regex, so checking every token is safe.
|
|
99
|
+
// The pip / uvx analog. Every token is tested rather than pre-filtering on
|
|
100
|
+
// `pip install`, since `sh -c "pip install some-mcp==1.2.3"` is one token.
|
|
130
101
|
if (/==\d+\.\d+\.\d+/.test(tok)) return true;
|
|
131
102
|
}
|
|
132
103
|
return false;
|
|
133
104
|
}
|
|
134
105
|
|
|
135
|
-
// VS Code yolo
|
|
136
|
-
// per-tool autoApprove === true.
|
|
106
|
+
// VS Code yolo mode: chat.tools.autoApprove, or any per-tool autoApprove.
|
|
137
107
|
function hasYoloMode(j) {
|
|
138
108
|
if (!j || typeof j !== "object") return false;
|
|
139
109
|
if (j["chat.tools.autoApprove"] === true) return true;
|
|
@@ -153,9 +123,8 @@ function hasAnsiEscape(content) {
|
|
|
153
123
|
return content.indexOf("\x1b") !== -1;
|
|
154
124
|
}
|
|
155
125
|
|
|
156
|
-
// Unicode tag smuggling:
|
|
157
|
-
//
|
|
158
|
-
// payloads through MCP responses.
|
|
126
|
+
// Unicode tag smuggling: U+E0000..U+E007F are invisible in a normal render but
|
|
127
|
+
// carry instruction-coercion payloads through MCP responses.
|
|
159
128
|
function hasUnicodeTagSmuggling(content) {
|
|
160
129
|
for (let i = 0; i < content.length; i++) {
|
|
161
130
|
const code = content.codePointAt(i);
|
|
@@ -167,11 +136,8 @@ function hasUnicodeTagSmuggling(content) {
|
|
|
167
136
|
}
|
|
168
137
|
|
|
169
138
|
function findMcpLogs(home) {
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
// already MCP-scoped by path; we accept every log file inside.
|
|
173
|
-
// Generic log directories (~/.cursor/logs/) are filtered to
|
|
174
|
-
// filenames containing "mcp".
|
|
139
|
+
// A path already MCP-scoped (~/.claude/logs/mcp/) accepts every log file
|
|
140
|
+
// inside; a generic one (~/.cursor/logs/) is filtered to names holding "mcp".
|
|
175
141
|
const dirs = [
|
|
176
142
|
{ path: path.join(home, ".claude", "logs", "mcp"), filterByName: false },
|
|
177
143
|
{ path: path.join(home, ".cursor", "logs"), filterByName: true },
|
|
@@ -197,26 +163,22 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
197
163
|
const root = path.resolve(cwd);
|
|
198
164
|
const home = (env && env.HOME) || (env && env.USERPROFILE) || os.homedir();
|
|
199
165
|
|
|
200
|
-
// Vendor MCP configs.
|
|
201
166
|
const configsByVendor = vendorConfigPaths(home);
|
|
202
167
|
const servers = collectMcpServers(configsByVendor);
|
|
203
168
|
const pinnedNoIntegrity = servers.filter(isPinnedNoIntegrity);
|
|
204
169
|
|
|
205
|
-
// VS Code yolo-mode across the candidate VS Code settings files.
|
|
206
170
|
let yoloHit = false;
|
|
207
171
|
let yoloFile = null;
|
|
208
172
|
for (const f of configsByVendor["vscode-copilot"]) {
|
|
209
173
|
const j = readJson(f);
|
|
210
174
|
if (j && hasYoloMode(j)) { yoloHit = true; yoloFile = f; break; }
|
|
211
175
|
}
|
|
212
|
-
// Project-level .vscode/settings.json under cwd
|
|
213
176
|
if (!yoloHit) {
|
|
214
177
|
const projVsc = path.join(root, ".vscode", "settings.json");
|
|
215
178
|
const j = readJson(projVsc);
|
|
216
179
|
if (j && hasYoloMode(j)) { yoloHit = true; yoloFile = projVsc; }
|
|
217
180
|
}
|
|
218
181
|
|
|
219
|
-
// MCP response log scan for ANSI / unicode-tag smuggling.
|
|
220
182
|
const logFiles = findMcpLogs(home);
|
|
221
183
|
let ansiHit = false;
|
|
222
184
|
let unicodeTagHit = false;
|
|
@@ -239,10 +201,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
239
201
|
"mcp-version-without-integrity": pinnedNoIntegrity.length > 0 ? "hit" : "miss",
|
|
240
202
|
"copilot-yolo-mode-flag": yoloHit ? "hit" : "miss",
|
|
241
203
|
};
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// indicators unflipped (the operator may not have enabled MCP
|
|
245
|
-
// logging — the runner returns inconclusive).
|
|
204
|
+
// Emitted only when a log file was actually scanned: with none, the indicators
|
|
205
|
+
// stay unflipped and the runner returns inconclusive.
|
|
246
206
|
if (logsScanned) {
|
|
247
207
|
signal_overrides["mcp-response-ansi-escape"] = ansiHit ? "hit" : "miss";
|
|
248
208
|
signal_overrides["mcp-response-unicode-tag-smuggling"] = unicodeTagHit ? "hit" : "miss";
|
|
@@ -298,17 +258,11 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
298
258
|
},
|
|
299
259
|
};
|
|
300
260
|
|
|
301
|
-
// Attest any-ai-coding-assistant-installed
|
|
302
|
-
// client is confirmed present
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
// that clearly has a client surfaces a spurious precondition_unverified.
|
|
307
|
-
// Crucially we NEVER submit false: the gate is skip_phase, so a false on a
|
|
308
|
-
// config-file-only absence would wrongly skip the detect phase even when an
|
|
309
|
-
// installed client directory or a project-level signal (e.g. a .vscode
|
|
310
|
-
// Copilot config) exists. When presence can't be confirmed, leave the gate
|
|
311
|
-
// unsubmitted and let the host-side resolver decide.
|
|
261
|
+
// Attest the any-ai-coding-assistant-installed skip_phase gate ONLY when a
|
|
262
|
+
// client is confirmed present; unconfirmed leaves it for the host-side
|
|
263
|
+
// resolver. Never submit false: the gate is skip_phase, so a false on a
|
|
264
|
+
// config-file absence would skip detect on a host that has an installed client
|
|
265
|
+
// directory or a project-level Copilot config.
|
|
312
266
|
const assistantInstallDirs = [
|
|
313
267
|
".cursor",
|
|
314
268
|
path.join(".codeium", "windsurf"),
|
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* walks /etc/sudoers + /etc/sudoers.d/*, parses /etc/passwd for
|
|
8
|
-
* duplicate UID 0 entries, scans well-known trusted-path
|
|
9
|
-
* directories for world-writable files, and inspects /proc/<pid>
|
|
10
|
-
* for orphan-privileged processes. Defers non-deterministic
|
|
11
|
-
* indicators (non-baseline-suid, listening-socket-unknown-bind,
|
|
12
|
-
* cron-or-timer-outside-policy) so the runner returns inconclusive.
|
|
13
|
-
*
|
|
14
|
-
* Interface: see lib/collectors/README.md
|
|
4
|
+
* Companion collector for the `runtime` playbook. Linux-only, deterministic
|
|
5
|
+
* indicators only — anything it cannot settle stays unflipped for the runner to
|
|
6
|
+
* call inconclusive. Interface: see lib/collectors/README.md
|
|
15
7
|
*/
|
|
16
8
|
|
|
17
9
|
const fs = require("node:fs");
|
|
@@ -25,10 +17,8 @@ function readFileSafe(p, max = 512 * 1024) {
|
|
|
25
17
|
fd = fs.openSync(p, "r");
|
|
26
18
|
const s = fs.fstatSync(fd);
|
|
27
19
|
if (s.size > max) return null;
|
|
28
|
-
// readFileSync(fd) loops
|
|
29
|
-
//
|
|
30
|
-
// leave the buffer tail NUL-filled and silently drop trailing content.
|
|
31
|
-
// Reading via the already-open fd keeps the fstat-then-read TOCTOU-free.
|
|
20
|
+
// readFileSync(fd) loops to EOF; a single readSync can short-read a network
|
|
21
|
+
// fd. Reading through the open fd also keeps the fstat-then-read TOCTOU-free.
|
|
32
22
|
return fs.readFileSync(fd, "utf8");
|
|
33
23
|
} catch { return null; }
|
|
34
24
|
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
|
|
@@ -36,10 +26,7 @@ function readFileSafe(p, max = 512 * 1024) {
|
|
|
36
26
|
|
|
37
27
|
const NOPASSWD_WILDCARD_RE = /\bNOPASSWD:\s*(?:ALL|\/[^,\n]*\*)/;
|
|
38
28
|
|
|
39
|
-
//
|
|
40
|
-
// playbook explicitly lists /etc, /usr/local/bin, /usr/local/sbin,
|
|
41
|
-
// /opt. We add /usr/bin and /usr/sbin as common variants but stay
|
|
42
|
-
// shallow (depth 2) so we don't walk arbitrarily deep filesystems.
|
|
29
|
+
// World-writable scan roots; the depth cap keeps the walk off deep filesystems.
|
|
43
30
|
const TRUSTED_PATHS = ["/etc", "/usr/local/bin", "/usr/local/sbin", "/opt", "/usr/bin", "/usr/sbin"];
|
|
44
31
|
const TRUSTED_PATH_MAX_DEPTH = 2;
|
|
45
32
|
|
|
@@ -56,11 +43,8 @@ function parseSudoersForWildcards(content) {
|
|
|
56
43
|
const line = raw.replace(/#.*$/, "").trim();
|
|
57
44
|
if (!line) continue;
|
|
58
45
|
if (!NOPASSWD_WILDCARD_RE.test(line)) continue;
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// Skip ONLY when the user list is exactly `root` (no other
|
|
62
|
-
// principals). A line like `root,deploy ALL=(ALL) NOPASSWD: ALL`
|
|
63
|
-
// still grants wildcard sudo to `deploy` and counts as a hit.
|
|
46
|
+
// Skip only when the principal list is exactly `root` — `root,deploy ALL=(ALL)
|
|
47
|
+
// NOPASSWD: ALL` still grants wildcard sudo to deploy and counts as a hit.
|
|
64
48
|
const userListMatch = line.match(/^([^\s]+)\s/);
|
|
65
49
|
if (userListMatch) {
|
|
66
50
|
const principals = userListMatch[1].split(",").map(s => s.trim()).filter(Boolean);
|
|
@@ -113,12 +97,8 @@ function isWorldWritable(p) {
|
|
|
113
97
|
} catch { return false; }
|
|
114
98
|
}
|
|
115
99
|
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// [0] sticky-bit (1777-style) dirs/files intentionally permit per-user write
|
|
119
|
-
// [1] 0-byte stamp / unix-socket / FIFO documented for the application
|
|
120
|
-
// Returns { stickyBit, special } so the caller can both keep only genuine
|
|
121
|
-
// hits and attest exactly the checks it ran.
|
|
100
|
+
// The two deterministic FP checks on world-writable-in-trusted-path: [0] sticky-bit
|
|
101
|
+
// per-user-write, [1] 0-byte stamp / socket / FIFO. The caller drops carriers.
|
|
122
102
|
function classifyWorldWritable(p) {
|
|
123
103
|
try {
|
|
124
104
|
const s = fs.lstatSync(p);
|
|
@@ -150,33 +130,21 @@ function readProcPid(pid, procRoot) {
|
|
|
150
130
|
}
|
|
151
131
|
|
|
152
132
|
function scanOrphanPrivileged(procRoot) {
|
|
153
|
-
// Returns { hits, exeReadable }. exeReadable
|
|
154
|
-
//
|
|
155
|
-
// /proc/<pid>/exe symlink AND PID 1's exe was also unreadable —
|
|
156
|
-
// in that case the indicator MUST stay unflipped (the caller
|
|
157
|
-
// converts that to inconclusive). hidepid / ptrace-restrict /
|
|
158
|
-
// non-root scope all collapse exe readability to zero, and
|
|
159
|
-
// emitting "miss" there would mask real privileged orphans.
|
|
133
|
+
// Returns { hits, exeReadable }. exeReadable false means no exe-link visibility
|
|
134
|
+
// and the caller must leave the indicator unflipped — a "miss" masks real orphans.
|
|
160
135
|
let entries;
|
|
161
136
|
try { entries = fs.readdirSync(procRoot); } catch { return { hits: [], exeReadable: false }; }
|
|
162
137
|
const hits = [];
|
|
163
138
|
|
|
164
|
-
// PID 1's exe is the canonical
|
|
165
|
-
// can't tell whether the parent is canonical init or a hijacked
|
|
166
|
-
// PID 1. That's an inconclusive condition.
|
|
139
|
+
// PID 1's exe is the anchor: unreadable, canonical init and a hijacked PID 1 look alike.
|
|
167
140
|
const initProc = readProcPid(1, procRoot);
|
|
168
141
|
const initExeReadable = !!(initProc && initProc.exe);
|
|
169
142
|
if (!initExeReadable) return { hits: [], exeReadable: false };
|
|
170
143
|
if (!PARENT_INIT_BINARIES.has(initProc.exe)) {
|
|
171
|
-
//
|
|
172
|
-
// as inconclusive — the playbook predicate explicitly says
|
|
173
|
-
// "parent != systemd/init" is the orphan condition, but
|
|
174
|
-
// distinguishing a legitimate non-systemd init (e.g. SysV,
|
|
175
|
-
// BusyBox init) from a hijacked PID 1 needs operator review.
|
|
144
|
+
// Not a canonical init: SysV / BusyBox vs a hijacked PID 1 needs operator review.
|
|
176
145
|
return { hits: [], exeReadable: false };
|
|
177
146
|
}
|
|
178
147
|
|
|
179
|
-
// PID 1's exe is canonical init. Now scan candidate PIDs.
|
|
180
148
|
let anyExeReadable = false;
|
|
181
149
|
let anyExeUnreadable = false;
|
|
182
150
|
for (const name of entries) {
|
|
@@ -194,12 +162,7 @@ function scanOrphanPrivileged(procRoot) {
|
|
|
194
162
|
anyExeUnreadable = true;
|
|
195
163
|
}
|
|
196
164
|
}
|
|
197
|
-
//
|
|
198
|
-
// hidepid scope), the indicator stays unflipped. If we read at
|
|
199
|
-
// least one and saw zero risky-path orphans, the miss is
|
|
200
|
-
// honest. If we saw mixed (some readable, some not), still
|
|
201
|
-
// report what we found — but only emit a verdict when at least
|
|
202
|
-
// one exe was readable so we have evidence to back it.
|
|
165
|
+
// A verdict needs at least one readable exe behind it; with none, it stays unflipped.
|
|
203
166
|
const exeReadable = anyExeReadable || (!anyExeUnreadable && entries.length > 0);
|
|
204
167
|
return { hits, exeReadable };
|
|
205
168
|
}
|
|
@@ -240,14 +203,12 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
240
203
|
};
|
|
241
204
|
}
|
|
242
205
|
|
|
243
|
-
// Sudo rules: /etc/sudoers + /etc/sudoers.d/*
|
|
244
206
|
const sudoersBase = readFileSafe(P.sudoers);
|
|
245
207
|
let sudoersContent = sudoersBase || "";
|
|
246
208
|
let sudoersReadable = sudoersBase != null;
|
|
247
209
|
try {
|
|
248
210
|
const dEntries = fs.readdirSync(P.sudoersD);
|
|
249
211
|
for (const e of dEntries) {
|
|
250
|
-
// skip editor backup files (~, .bak, .swp)
|
|
251
212
|
if (/[~]$/.test(e) || /\.(bak|swp|orig)$/.test(e)) continue;
|
|
252
213
|
const c = readFileSafe(path.join(P.sudoersD, e));
|
|
253
214
|
if (c != null) {
|
|
@@ -258,16 +219,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
258
219
|
} catch { /* .d not present */ }
|
|
259
220
|
const sudoersHits = parseSudoersForWildcards(sudoersContent);
|
|
260
221
|
|
|
261
|
-
// /etc/passwd UID-zero count
|
|
262
222
|
const passwdContent = readFileSafe(P.passwd);
|
|
263
223
|
const uid0 = passwdContent ? parsePasswdUidZero(passwdContent) : null;
|
|
264
224
|
|
|
265
|
-
//
|
|
266
|
-
// (regular non-empty files without the sticky bit) and benign carriers
|
|
267
|
-
// the two false_positive_checks_required entries demote (sticky-bit
|
|
268
|
-
// per-user-write dirs; 0-byte stamps / sockets / FIFOs). Only the
|
|
269
|
-
// genuine hits flip the indicator; the split records which FP checks
|
|
270
|
-
// the collector deterministically ran.
|
|
225
|
+
// Genuine hits are regular non-empty files without the sticky bit; carriers are demoted.
|
|
271
226
|
const worldWritableFiles = [];
|
|
272
227
|
let sawStickyBitCarrier = false;
|
|
273
228
|
let sawSpecialCarrier = false;
|
|
@@ -281,8 +236,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
281
236
|
}
|
|
282
237
|
}
|
|
283
238
|
|
|
284
|
-
// Orphan-privileged process scan. Only meaningful on a real
|
|
285
|
-
// /proc; on a synthetic tempdir we still walk it the same way.
|
|
286
239
|
const orphanScan = scanOrphanPrivileged(P.procRoot);
|
|
287
240
|
|
|
288
241
|
const signal_overrides = {};
|
|
@@ -292,29 +245,21 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
292
245
|
if (uid0 !== null) {
|
|
293
246
|
signal_overrides["duplicate-uid-zero"] = uid0.length > 1 ? "hit" : "miss";
|
|
294
247
|
}
|
|
295
|
-
//
|
|
296
|
-
// one trusted path was readable. If every TP was unreadable
|
|
297
|
-
// (chroot / restricted container), leave the indicator unflipped.
|
|
248
|
+
// A verdict needs one readable trusted path; an all-unreadable scope stays unflipped.
|
|
298
249
|
const anyTpReadable = P.trustedPaths.some(tp => {
|
|
299
250
|
try { fs.readdirSync(tp); return true; } catch { return false; }
|
|
300
251
|
});
|
|
301
252
|
if (anyTpReadable) {
|
|
302
253
|
const wwHit = worldWritableFiles.length > 0;
|
|
303
254
|
signal_overrides["world-writable-in-trusted-path"] = wwHit ? "hit" : "miss";
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
// 0-byte/socket/FIFO carriers were both stat-inspected and excluded,
|
|
307
|
-
// so a surviving hit satisfies both. Without this attestation the
|
|
308
|
-
// runner downgrades a real world-writable hit to inconclusive.
|
|
255
|
+
// Both FP checks ran against every flagged file, so a surviving hit satisfies
|
|
256
|
+
// them; without this attestation the runner downgrades the hit to inconclusive.
|
|
309
257
|
if (wwHit) {
|
|
310
258
|
signal_overrides["world-writable-in-trusted-path__fp_checks"] = { "0": true, "1": true };
|
|
311
259
|
}
|
|
312
260
|
}
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
// hidepid / ptrace-restrict / non-root scope leaves
|
|
316
|
-
// exeReadable=false; the indicator stays unflipped so the runner
|
|
317
|
-
// returns inconclusive (codex P1 #80).
|
|
261
|
+
// A verdict needs a walkable /proc and exe-link visibility; otherwise exeReadable
|
|
262
|
+
// is false and the indicator stays unflipped.
|
|
318
263
|
let procWalkable = false;
|
|
319
264
|
try { fs.readdirSync(P.procRoot); procWalkable = true; } catch { /* not present */ }
|
|
320
265
|
if (procWalkable && orphanScan.exeReadable) {
|
|
@@ -341,16 +286,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
341
286
|
return {
|
|
342
287
|
precondition_checks: {
|
|
343
288
|
"linux-platform": true,
|
|
344
|
-
// The collector
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
// inventory commands') is satisfied by construction once the collection
|
|
348
|
-
// reaches at least one inventory source. The runner cannot mechanically
|
|
349
|
-
// resolve `agent_has_command_exec == true`, so without this attestation
|
|
350
|
-
// the on_fail=halt preflight blocked the canonical `collect runtime | run
|
|
351
|
-
// runtime` pipe even with valid evidence — the sibling fs-only cred-stores
|
|
352
|
-
// collector attests its own halt precondition the same way. Gated on
|
|
353
|
-
// readability so a fully-masked /proc+/etc scope reports it false.
|
|
289
|
+
// The collector execs nothing, so `exec-allowed` holds by construction; the
|
|
290
|
+
// runner cannot resolve `agent_has_command_exec` mechanically and its
|
|
291
|
+
// on_fail=halt preflight would block `collect runtime | run runtime`.
|
|
354
292
|
"exec-allowed": Boolean(sudoersReadable || passwdContent != null || procWalkable || anyTpReadable),
|
|
355
293
|
},
|
|
356
294
|
artifacts,
|