@blamejs/exceptd-skills 0.13.33 → 0.13.35
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 +40 -0
- package/CHANGELOG.md +28 -0
- package/bin/exceptd.js +116 -1
- package/data/_indexes/_meta.json +2 -2
- package/lib/collectors/README.md +76 -0
- package/lib/collectors/containers.js +403 -0
- package/lib/collectors/kernel.js +159 -0
- package/lib/collectors/sbom.js +278 -0
- package/lib/collectors/secrets.js +323 -0
- package/lib/flag-suggest.js +2 -1
- package/manifest.json +44 -44
- package/package.json +1 -1
- package/sbom.cdx.json +93 -18
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/collectors/sbom.js
|
|
5
|
+
*
|
|
6
|
+
* Companion collector for the `sbom` playbook. Identifies the lockfile
|
|
7
|
+
* fingerprint of the cwd (npm / yarn / pnpm / pip / cargo / go / ruby /
|
|
8
|
+
* composer) so the runner can correlate against the SBOM-currency +
|
|
9
|
+
* supply-chain integrity indicators. Counts components per lockfile
|
|
10
|
+
* for a coarse SBOM-presence signal.
|
|
11
|
+
*
|
|
12
|
+
* Scope: any cwd with a recognizable lockfile. Multi-ecosystem repos
|
|
13
|
+
* report every detected lockfile.
|
|
14
|
+
*
|
|
15
|
+
* Interface: see lib/collectors/README.md
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require("node:fs");
|
|
19
|
+
const path = require("node:path");
|
|
20
|
+
|
|
21
|
+
const COLLECTOR_ID = "sbom";
|
|
22
|
+
|
|
23
|
+
// Lockfile fingerprints. Each entry: { file: <basename>, ecosystem,
|
|
24
|
+
// parser: <function(content) -> { component_count, top_level_count }> }.
|
|
25
|
+
const LOCKFILES = [
|
|
26
|
+
{
|
|
27
|
+
file: "package-lock.json",
|
|
28
|
+
ecosystem: "npm",
|
|
29
|
+
parser: (content) => {
|
|
30
|
+
try {
|
|
31
|
+
const j = JSON.parse(content);
|
|
32
|
+
const components = j.packages ? Object.keys(j.packages).filter(k => k !== "").length
|
|
33
|
+
: (j.dependencies ? Object.keys(j.dependencies).length : 0);
|
|
34
|
+
const topLevel = j.packages ? Object.keys(j.packages || {}).filter(k => /^node_modules\/[^/]+$/.test(k)).length
|
|
35
|
+
: (j.dependencies ? Object.keys(j.dependencies).length : 0);
|
|
36
|
+
return { component_count: components, top_level_count: topLevel, lockfile_version: j.lockfileVersion };
|
|
37
|
+
} catch (e) { return { error: e.message }; }
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
file: "yarn.lock",
|
|
42
|
+
ecosystem: "yarn",
|
|
43
|
+
parser: (content) => {
|
|
44
|
+
// yarn.lock isn't JSON. Coarse count: each block starts with `"<spec>":` at column 0.
|
|
45
|
+
const blocks = content.match(/^[^\s#].*:$/gm) || [];
|
|
46
|
+
return { component_count: blocks.length, top_level_count: null, lockfile_version: null };
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
file: "pnpm-lock.yaml",
|
|
51
|
+
ecosystem: "pnpm",
|
|
52
|
+
parser: (content) => {
|
|
53
|
+
const packages = (content.match(/^\s+\/[a-zA-Z0-9@_\-/.]+/gm) || []).length;
|
|
54
|
+
return { component_count: packages, top_level_count: null, lockfile_version: null };
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
file: "requirements.txt",
|
|
59
|
+
ecosystem: "pip",
|
|
60
|
+
parser: (content) => {
|
|
61
|
+
const lines = content.split(/\r?\n/).filter(l => l && !l.startsWith("#") && !l.startsWith("-"));
|
|
62
|
+
return { component_count: lines.length, top_level_count: lines.length, lockfile_version: null };
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
file: "Pipfile.lock",
|
|
67
|
+
ecosystem: "pipenv",
|
|
68
|
+
parser: (content) => {
|
|
69
|
+
try {
|
|
70
|
+
const j = JSON.parse(content);
|
|
71
|
+
const def = Object.keys(j.default || {}).length;
|
|
72
|
+
const dev = Object.keys(j.develop || {}).length;
|
|
73
|
+
return { component_count: def + dev, top_level_count: def, lockfile_version: j._meta?.["pipfile-spec"] };
|
|
74
|
+
} catch (e) { return { error: e.message }; }
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
file: "poetry.lock",
|
|
79
|
+
ecosystem: "poetry",
|
|
80
|
+
parser: (content) => {
|
|
81
|
+
const packages = (content.match(/^\[\[package\]\]/gm) || []).length;
|
|
82
|
+
return { component_count: packages, top_level_count: null, lockfile_version: null };
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
file: "Cargo.lock",
|
|
87
|
+
ecosystem: "cargo",
|
|
88
|
+
parser: (content) => {
|
|
89
|
+
const packages = (content.match(/^\[\[package\]\]/gm) || []).length;
|
|
90
|
+
return { component_count: packages, top_level_count: null, lockfile_version: null };
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
file: "go.sum",
|
|
95
|
+
ecosystem: "go",
|
|
96
|
+
parser: (content) => {
|
|
97
|
+
const modules = new Set(content.split(/\r?\n/).map(l => l.split(/\s+/)[0]).filter(Boolean));
|
|
98
|
+
return { component_count: modules.size, top_level_count: null, lockfile_version: null };
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
file: "Gemfile.lock",
|
|
103
|
+
ecosystem: "rubygems",
|
|
104
|
+
parser: (content) => {
|
|
105
|
+
const match = content.match(/GEM\s+remote:[\s\S]*?specs:([\s\S]*?)(?:\n\n|\nPLATFORMS)/);
|
|
106
|
+
if (!match) return { component_count: 0, top_level_count: null, lockfile_version: null };
|
|
107
|
+
const specs = match[1].split(/\r?\n/).filter(l => /^\s+[a-z0-9_-]+\s*\(/.test(l));
|
|
108
|
+
return { component_count: specs.length, top_level_count: null, lockfile_version: null };
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
file: "composer.lock",
|
|
113
|
+
ecosystem: "composer",
|
|
114
|
+
parser: (content) => {
|
|
115
|
+
try {
|
|
116
|
+
const j = JSON.parse(content);
|
|
117
|
+
return {
|
|
118
|
+
component_count: (j.packages || []).length + (j["packages-dev"] || []).length,
|
|
119
|
+
top_level_count: (j.packages || []).length,
|
|
120
|
+
lockfile_version: j["content-hash"] ? "content-hash" : null,
|
|
121
|
+
};
|
|
122
|
+
} catch (e) { return { error: e.message }; }
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const SBOM_FORMATS = [
|
|
128
|
+
{ file: "sbom.cdx.json", format: "cyclonedx-1.x" },
|
|
129
|
+
{ file: "bom.json", format: "cyclonedx-1.x" },
|
|
130
|
+
{ file: "sbom.json", format: "unknown" },
|
|
131
|
+
{ file: "sbom.spdx.json", format: "spdx-2.x" },
|
|
132
|
+
{ file: "sbom.cdx.xml", format: "cyclonedx-xml" },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
function findLockfiles(cwd) {
|
|
136
|
+
const found = [];
|
|
137
|
+
for (const lf of LOCKFILES) {
|
|
138
|
+
const p = path.join(cwd, lf.file);
|
|
139
|
+
if (fs.existsSync(p)) {
|
|
140
|
+
try {
|
|
141
|
+
const content = fs.readFileSync(p, "utf8");
|
|
142
|
+
const stats = lf.parser(content);
|
|
143
|
+
found.push({
|
|
144
|
+
file: lf.file,
|
|
145
|
+
ecosystem: lf.ecosystem,
|
|
146
|
+
path: p,
|
|
147
|
+
size_bytes: Buffer.byteLength(content, "utf8"),
|
|
148
|
+
...stats,
|
|
149
|
+
});
|
|
150
|
+
} catch (e) {
|
|
151
|
+
found.push({ file: lf.file, ecosystem: lf.ecosystem, path: p, error: e.message });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return found;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function findSbomDocuments(cwd) {
|
|
159
|
+
const found = [];
|
|
160
|
+
for (const s of SBOM_FORMATS) {
|
|
161
|
+
const p = path.join(cwd, s.file);
|
|
162
|
+
if (fs.existsSync(p)) {
|
|
163
|
+
try {
|
|
164
|
+
const stat = fs.statSync(p);
|
|
165
|
+
let content;
|
|
166
|
+
try { content = fs.readFileSync(p, "utf8"); } catch { content = null; }
|
|
167
|
+
let component_count = null;
|
|
168
|
+
if (content && s.format === "cyclonedx-1.x") {
|
|
169
|
+
try {
|
|
170
|
+
const j = JSON.parse(content);
|
|
171
|
+
component_count = (j.components || []).length;
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
found.push({ file: s.file, format: s.format, size_bytes: stat.size, component_count });
|
|
175
|
+
} catch (e) {
|
|
176
|
+
found.push({ file: s.file, format: s.format, error: e.message });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return found;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
184
|
+
const errors = [];
|
|
185
|
+
const startTime = Date.now();
|
|
186
|
+
const root = path.resolve(cwd);
|
|
187
|
+
|
|
188
|
+
// Precondition: sbom-tool-available — the runner / playbook treats
|
|
189
|
+
// this as "an operator has SOME way to produce an SBOM". For the
|
|
190
|
+
// collector, "we found a lockfile or an SBOM" is a sufficient proxy.
|
|
191
|
+
const lockfiles = findLockfiles(root);
|
|
192
|
+
const sbomDocuments = findSbomDocuments(root);
|
|
193
|
+
const hasAnything = lockfiles.length > 0 || sbomDocuments.length > 0;
|
|
194
|
+
|
|
195
|
+
const artifacts = {
|
|
196
|
+
"lockfile-inventory": {
|
|
197
|
+
value: lockfiles.length
|
|
198
|
+
? lockfiles.map(l => `${l.ecosystem}:${l.file} (${l.component_count ?? "?"} components${l.lockfile_version ? `, v${l.lockfile_version}` : ""})`).join("; ")
|
|
199
|
+
: "no lockfile found",
|
|
200
|
+
captured: true,
|
|
201
|
+
},
|
|
202
|
+
"sbom-document": {
|
|
203
|
+
value: sbomDocuments.length
|
|
204
|
+
? sbomDocuments.map(s => `${s.format}:${s.file} (${s.size_bytes} bytes${s.component_count != null ? `, ${s.component_count} components` : ""})`).join("; ")
|
|
205
|
+
: "no SBOM document at cwd root",
|
|
206
|
+
captured: true,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// The sbom playbook's detect indicators (package-matches-catalogued-cve,
|
|
211
|
+
// lockfile-no-integrity, transitive-deps-incomplete-sbom,
|
|
212
|
+
// matched-cve-without-vex, ai-code-no-provenance, ...) require
|
|
213
|
+
// catalog cross-referencing the collector does not have — that's
|
|
214
|
+
// the runner's job. The collector's role here is to surface the
|
|
215
|
+
// artifacts (lockfile-inventory + sbom-document) and let the
|
|
216
|
+
// runner evaluate the indicators against them. Emitting
|
|
217
|
+
// signal_overrides for keys that don't exist in the playbook
|
|
218
|
+
// would be silently ignored; surfacing the artifacts honestly
|
|
219
|
+
// is the contract.
|
|
220
|
+
//
|
|
221
|
+
// One indicator the collector CAN decide deterministically:
|
|
222
|
+
// lockfile-no-integrity — true when an npm package-lock.json
|
|
223
|
+
// exists but has zero `integrity` entries (lockfileVersion 1
|
|
224
|
+
// legacy) OR when the dependency list contains entries lacking
|
|
225
|
+
// `integrity` strings.
|
|
226
|
+
const npmLockfile = lockfiles.find(l => l.file === "package-lock.json");
|
|
227
|
+
const signal_overrides = {};
|
|
228
|
+
if (npmLockfile && !npmLockfile.error) {
|
|
229
|
+
try {
|
|
230
|
+
const j = JSON.parse(fs.readFileSync(npmLockfile.path, "utf8"));
|
|
231
|
+
let withIntegrity = 0;
|
|
232
|
+
let withoutIntegrity = 0;
|
|
233
|
+
const walk = (obj) => {
|
|
234
|
+
if (!obj || typeof obj !== "object") return;
|
|
235
|
+
if (obj.integrity != null) withIntegrity++;
|
|
236
|
+
else if (obj.resolved != null || obj.version != null) withoutIntegrity++;
|
|
237
|
+
for (const v of Object.values(obj)) if (v && typeof v === "object") walk(v);
|
|
238
|
+
};
|
|
239
|
+
walk(j.packages || j.dependencies || {});
|
|
240
|
+
// Fire only if integrity is missing on ANY package entry that
|
|
241
|
+
// resolves to a remote tarball — the indicator captures the
|
|
242
|
+
// class, not full coverage.
|
|
243
|
+
if (withoutIntegrity > 0) {
|
|
244
|
+
signal_overrides["lockfile-no-integrity"] = "hit";
|
|
245
|
+
} else if (withIntegrity > 0) {
|
|
246
|
+
signal_overrides["lockfile-no-integrity"] = "miss";
|
|
247
|
+
}
|
|
248
|
+
// Stash diagnostic counts on collector_meta further below.
|
|
249
|
+
npmLockfile.integrity_present_count = withIntegrity;
|
|
250
|
+
npmLockfile.integrity_missing_count = withoutIntegrity;
|
|
251
|
+
} catch {
|
|
252
|
+
// Malformed lockfile — leave the indicator unflipped so the
|
|
253
|
+
// runner returns inconclusive rather than a forced miss.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
precondition_checks: {
|
|
259
|
+
"sbom-tool-available": hasAnything,
|
|
260
|
+
},
|
|
261
|
+
artifacts,
|
|
262
|
+
signal_overrides,
|
|
263
|
+
collector_meta: {
|
|
264
|
+
collector_id: COLLECTOR_ID,
|
|
265
|
+
collector_version: "2026-05-20",
|
|
266
|
+
platform: process.platform,
|
|
267
|
+
captured_at: new Date().toISOString(),
|
|
268
|
+
cwd: root,
|
|
269
|
+
duration_ms: Date.now() - startTime,
|
|
270
|
+
lockfiles_found: lockfiles.length,
|
|
271
|
+
sbom_documents_found: sbomDocuments.length,
|
|
272
|
+
ecosystems_detected: [...new Set(lockfiles.map(l => l.ecosystem))],
|
|
273
|
+
},
|
|
274
|
+
collector_errors: errors,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = { playbook_id: COLLECTOR_ID, collect };
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/collectors/secrets.js
|
|
5
|
+
*
|
|
6
|
+
* Companion collector for the `secrets` playbook. Walks the cwd
|
|
7
|
+
* tree, identifies the artifact files (env / auth-config / ssh-keys /
|
|
8
|
+
* iac-credential-bearers), runs the catalogued regex set against text
|
|
9
|
+
* file contents, and stats permission posture on secret-carrier
|
|
10
|
+
* files. Emits a submission with deterministic signal_overrides per
|
|
11
|
+
* indicator that fired.
|
|
12
|
+
*
|
|
13
|
+
* Scope: any cwd. Cross-platform (Windows / macOS / Linux).
|
|
14
|
+
* Permission-posture indicator only meaningful on POSIX hosts.
|
|
15
|
+
*
|
|
16
|
+
* Interface: see lib/collectors/README.md
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require("node:fs");
|
|
20
|
+
const path = require("node:path");
|
|
21
|
+
|
|
22
|
+
const COLLECTOR_ID = "secrets";
|
|
23
|
+
|
|
24
|
+
// Walk depth + exclusion list mirrors the secrets playbook's
|
|
25
|
+
// `look.artifacts[repo-tree].source` declaration.
|
|
26
|
+
const DEFAULT_MAX_DEPTH = 6;
|
|
27
|
+
const DEFAULT_EXCLUDES = new Set([
|
|
28
|
+
"node_modules", ".git", "dist", "build", "out",
|
|
29
|
+
".venv", "venv", "__pycache__", ".pytest_cache",
|
|
30
|
+
"target", ".idea", ".vscode",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const ENV_FILE_PREDICATE = (name) => {
|
|
34
|
+
if (name === ".env" || name === ".envrc") return true;
|
|
35
|
+
if (name.startsWith(".env.")) return true;
|
|
36
|
+
if (name.endsWith(".env")) return true;
|
|
37
|
+
return false;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const AUTH_CONFIG_FILES = new Set([
|
|
41
|
+
".npmrc", ".pypirc", ".netrc", ".git-credentials",
|
|
42
|
+
"config.json", // .docker/config.json — caller checks parent dir
|
|
43
|
+
".yarnrc.yml", ".yarnrc",
|
|
44
|
+
"settings.xml", "gradle.properties",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const SSH_PRIVATE_KEY_FILES = new Set(["id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
|
|
48
|
+
const SSH_PRIVATE_KEY_EXTS = new Set([".pem", ".key", ".p12", ".pfx"]);
|
|
49
|
+
|
|
50
|
+
const IAC_EXTS = new Set([".tf", ".tfvars", ".bicep"]);
|
|
51
|
+
const IAC_EXACT = new Set(["terraform.tfstate", "values.yaml", "secret.yaml"]);
|
|
52
|
+
const IAC_GLOB_PREFIX = ["pulumi.", "arm."];
|
|
53
|
+
|
|
54
|
+
// Indicator regex set — must mirror data/playbooks/secrets.json's
|
|
55
|
+
// detect.indicators[].value embedded patterns. The playbook is the
|
|
56
|
+
// source of truth for what counts as a hit; the collector
|
|
57
|
+
// implements the same patterns so its signal_overrides match what
|
|
58
|
+
// the runner would compute.
|
|
59
|
+
const INDICATOR_PATTERNS = [
|
|
60
|
+
{ id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
61
|
+
{ id: "aws-secret-access-key", re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi },
|
|
62
|
+
{ id: "gcp-service-account-json", re: /"type"\s*:\s*"service_account"[\s\S]{0,1200}?"private_key"\s*:\s*"-----BEGIN PRIVATE KEY-----/g },
|
|
63
|
+
{ id: "github-personal-access-token", re: /\bghp_[A-Za-z0-9]{36}\b/g },
|
|
64
|
+
{ id: "github-fine-grained-pat", re: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
|
|
65
|
+
{ id: "slack-bot-or-user-token", re: /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g },
|
|
66
|
+
{ id: "stripe-secret-key", re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b/g },
|
|
67
|
+
{ id: "jwt-token-with-secret-context", re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
|
|
68
|
+
{ id: "ssh-private-key-block", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED |)PRIVATE KEY-----/g },
|
|
69
|
+
{ id: "openai-api-key", re: /\bsk-(?:proj-|svcacct-|admin-|)[A-Za-z0-9_-]{20,}\b/g },
|
|
70
|
+
{ id: "anthropic-api-key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const TEXT_EXTENSIONS = new Set([
|
|
74
|
+
".env", ".envrc", ".txt", ".md", ".json", ".yaml", ".yml", ".toml",
|
|
75
|
+
".tf", ".tfvars", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx",
|
|
76
|
+
".py", ".rb", ".go", ".rs", ".java", ".cs", ".php", ".sh", ".bash",
|
|
77
|
+
".zsh", ".fish", ".ps1", ".psm1", ".bicep", ".html", ".xml", ".ini",
|
|
78
|
+
".conf", ".cfg", ".properties", ".gradle", ".sql", ".dockerfile",
|
|
79
|
+
]);
|
|
80
|
+
const TEXT_EXACT = new Set(["Dockerfile", "Makefile", "Procfile", ".env", ".envrc"]);
|
|
81
|
+
const MAX_FILE_BYTES = 1024 * 1024; // 1 MB per file content scan
|
|
82
|
+
|
|
83
|
+
function walkTree(root, opts = {}) {
|
|
84
|
+
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
85
|
+
const excludes = opts.excludes ?? DEFAULT_EXCLUDES;
|
|
86
|
+
const out = [];
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
|
|
89
|
+
function walk(dir, depth) {
|
|
90
|
+
if (depth > maxDepth) return;
|
|
91
|
+
let entries;
|
|
92
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
93
|
+
catch { return; }
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (excludes.has(entry.name)) continue;
|
|
96
|
+
const full = path.join(dir, entry.name);
|
|
97
|
+
let real;
|
|
98
|
+
try { real = fs.realpathSync(full); } catch { continue; }
|
|
99
|
+
if (seen.has(real)) continue;
|
|
100
|
+
seen.add(real);
|
|
101
|
+
if (entry.isDirectory()) {
|
|
102
|
+
walk(full, depth + 1);
|
|
103
|
+
} else if (entry.isFile()) {
|
|
104
|
+
out.push({ full, rel: path.relative(root, full), name: entry.name });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
walk(root, 0);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function classify(file) {
|
|
113
|
+
const name = file.name;
|
|
114
|
+
const ext = path.extname(name).toLowerCase();
|
|
115
|
+
const rel = file.rel;
|
|
116
|
+
const isDockerConfig = /(^|\/|\\)\.docker\/config\.json$/.test(rel.replace(/\\/g, "/"));
|
|
117
|
+
const isHelmValues = name === "values.yaml" || rel.toLowerCase().includes("/helm/");
|
|
118
|
+
const isAnsible = (ext === ".yml" || ext === ".yaml") &&
|
|
119
|
+
/(roles|group_vars|host_vars)\//.test(rel.replace(/\\/g, "/"));
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
isEnv: ENV_FILE_PREDICATE(name),
|
|
123
|
+
isAuthConfig: AUTH_CONFIG_FILES.has(name) || isDockerConfig,
|
|
124
|
+
isSshKey:
|
|
125
|
+
(SSH_PRIVATE_KEY_FILES.has(name) ||
|
|
126
|
+
(SSH_PRIVATE_KEY_EXTS.has(ext) && !name.endsWith(".pub"))),
|
|
127
|
+
isIac:
|
|
128
|
+
IAC_EXTS.has(ext) || IAC_EXACT.has(name) || isHelmValues || isAnsible ||
|
|
129
|
+
IAC_GLOB_PREFIX.some(p => name.startsWith(p) && (name.endsWith(".yaml") || name.endsWith(".yml") || name.endsWith(".json"))),
|
|
130
|
+
isText: TEXT_EXACT.has(name) || TEXT_EXTENSIONS.has(ext) || name.endsWith(".env"),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function statPosture(full) {
|
|
135
|
+
try {
|
|
136
|
+
const s = fs.statSync(full);
|
|
137
|
+
const mode = s.mode & 0o777;
|
|
138
|
+
return {
|
|
139
|
+
mode,
|
|
140
|
+
mode_octal: "0" + mode.toString(8),
|
|
141
|
+
world_writable: (mode & 0o002) !== 0,
|
|
142
|
+
world_readable: (mode & 0o004) !== 0,
|
|
143
|
+
group_writable: (mode & 0o020) !== 0,
|
|
144
|
+
group_readable: (mode & 0o040) !== 0,
|
|
145
|
+
};
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return { error: e.message };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function redactMatch(literal) {
|
|
152
|
+
if (literal.length <= 6) return "<redacted:" + literal.length + "ch>";
|
|
153
|
+
return literal.slice(0, 4) + "…[" + (literal.length - 4) + "ch-redacted]";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function scanContent(full, rel) {
|
|
157
|
+
let buf;
|
|
158
|
+
try {
|
|
159
|
+
const s = fs.statSync(full);
|
|
160
|
+
if (s.size > MAX_FILE_BYTES) return { skipped: "file_too_large", bytes: s.size, hits: [] };
|
|
161
|
+
buf = fs.readFileSync(full, "utf8");
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return { skipped: "read_error", reason: e.message, hits: [] };
|
|
164
|
+
}
|
|
165
|
+
const hits = [];
|
|
166
|
+
for (const p of INDICATOR_PATTERNS) {
|
|
167
|
+
const matches = buf.matchAll(p.re);
|
|
168
|
+
let count = 0;
|
|
169
|
+
for (const m of matches) {
|
|
170
|
+
hits.push({
|
|
171
|
+
indicator_id: p.id,
|
|
172
|
+
file: rel,
|
|
173
|
+
offset: m.index,
|
|
174
|
+
redacted_match: redactMatch(m[0]),
|
|
175
|
+
});
|
|
176
|
+
if (++count >= 5) break; // cap per-indicator-per-file
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { hits };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
183
|
+
const errors = [];
|
|
184
|
+
const startTime = Date.now();
|
|
185
|
+
const root = path.resolve(cwd);
|
|
186
|
+
|
|
187
|
+
let files;
|
|
188
|
+
try {
|
|
189
|
+
files = walkTree(root);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
errors.push({ kind: "walk_failed", reason: e.message });
|
|
192
|
+
files = [];
|
|
193
|
+
}
|
|
194
|
+
if (files.length > 50000) {
|
|
195
|
+
errors.push({
|
|
196
|
+
kind: "file_count_capped",
|
|
197
|
+
reason: `walked ${files.length} files; capping content scan at 50000. Narrow the cwd or raise the cap explicitly.`,
|
|
198
|
+
});
|
|
199
|
+
files = files.slice(0, 50000);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const envFiles = [];
|
|
203
|
+
const authConfigFiles = [];
|
|
204
|
+
const sshPrivateKeys = [];
|
|
205
|
+
const iacFiles = [];
|
|
206
|
+
const textFiles = [];
|
|
207
|
+
for (const f of files) {
|
|
208
|
+
const c = classify(f);
|
|
209
|
+
if (c.isEnv) envFiles.push(f);
|
|
210
|
+
if (c.isAuthConfig) authConfigFiles.push(f);
|
|
211
|
+
if (c.isSshKey) sshPrivateKeys.push(f);
|
|
212
|
+
if (c.isIac) iacFiles.push(f);
|
|
213
|
+
if (c.isText) textFiles.push(f);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const worldWritablePosture = [];
|
|
217
|
+
if (process.platform !== "win32") {
|
|
218
|
+
const carriers = [...new Set([...envFiles, ...authConfigFiles, ...sshPrivateKeys].map(f => f.full))]
|
|
219
|
+
.map(p => files.find(f => f.full === p))
|
|
220
|
+
.filter(Boolean);
|
|
221
|
+
for (const f of carriers) {
|
|
222
|
+
const p = statPosture(f.full);
|
|
223
|
+
if (p.world_writable || p.world_readable) {
|
|
224
|
+
worldWritablePosture.push({ file: f.rel, ...p });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const allHits = [];
|
|
230
|
+
for (const f of textFiles) {
|
|
231
|
+
const r = scanContent(f.full, f.rel);
|
|
232
|
+
if (r.hits) allHits.push(...r.hits);
|
|
233
|
+
if (r.skipped === "read_error") {
|
|
234
|
+
errors.push({ artifact_id: "secret-regex-scan-text-files", kind: "read_failed", reason: `${f.rel}: ${r.reason}` });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const hitsByIndicator = {};
|
|
239
|
+
for (const h of allHits) {
|
|
240
|
+
(hitsByIndicator[h.indicator_id] = hitsByIndicator[h.indicator_id] || []).push(h);
|
|
241
|
+
}
|
|
242
|
+
const signal_overrides = {};
|
|
243
|
+
for (const p of INDICATOR_PATTERNS) {
|
|
244
|
+
signal_overrides[p.id] = hitsByIndicator[p.id] && hitsByIndicator[p.id].length > 0 ? "hit" : "miss";
|
|
245
|
+
}
|
|
246
|
+
// world-writable-env-file predicate (per data/playbooks/secrets.json):
|
|
247
|
+
// restricted to env-files artifact entries
|
|
248
|
+
// any .env / .env.* / .envrc with mode 0666 or 0664 (group/world writable)
|
|
249
|
+
// i.e. group-write OR world-write bit set (mode & 0o022).
|
|
250
|
+
const envFilePostures = process.platform === "win32" ? [] : envFiles.map(f => ({ file: f.rel, ...statPosture(f.full) }));
|
|
251
|
+
signal_overrides["world-writable-env-file"] = envFilePostures.some(p => p.error == null && (p.mode & 0o022) !== 0) ? "hit" : "miss";
|
|
252
|
+
|
|
253
|
+
// ssh-key-bad-perms predicate (per playbook):
|
|
254
|
+
// restricted to ssh-private-keys artifact + ~/.ssh/id_* paths
|
|
255
|
+
// any private-key file with mode != 0600
|
|
256
|
+
// The collector scope is the cwd; ~/.ssh enumeration is outside this
|
|
257
|
+
// walk root. Within cwd, flag any discovered private key whose mode
|
|
258
|
+
// is anything other than 0600 (strict).
|
|
259
|
+
const sshKeyPostures = process.platform === "win32" ? [] : sshPrivateKeys.map(f => ({ file: f.rel, ...statPosture(f.full) }));
|
|
260
|
+
signal_overrides["ssh-key-bad-perms"] = sshKeyPostures.some(p => p.error == null && p.mode !== 0o600) ? "hit" : "miss";
|
|
261
|
+
|
|
262
|
+
const summarizeFiles = (list) => list.map(f => f.rel).join(", ");
|
|
263
|
+
const artifacts = {
|
|
264
|
+
"repo-tree": {
|
|
265
|
+
value: `${files.length} file(s) walked (depth ≤ ${DEFAULT_MAX_DEPTH}, exclude ${[...DEFAULT_EXCLUDES].slice(0, 8).join("/")}/…)`,
|
|
266
|
+
captured: true,
|
|
267
|
+
},
|
|
268
|
+
"env-files": {
|
|
269
|
+
value: envFiles.length ? summarizeFiles(envFiles) : "none found",
|
|
270
|
+
captured: true,
|
|
271
|
+
},
|
|
272
|
+
"auth-config-files": {
|
|
273
|
+
value: authConfigFiles.length ? summarizeFiles(authConfigFiles) : "none found",
|
|
274
|
+
captured: true,
|
|
275
|
+
},
|
|
276
|
+
"ssh-private-keys": {
|
|
277
|
+
value: sshPrivateKeys.length ? summarizeFiles(sshPrivateKeys) : "none found",
|
|
278
|
+
captured: true,
|
|
279
|
+
},
|
|
280
|
+
"iac-credential-bearers": {
|
|
281
|
+
value: iacFiles.length ? summarizeFiles(iacFiles) : "none found",
|
|
282
|
+
captured: true,
|
|
283
|
+
},
|
|
284
|
+
"secret-regex-scan-text-files": {
|
|
285
|
+
value: allHits.length
|
|
286
|
+
? `${allHits.length} hit(s): ` + allHits.slice(0, 20).map(h => `${h.indicator_id}@${h.file}:${h.offset} ${h.redacted_match}`).join("; ") + (allHits.length > 20 ? "; …" : "")
|
|
287
|
+
: `scanned ${textFiles.length} text file(s); 0 hits`,
|
|
288
|
+
captured: true,
|
|
289
|
+
},
|
|
290
|
+
"world-writable-secret-files": {
|
|
291
|
+
value: process.platform === "win32"
|
|
292
|
+
? "skipped on win32 (POSIX mode bits not load-bearing)"
|
|
293
|
+
: (worldWritablePosture.length
|
|
294
|
+
? worldWritablePosture.map(p => `${p.file} (mode ${p.mode_octal}, wr=${p.world_writable}, rd=${p.world_readable})`).join("; ")
|
|
295
|
+
: "scanned for world-writable; 0 carriers above 0644"),
|
|
296
|
+
captured: process.platform !== "win32",
|
|
297
|
+
reason: process.platform === "win32" ? "POSIX mode bits not meaningful on Windows; ACL audit out of scope" : undefined,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
precondition_checks: {
|
|
303
|
+
"repo-context": true,
|
|
304
|
+
"regex-engine": true,
|
|
305
|
+
},
|
|
306
|
+
artifacts,
|
|
307
|
+
signal_overrides,
|
|
308
|
+
collector_meta: {
|
|
309
|
+
collector_id: COLLECTOR_ID,
|
|
310
|
+
collector_version: "2026-05-20",
|
|
311
|
+
platform: process.platform,
|
|
312
|
+
captured_at: new Date().toISOString(),
|
|
313
|
+
cwd: root,
|
|
314
|
+
duration_ms: Date.now() - startTime,
|
|
315
|
+
files_walked: files.length,
|
|
316
|
+
text_files_scanned: textFiles.length,
|
|
317
|
+
hits_total: allHits.length,
|
|
318
|
+
},
|
|
319
|
+
collector_errors: errors,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = { playbook_id: COLLECTOR_ID, collect };
|
package/lib/flag-suggest.js
CHANGED
|
@@ -109,8 +109,9 @@ const VERB_FLAG_ALLOWLIST = Object.freeze({
|
|
|
109
109
|
reattest: [
|
|
110
110
|
'playbook', 'since', 'latest', 'force-replay', 'attestation-root',
|
|
111
111
|
],
|
|
112
|
-
doctor: ['signatures', 'cves', 'rfcs', 'fix', 'registry-check', 'exit-codes'],
|
|
112
|
+
doctor: ['signatures', 'cves', 'rfcs', 'fix', 'registry-check', 'exit-codes', 'shipped-tarball', 'ai-config', 'currency'],
|
|
113
113
|
lint: ['evidence'],
|
|
114
|
+
collect: ['cwd'],
|
|
114
115
|
refresh: [
|
|
115
116
|
'apply', 'dry-run', 'from-cache', 'from-fixture', 'network', 'source',
|
|
116
117
|
'advisory', 'force-stale', 'force-stale-acked', 'air-gap', 'swarm',
|