@shomra/agent 0.3.6 → 0.3.7
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/code-sast.mjs +22 -9
- package/guard-signals.mjs +56 -12
- package/model-refs.mjs +25 -2
- package/package.json +1 -1
- package/shomra.mjs +6710 -6565
package/code-sast.mjs
CHANGED
|
@@ -278,15 +278,25 @@ const PY_RULES = [
|
|
|
278
278
|
},
|
|
279
279
|
{
|
|
280
280
|
id: 'python.network_egress',
|
|
281
|
-
title: 'Network egress
|
|
282
|
-
|
|
281
|
+
title: 'Network egress',
|
|
282
|
+
// ⚠ MEDIUM, and titled 'Network egress' NOT 'from model code' — in lockstep
|
|
283
|
+
// with checks/code-sast.ts (python.network_egress). This rule fires on ANY
|
|
284
|
+
// `requests.get`/`httpx`/`socket`, and this SAST tier runs over EVERY source
|
|
285
|
+
// file in a repo, not just model loaders — so an ordinary RAG/app file that
|
|
286
|
+
// makes an outbound call (`benign/src/rag.py`) got a HIGH "from model code"
|
|
287
|
+
// finding for doing the most normal thing an application does. A bare
|
|
288
|
+
// outbound call is an informational capability standalone; it is DANGEROUS
|
|
289
|
+
// only chained with a remote-code load, which `chain.remote_code_egress`
|
|
290
|
+
// already escalates to CRITICAL. Drifting this back to HIGH re-breaks the
|
|
291
|
+
// parity the local-mirror bench exists to protect.
|
|
292
|
+
severity: 'MEDIUM',
|
|
283
293
|
category: 'egress',
|
|
284
|
-
confidence: 0.
|
|
294
|
+
confidence: 0.5,
|
|
285
295
|
re: /\b(requests|httpx)\.(get|post|put|request)\s*\(|\burllib\.request\.(urlopen|urlretrieve)\s*\(|\bsocket\.(socket|create_connection)\s*\(|\baiohttp\.ClientSession\s*\(/,
|
|
286
296
|
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
287
297
|
source: 'network',
|
|
288
|
-
message: '
|
|
289
|
-
remediation: '
|
|
298
|
+
message: 'Opens an outbound network connection. Normal in application code; in model/tokenizer code that should never phone out, or chained with a remote-code load, this is the exfiltration / second-stage-download shape.',
|
|
299
|
+
remediation: 'Confirm the destination and payload. Model inference code should never make outbound requests; treat a phoning-out model as hostile until proven otherwise.',
|
|
290
300
|
cwe: 'CWE-913',
|
|
291
301
|
},
|
|
292
302
|
{
|
|
@@ -439,14 +449,17 @@ const JS_RULES = [
|
|
|
439
449
|
},
|
|
440
450
|
{
|
|
441
451
|
id: 'js.network_egress',
|
|
442
|
-
title: 'Network egress
|
|
443
|
-
|
|
452
|
+
title: 'Network egress',
|
|
453
|
+
// MEDIUM + neutral title, in lockstep with checks/code-sast.ts js.network_egress.
|
|
454
|
+
// Same reasoning as the python twin above: a bare outbound call is ordinary
|
|
455
|
+
// application behaviour, HIGH only when chained with a remote-code load.
|
|
456
|
+
severity: 'MEDIUM',
|
|
444
457
|
category: 'egress',
|
|
445
|
-
confidence: 0.
|
|
458
|
+
confidence: 0.5,
|
|
446
459
|
re: /\baxios\s*\.\s*(get|post|put|request)\s*\(|\bhttps?\.request\s*\(|\bnet\.(connect|createConnection)\s*\(|\bnew\s+WebSocket\s*\(|require\(\s*['"](node-fetch|got|undici|axios)['"]/,
|
|
447
460
|
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
448
461
|
source: 'network',
|
|
449
|
-
message: 'Opens an outbound connection
|
|
462
|
+
message: 'Opens an outbound network connection. Normal in application code; chained with a reads-secrets or remote-code-load finding this is the exfiltration / second-stage-download shape.',
|
|
450
463
|
remediation: 'Confirm the destination is expected and necessary; agent tools should not phone out to arbitrary hosts.',
|
|
451
464
|
cwe: 'CWE-913',
|
|
452
465
|
},
|
package/guard-signals.mjs
CHANGED
|
@@ -127,7 +127,33 @@ export const SECRET_PATTERNS = [
|
|
|
127
127
|
{ name: 'OpenAI key', re: /\bsk-[A-Za-z0-9]{20,}/ },
|
|
128
128
|
{ name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}/ },
|
|
129
129
|
{ name: 'GitHub token', re: /ghp_[0-9A-Za-z]{20,}/ },
|
|
130
|
+
// ── AI-provider keys ──────────────────────────────────────────────────────
|
|
131
|
+
// ⚠ These seven were in `checks/patterns.ts` and NOT here, so the mirror was
|
|
132
|
+
// silently the weaker half: `shomra secrets` found 3 of 6 planted credentials
|
|
133
|
+
// in a .env that `shomra gate` (server-side) scored 6 CRITICAL on. The command
|
|
134
|
+
// named after the job was the one that missed them.
|
|
135
|
+
//
|
|
136
|
+
// `sk-[A-Za-z0-9]{20,}` above cannot match `sk-ant-api03-…` OR `sk-proj-…`:
|
|
137
|
+
// the HYPHEN after the vendor segment is outside the character class, so the
|
|
138
|
+
// quantifier dies on the fourth character. That covers both the provider this
|
|
139
|
+
// product is built on and the CURRENT OpenAI project-key format.
|
|
140
|
+
{ name: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
141
|
+
{ name: 'OpenAI project key', re: /\bsk-proj-[A-Za-z0-9_-]{20,}/ },
|
|
142
|
+
{ name: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
143
|
+
{ name: 'Hugging Face token', re: /\bhf_[A-Za-z0-9]{30,}/ },
|
|
144
|
+
{ name: 'GitLab PAT', re: /\bglpat-[A-Za-z0-9_-]{20,}/ },
|
|
145
|
+
{ name: 'npm token', re: /\bnpm_[A-Za-z0-9]{30,}/ },
|
|
130
146
|
{ name: 'Slack token', re: /xox[baprs]-[0-9A-Za-z-]{10,}/ },
|
|
147
|
+
// Keyed forms: the VALUE alone is unremarkable (40 base64-ish chars), so the
|
|
148
|
+
// assignment is the evidence. Without these an AWS secret key and a database
|
|
149
|
+
// password sit in a .env looking like configuration.
|
|
150
|
+
{ name: 'AWS secret access key (keyed)', re: /\bAWS_SECRET_ACCESS_KEY\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}\b/ },
|
|
151
|
+
// The negative lookahead mirrors checks/patterns.ts — `postgres://user:pass@host/db`
|
|
152
|
+
// is the documentation placeholder, not a credential.
|
|
153
|
+
{
|
|
154
|
+
name: 'Database URL with password',
|
|
155
|
+
re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/(?!(?:user|username|admin|root|myuser|dbuser):(?:pass|password|passwd|secret|changeme|mypassword|yourpassword|xxx+|123456)@)[^\s:@/]+:[^\s:@/]{4,}@/i,
|
|
156
|
+
},
|
|
131
157
|
{ name: 'Generic bearer', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
|
|
132
158
|
{ name: 'Private key block', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
|
|
133
159
|
];
|
|
@@ -139,10 +165,13 @@ export const RISKY_CONFIG_MARKERS = [
|
|
|
139
165
|
];
|
|
140
166
|
|
|
141
167
|
// ── PII (patterns + Luhn gate) ──
|
|
168
|
+
// ⚠ Bounded quantifiers, mirroring checks/patterns.ts — the unbounded `+`/`[ -]*?`
|
|
169
|
+
// forms are O(n²) ReDoS on a long single-class run (100KB of "AAAA…" → ~7s of
|
|
170
|
+
// pegged CPU). RFC-correct maxima, so no real email/card is missed.
|
|
142
171
|
export const PII_PATTERNS = [
|
|
143
|
-
{ name: 'Email address', re: /[A-Za-z0-9._%+-]
|
|
172
|
+
{ name: 'Email address', re: /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/ },
|
|
144
173
|
{ name: 'US SSN', re: /\b\d{3}-\d{2}-\d{4}\b/ },
|
|
145
|
-
{ name: 'Credit card number', re: /\b(?:\d[ -]
|
|
174
|
+
{ name: 'Credit card number', re: /\b(?:\d[ -]?){13,16}\b/ },
|
|
146
175
|
{ name: 'Phone number', re: /\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b/ },
|
|
147
176
|
{ name: 'IPv4 address', re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/ },
|
|
148
177
|
];
|
|
@@ -328,11 +357,24 @@ function lineOf(text, needle) {
|
|
|
328
357
|
// sits in such a code/data context (→ safe to down-rank) rather than as a bare,
|
|
329
358
|
// runnable command line (→ still dangerous).
|
|
330
359
|
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
360
|
+
// Two marks, because "not a live command line" splits into two OPPOSITE cases.
|
|
361
|
+
//
|
|
362
|
+
// 1 = QUOTED. String literals, `//` and `#` line comments, /* */ blocks,
|
|
363
|
+
// regex literals, fenced code blocks. The reader SEES this text. A rule
|
|
364
|
+
// definition, a docs example, a quoted sample — safe to down-rank.
|
|
365
|
+
//
|
|
366
|
+
// 2 = CONCEALED. An HTML comment. The reader does NOT see this text and the
|
|
367
|
+
// model does. That is not a quotation, it is a hiding place, and it is the
|
|
368
|
+
// single most common way a poisoned document carries a payload past human
|
|
369
|
+
// review.
|
|
370
|
+
//
|
|
371
|
+
// ⚠ These were both 1, so wrapping a payload in `<!-- -->` was a ONE-LINE
|
|
372
|
+
// bypass: an identical instruction-override scored HIGH/QUARANTINE as bare
|
|
373
|
+
// prose and LOW/REVIEW inside a comment, labelled "[in a code block]" so the
|
|
374
|
+
// reviewer would dismiss it. Concealment must never buy a discount. Anything
|
|
375
|
+
// reading this mask must test `=== 1`, never truthiness.
|
|
376
|
+
const MARK_CONCEALED = 2;
|
|
377
|
+
// Single-pass mask of the non-plain regions of a text.
|
|
336
378
|
// A best-effort tokenizer — it biases toward marking (fewer false positives),
|
|
337
379
|
// which is the correct trade for a security tool scanning content it will merely
|
|
338
380
|
// read; execution is gated separately by the pre-call firewall.
|
|
@@ -367,19 +409,19 @@ function codeMask(text) {
|
|
|
367
409
|
if (c === '/' && c2 === '/') { state = 4; mask[i++] = 1; continue; }
|
|
368
410
|
if (c === '#' && (i === 0 || /\s/.test(text[i - 1]))) { state = 4; mask[i++] = 1; continue; }
|
|
369
411
|
if (c === '/' && c2 === '*') { state = 5; mask[i++] = 1; continue; }
|
|
370
|
-
if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] =
|
|
412
|
+
if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] = MARK_CONCEALED; continue; }
|
|
371
413
|
if (c === '/' && REGEX_START.has(prevSig)) { state = 7; inClass = false; mask[i++] = 1; continue; }
|
|
372
414
|
if (!/\s/.test(c)) prevSig = c;
|
|
373
415
|
i++;
|
|
374
416
|
continue;
|
|
375
417
|
}
|
|
376
|
-
mask[i] = 1;
|
|
418
|
+
mask[i] = state === 6 ? MARK_CONCEALED : 1;
|
|
377
419
|
if (state === 1) { if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; } if (c === "'") { state = 0; prevSig = "'"; } i++; continue; }
|
|
378
420
|
if (state === 2) { if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; } if (c === '"') { state = 0; prevSig = '"'; } i++; continue; }
|
|
379
421
|
if (state === 3) { if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; } if (c === '`') { state = 0; prevSig = '`'; } i++; continue; }
|
|
380
422
|
if (state === 4) { if (c === '\n') state = 0; i++; continue; }
|
|
381
423
|
if (state === 5) { if (c === '*' && c2 === '/') { mask[i + 1] = 1; i += 2; state = 0; } else i++; continue; }
|
|
382
|
-
if (state === 6) { if (text.startsWith('-->', i)) { mask[i + 1] =
|
|
424
|
+
if (state === 6) { if (text.startsWith('-->', i)) { mask[i + 1] = MARK_CONCEALED; mask[i + 2] = MARK_CONCEALED; i += 3; state = 0; } else i++; continue; }
|
|
383
425
|
if (state === 7) { // regex literal
|
|
384
426
|
if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; }
|
|
385
427
|
if (c === '\n') { state = 0; } // unterminated → bail
|
|
@@ -404,8 +446,10 @@ function locate(text, needle, mask) {
|
|
|
404
446
|
const m = text.match(needle);
|
|
405
447
|
idx = m && m.index != null ? m.index : -1;
|
|
406
448
|
}
|
|
407
|
-
if (idx < 0) return { line: undefined, codeContext: false };
|
|
408
|
-
|
|
449
|
+
if (idx < 0) return { line: undefined, codeContext: false, concealed: false };
|
|
450
|
+
// `codeContext` stays strictly the QUOTED case — it is what down-ranking keys
|
|
451
|
+
// on, and a concealed payload must not qualify for that discount.
|
|
452
|
+
return { line: lineAt(text, idx), codeContext: mask[idx] === 1, concealed: mask[idx] === MARK_CONCEALED };
|
|
409
453
|
}
|
|
410
454
|
|
|
411
455
|
// Obvious non-secrets: documented sample keys, placeholders, masked values.
|
package/model-refs.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
// A line must carry one of these to be considered a model load.
|
|
17
|
-
const LOADER_HINT = /\b(from_pretrained|SentenceTransformer|CrossEncoder|hf_hub_download|snapshot_download|InferenceClient|AutoModel\w*|AutoTokenizer|AutoConfig|AutoProcessor|AutoFeatureExtractor|from_hf_hub|hf_hub|load_dataset|torch\.hub\.load|ollama)\b|\bpipeline\s*\(|\bmodel\s
|
|
17
|
+
const LOADER_HINT = /\b(from_pretrained|SentenceTransformer|CrossEncoder|hf_hub_download|snapshot_download|InferenceClient|AutoModel\w*|AutoTokenizer|AutoConfig|AutoProcessor|AutoFeatureExtractor|from_hf_hub|hf_hub|load_dataset|torch\.hub\.load|ollama)\b|\bpipeline\s*\(|\bmodel\s*[=:]\s*['"]|huggingface\.co|\bhf\.co\b/i;
|
|
18
18
|
|
|
19
19
|
// A quoted HF-style id: "org/model" (one slash, HF-legal chars, no path/URL/ext).
|
|
20
20
|
const QUOTED_ID = /['"]([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*)['"]/g;
|
|
@@ -22,7 +22,25 @@ const QUOTED_ID = /['"]([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*)['"]/g;
|
|
|
22
22
|
// accept BARE ids (no org, e.g. "gpt2", "distilbert-base-uncased") from them.
|
|
23
23
|
const FROM_PRETRAINED_ARG = /\bfrom_pretrained\s*\(\s*(?:[A-Za-z_][\w.]*\s*,\s*)?['"]([\w./-]+)['"]/g;
|
|
24
24
|
const ST_ARG = /\b(?:SentenceTransformer|CrossEncoder)\s*\(\s*['"]([\w./-]+)['"]/g;
|
|
25
|
-
|
|
25
|
+
// ⚠ `[=:]`, not `=`. This required an equals sign, so it saw Python and nothing
|
|
26
|
+
// else: JS/TS object literals and JSON all write `model: "gpt-4o"`, which is how
|
|
27
|
+
// every Node OpenAI/Anthropic SDK call declares its model. A JS repo full of
|
|
28
|
+
// model calls reported "✓ No AI model references found in the code." — a green
|
|
29
|
+
// pass over a blind spot, and no vuln lookups ran for the whole estate.
|
|
30
|
+
//
|
|
31
|
+
// The VALUE STAYS QUOTED. An unquoted value matches any identifier, so
|
|
32
|
+
// `const model = keyword` and `model: capabilities` came back as models named
|
|
33
|
+
// "keyword" and "capabilities" — an inventory of things that do not exist is
|
|
34
|
+
// worse than a short one. YAML's unquoted form is handled by YAML_KW below,
|
|
35
|
+
// where line anchoring makes it safe.
|
|
36
|
+
const KW_ID = /\b(?:model|repo_id|model_name|model_id|model_name_or_path|pretrained_model_name_or_path|checkpoint|base_model)\s*[=:]\s*['"]([\w./-]+)['"]/gi;
|
|
37
|
+
|
|
38
|
+
// YAML: `model: gpt-4o` with no quotes. Anchored to the start of a line and to
|
|
39
|
+
// end-of-value, so it cannot fire inside expressions the way a free-floating
|
|
40
|
+
// pattern does. The value must carry a digit, a slash or a dot — model ids do
|
|
41
|
+
// (`gpt-4o`, `openai-community/gpt2`, `claude-opus-4-8`); bare English words
|
|
42
|
+
// like `capabilities` do not.
|
|
43
|
+
const YAML_KW = /^[ \t-]*(?:model|model_name|model_id|base_model|checkpoint)\s*:\s*([A-Za-z0-9][\w./-]*[\w/.-])\s*(?:#.*)?$/gim;
|
|
26
44
|
// A pinned revision/commit in the same call.
|
|
27
45
|
const REVISION = /\b(?:revision|commit|sha)\s*=\s*['"]([\w.-]{4,})['"]/i;
|
|
28
46
|
// Bare-id positions can accidentally grab a pipeline TASK / device / dtype — drop those.
|
|
@@ -147,6 +165,11 @@ export function scanModelRefs(text, file = '') {
|
|
|
147
165
|
for (const m of raw.matchAll(FROM_PRETRAINED_ARG)) add(m[1], { revision: rev, source: 'hf', line: ln, via: 'from_pretrained', bare: true });
|
|
148
166
|
for (const m of raw.matchAll(ST_ARG)) add(m[1], { revision: rev, source: 'hf', line: ln, via: 'sentence-transformers', bare: true });
|
|
149
167
|
for (const m of raw.matchAll(KW_ID)) add(m[1], { revision: rev, source: 'hf', line: ln, via: 'model= keyword', bare: true });
|
|
168
|
+
// Unquoted YAML values, only where a model id is plausible (see YAML_KW).
|
|
169
|
+
for (const m of raw.matchAll(YAML_KW)) {
|
|
170
|
+
if (!/[0-9./]/.test(m[1])) continue; // no digit, slash or dot → an English word, not a model id
|
|
171
|
+
add(m[1], { revision: rev, source: 'hf', line: ln, via: 'model: yaml key', bare: true });
|
|
172
|
+
}
|
|
150
173
|
|
|
151
174
|
// 4b. Any other loader line with a quoted org/model id (hf_hub_download,
|
|
152
175
|
// snapshot_download, InferenceClient(model=…), etc.).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "Shomra — adversarial assurance for AI agents, as a local-first CLI. Blocks dangerous tool-calls before they run, attacks your own guardrails to prove they hold, and gates AI artifacts in your editor and CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|