@shomra/agent 0.2.4 → 0.2.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 +140 -41
- package/discovery.mjs +3 -3
- package/guard-signals.mjs +219 -55
- package/model-refs.mjs +8 -0
- package/package.json +51 -51
- package/shomra.mjs +94 -42
package/code-sast.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local SAST rule engine for AI-artifact source code — a dependency-free port of
|
|
3
|
-
* the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* the server-side engine, so the CLI can catch the same AI-vulnerability shapes
|
|
4
|
+
* ON-MACHINE (offline, pre-commit, in the IDE) that the model scan and workspace
|
|
5
|
+
* scan catch server-side. Nothing here is executed.
|
|
6
6
|
*
|
|
7
7
|
* Three analysis tiers, worst-first, tuned for low false positives:
|
|
8
8
|
*
|
|
@@ -21,11 +21,15 @@
|
|
|
21
21
|
* • config.json / tokenizer_config.json: auto_map / custom_pipeline / declared
|
|
22
22
|
* trust_remote_code → remote-code-under-load.
|
|
23
23
|
*
|
|
24
|
-
* 2.
|
|
25
|
-
* (`.generate` / `.invoke` /
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* 2. Heuristic LLM-output propagation tier (NOT true dataflow) — marks a
|
|
25
|
+
* variable assigned from an LLM call (`.generate` / `.invoke` /
|
|
26
|
+
* `.completions.create` …) as tainted, propagates by NAME SUBSTRING across
|
|
27
|
+
* simple assignments, and raises an `*.llm_output_to_sink` finding when a
|
|
28
|
+
* tainted name appears in a code-execution sink's arguments. Surfaces the
|
|
29
|
+
* prompt-injection → RCE shape single-sink matching misses, but it is a
|
|
30
|
+
* low-confidence heuristic, not analysis: no scope, no reassignment/kill, no
|
|
31
|
+
* sanitizer awareness, no interprocedural tracking — it can over- and
|
|
32
|
+
* under-report. Findings are HIGH at low confidence with hedged wording.
|
|
29
33
|
*
|
|
30
34
|
* 3. Cross-signal chain tier — synthesises a finding when two independently
|
|
31
35
|
* suspicious signals co-occur in one file: encoded-payload + code-exec
|
|
@@ -34,9 +38,9 @@
|
|
|
34
38
|
*
|
|
35
39
|
* Findings carry a stable dotted rule id, the matched SINK, an optional SOURCE, a
|
|
36
40
|
* CWE, a category, a 0–1 confidence, the FILE + physical LINE and a context
|
|
37
|
-
* SNIPPET — the exact shape
|
|
38
|
-
*
|
|
39
|
-
*
|
|
41
|
+
* SNIPPET — the exact shape shomra.mjs folds into a gate result. The rule bodies
|
|
42
|
+
* mirror the server engine; drift only costs recall on the local floor, and the
|
|
43
|
+
* server remains the full check.
|
|
40
44
|
*/
|
|
41
45
|
|
|
42
46
|
const MAX_SNIPPET = 400;
|
|
@@ -73,7 +77,7 @@ const PY_RULES = [
|
|
|
73
77
|
// jsonpickle, torch.load, joblib/skops, numpy allow_pickle, yaml.load without a
|
|
74
78
|
// safe Loader, shelve, pandas.read_pickle, mlflow.*.load_model. All run
|
|
75
79
|
// __reduce__ / arbitrary code on a crafted file the moment they load.
|
|
76
|
-
re: /\b(pickle|cpickle|dill|_pickle|cloudpickle)\.(loads?|Unpickler)\s*\(|\bjsonpickle\.(decode|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*\(/,
|
|
80
|
+
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*\(/,
|
|
77
81
|
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
78
82
|
source: 'weight / config file',
|
|
79
83
|
message: 'Deserializes data with a pickle-backed loader. A crafted file runs arbitrary code via __reduce__ on load — the primary model-hub malware vector.',
|
|
@@ -86,6 +90,9 @@ const PY_RULES = [
|
|
|
86
90
|
severity: 'CRITICAL',
|
|
87
91
|
category: 'remote-code',
|
|
88
92
|
confidence: 0.95,
|
|
93
|
+
// codeOnly: keyword appears in docstrings / log warnings / usage examples
|
|
94
|
+
// (e.g. Falcon's "load without the trust_remote_code=True argument").
|
|
95
|
+
codeOnly: true,
|
|
89
96
|
re: /trust_remote_code\s*=\s*True/,
|
|
90
97
|
sink: () => 'trust_remote_code=True',
|
|
91
98
|
source: 'model repository',
|
|
@@ -93,6 +100,48 @@ const PY_RULES = [
|
|
|
93
100
|
remediation: 'Remove trust_remote_code=True. Prefer a model with native transformers support, or pin revision= to a specific reviewed commit hash and read the custom modeling code first.',
|
|
94
101
|
cwe: 'CWE-94',
|
|
95
102
|
},
|
|
103
|
+
{
|
|
104
|
+
id: 'python.dataset_remote_code',
|
|
105
|
+
title: 'Dataset loaded with trust_remote_code',
|
|
106
|
+
severity: 'CRITICAL',
|
|
107
|
+
category: 'remote-code',
|
|
108
|
+
confidence: 0.9,
|
|
109
|
+
codeOnly: true,
|
|
110
|
+
// datasets.load_dataset(..., trust_remote_code=True) runs the dataset's own
|
|
111
|
+
// Python loading script — a DISTINCT RCE vector from model trust_remote_code.
|
|
112
|
+
re: /\bload_dataset\s*\((?=[^)]*trust_remote_code\s*=\s*True)/,
|
|
113
|
+
sink: () => 'load_dataset(trust_remote_code=True)',
|
|
114
|
+
source: 'dataset repository',
|
|
115
|
+
message: 'Loads a Hugging Face dataset with trust_remote_code=True, which imports and runs the dataset\'s Python loading script in the host process — arbitrary code execution from the dataset publisher.',
|
|
116
|
+
remediation: 'Remove trust_remote_code=True. Use a non-script dataset format (Parquet/Arrow/CSV/JSON), or pin revision= to a reviewed commit and read the loading script first.',
|
|
117
|
+
cwe: 'CWE-94',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 'python.native_code_load',
|
|
121
|
+
title: 'Loads a native library (ctypes)',
|
|
122
|
+
severity: 'HIGH',
|
|
123
|
+
category: 'code-exec',
|
|
124
|
+
confidence: 0.85,
|
|
125
|
+
re: /\bctypes\.(CDLL|WinDLL|OleDLL|PyDLL|cdll|windll|oledll)\b|\bcdll\.LoadLibrary\s*\(|\bwindll\.LoadLibrary\s*\(/,
|
|
126
|
+
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
127
|
+
source: 'native library',
|
|
128
|
+
message: 'Loads a native/shared library via ctypes. Model code has no reason to dlopen libc or an arbitrary .so/.dll — it is an execution primitive (e.g. ctypes.CDLL("libc.so.6").system(cmd)).',
|
|
129
|
+
remediation: 'Remove the ctypes native-library load from model/loader code. Treat any model that dlopens libraries on load as hostile.',
|
|
130
|
+
cwe: 'CWE-94',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'python.dynamic_file_exec',
|
|
134
|
+
title: 'Loads and executes a Python file at runtime',
|
|
135
|
+
severity: 'HIGH',
|
|
136
|
+
category: 'code-exec',
|
|
137
|
+
confidence: 0.85,
|
|
138
|
+
re: /\bSourceFileLoader\s*\(|\bspec_from_file_location\s*\(|\bimp\.load_source\s*\(|\bexec_module\s*\(/,
|
|
139
|
+
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
140
|
+
source: 'file path',
|
|
141
|
+
message: 'Imports and executes a Python file from a path at runtime (SourceFileLoader / spec_from_file_location / imp.load_source). This runs code that is not statically visible as an import — a common way to hide an execution path.',
|
|
142
|
+
remediation: 'Remove runtime file-based module loading from model code. Import only reviewed, statically-visible modules.',
|
|
143
|
+
cwe: 'CWE-94',
|
|
144
|
+
},
|
|
96
145
|
{
|
|
97
146
|
id: 'python.torch_remote_code',
|
|
98
147
|
title: 'Loads/executes remote or native code via torch',
|
|
@@ -235,14 +284,17 @@ const PY_RULES = [
|
|
|
235
284
|
},
|
|
236
285
|
{
|
|
237
286
|
id: 'python.encoded_payload',
|
|
238
|
-
title: 'Encoded
|
|
239
|
-
|
|
287
|
+
title: 'Encoded blob decode',
|
|
288
|
+
// LOW on its own: base64/hex decode is ubiquitous & benign in ML (tokenizer
|
|
289
|
+
// vocabs, quant kernels). The dangerous decode→exec case is elevated to
|
|
290
|
+
// CRITICAL by chain.decode_exec. marshal.loads moved to pickle_deserialization.
|
|
291
|
+
severity: 'LOW',
|
|
240
292
|
category: 'obfuscation',
|
|
241
|
-
confidence: 0.
|
|
242
|
-
re: /\b(base64|codecs|binascii
|
|
293
|
+
confidence: 0.5,
|
|
294
|
+
re: /\b(base64|codecs|binascii)\.(b64decode|decode|unhexlify)\s*\(|bytes\.fromhex\s*\(/,
|
|
243
295
|
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
244
|
-
message: 'Decodes an encoded blob.
|
|
245
|
-
remediation: '
|
|
296
|
+
message: 'Decodes an encoded blob. Benign on its own (common in tokenizers/quantization), but the "decode then run it" packer combines this with eval/exec — see any decode-and-run chain finding on this file.',
|
|
297
|
+
remediation: 'If this decode feeds eval/exec/import, decode the blob offline and inspect it. A lone decode of vocab/kernel data is expected.',
|
|
246
298
|
cwe: 'CWE-506',
|
|
247
299
|
},
|
|
248
300
|
{
|
|
@@ -371,14 +423,17 @@ const JS_RULES = [
|
|
|
371
423
|
const CONFIG_RULES = [
|
|
372
424
|
{
|
|
373
425
|
id: 'json.automodel_usage',
|
|
374
|
-
|
|
375
|
-
|
|
426
|
+
// MEDIUM, not HIGH: capability/posture fact (ships trust_remote_code code) —
|
|
427
|
+
// true of every legit custom model, so HIGH is alert fatigue. A real sink in
|
|
428
|
+
// the routed module is elevated to CRITICAL by chain.config_module_rce.
|
|
429
|
+
title: 'AutoModel bound to repo-shipped code (trust_remote_code)',
|
|
430
|
+
severity: 'MEDIUM',
|
|
376
431
|
category: 'remote-code',
|
|
377
432
|
confidence: 0.8,
|
|
378
433
|
re: /"(AutoModel[A-Za-z]*|AutoConfig)"\s*:\s*"([^"]+)"/,
|
|
379
434
|
sink: (m) => `auto_map.${m[1]}`,
|
|
380
|
-
message: 'config.json maps an Auto* class to
|
|
381
|
-
remediation: '
|
|
435
|
+
message: 'config.json maps an Auto* class to code shipped in this repo. Loading with trust_remote_code imports and runs that code before any weights — review the publisher and the referenced module. (A dangerous sink in that module is reported separately at higher severity.)',
|
|
436
|
+
remediation: 'Review the referenced module before loading, pin revision= to a reviewed commit, or use a model with native transformers support.',
|
|
382
437
|
cwe: 'CWE-829',
|
|
383
438
|
},
|
|
384
439
|
{
|
|
@@ -419,10 +474,11 @@ const CONFIG_RULES = [
|
|
|
419
474
|
},
|
|
420
475
|
];
|
|
421
476
|
|
|
422
|
-
// ──
|
|
423
|
-
// Per-language patterns for the
|
|
424
|
-
// when its RHS is an LLM/model call; `execSink`
|
|
425
|
-
// tainted
|
|
477
|
+
// ── LLM-output propagation config: LLM output → code-execution sink ─────────
|
|
478
|
+
// Per-language patterns for the heuristic name-propagation pass (NOT dataflow).
|
|
479
|
+
// `aiCall` marks a variable tainted when its RHS is an LLM/model call; `execSink`
|
|
480
|
+
// is the dangerous consumer. A tainted name reaching a sink is the
|
|
481
|
+
// prompt-injection → RCE shape — reported low-confidence, to be confirmed.
|
|
426
482
|
const PY_TAINT = {
|
|
427
483
|
lang: 'python',
|
|
428
484
|
ruleId: 'python.llm_output_to_sink',
|
|
@@ -443,13 +499,13 @@ const CHAINS = [
|
|
|
443
499
|
title: 'Decode-and-execute packer (multi-signal)',
|
|
444
500
|
severity: 'CRITICAL',
|
|
445
501
|
category: 'chain',
|
|
446
|
-
confidence: 0.
|
|
502
|
+
confidence: 0.6,
|
|
447
503
|
// an encoded-blob decode AND a code-exec sink in the same file
|
|
448
504
|
parts: ['python.encoded_payload', 'js.decode_and_run', 'python.dangerous_sinks', 'js.code_exec', 'python.dynamic_import'],
|
|
449
505
|
needs: (ids) => (ids.has('python.encoded_payload') || ids.has('js.decode_and_run')) &&
|
|
450
506
|
(ids.has('python.dangerous_sinks') || ids.has('js.code_exec') || ids.has('python.dynamic_import')),
|
|
451
507
|
anchor: ['python.dangerous_sinks', 'js.code_exec', 'python.encoded_payload', 'js.decode_and_run'],
|
|
452
|
-
message: 'This file
|
|
508
|
+
message: 'This file BOTH decodes an encoded blob AND contains a code-execution sink — the two halves of a decode-then-run packer. Detected by co-occurrence in one file, not a proven decode→exec path, so confirm the decoded blob is what reaches the sink; when it is, this is how a hidden payload is smuggled and run.',
|
|
453
509
|
remediation: 'Decode every embedded blob offline and inspect it, and remove the decode → eval/exec path entirely. Do not ship artifacts that assemble code at runtime.',
|
|
454
510
|
cwe: 'CWE-506',
|
|
455
511
|
},
|
|
@@ -458,14 +514,14 @@ const CHAINS = [
|
|
|
458
514
|
title: 'Remote-code model that also phones out',
|
|
459
515
|
severity: 'CRITICAL',
|
|
460
516
|
category: 'chain',
|
|
461
|
-
confidence: 0.
|
|
517
|
+
confidence: 0.6,
|
|
462
518
|
// remote-code loading AND network egress in the same file
|
|
463
519
|
parts: ['python.trust_remote_code', 'python.torch_remote_code', 'json.automodel_usage', 'json.autotokenizer_usage', 'python.network_egress', 'js.network_egress'],
|
|
464
520
|
needs: (ids) => (ids.has('python.trust_remote_code') || ids.has('python.torch_remote_code') ||
|
|
465
521
|
ids.has('json.automodel_usage') || ids.has('json.autotokenizer_usage')) &&
|
|
466
522
|
(ids.has('python.network_egress') || ids.has('js.network_egress')),
|
|
467
523
|
anchor: ['python.network_egress', 'js.network_egress', 'python.trust_remote_code', 'python.torch_remote_code'],
|
|
468
|
-
message: 'This file loads code shipped in a model repo (trust_remote_code / auto_map / torch.hub) AND opens a network connection. Remote-code modeling that also phones out is the classic staged-download / exfiltration shape.',
|
|
524
|
+
message: 'This file loads code shipped in a model repo (trust_remote_code / auto_map / torch.hub) AND opens a network connection. Detected by co-occurrence in one file, not a proven load→egress path. Remote-code modeling that also phones out is the classic staged-download / exfiltration shape — confirm whether the network call is reachable from the model-code load.',
|
|
469
525
|
remediation: 'Do not load remote model code; use a native-transformers model or a reviewed, pinned revision. Model code should never make outbound requests — treat this artifact as hostile.',
|
|
470
526
|
cwe: 'CWE-494',
|
|
471
527
|
},
|
|
@@ -538,6 +594,42 @@ function escapeRe(s) {
|
|
|
538
594
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
539
595
|
}
|
|
540
596
|
|
|
597
|
+
/**
|
|
598
|
+
* Whether byte `offset` in `text` falls inside a string literal — tracks single/
|
|
599
|
+
* double/backtick + triple quotes, honouring escapes. Drops `codeOnly` rule
|
|
600
|
+
* matches that land in a docstring / log message / usage example (the Falcon
|
|
601
|
+
* `trust_remote_code=True`-in-a-warning FP). Approximate and only ever more
|
|
602
|
+
* conservative (may skip a real hit, never invents one).
|
|
603
|
+
*/
|
|
604
|
+
function isInsideString(text, offset) {
|
|
605
|
+
let quote = null;
|
|
606
|
+
let triple = false;
|
|
607
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
608
|
+
const c = text[i];
|
|
609
|
+
if (quote) {
|
|
610
|
+
if (c === '\\') { i++; continue; }
|
|
611
|
+
if (triple) {
|
|
612
|
+
if (c === quote && text[i + 1] === quote && text[i + 2] === quote) { i += 2; quote = null; triple = false; }
|
|
613
|
+
} else if (c === quote) {
|
|
614
|
+
quote = null;
|
|
615
|
+
}
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (c === '#') {
|
|
619
|
+
const nl = text.indexOf('\n', i);
|
|
620
|
+
if (nl === -1) return false;
|
|
621
|
+
i = nl;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
625
|
+
quote = c;
|
|
626
|
+
triple = text[i + 1] === c && text[i + 2] === c;
|
|
627
|
+
if (triple) i += 2;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return quote !== null;
|
|
631
|
+
}
|
|
632
|
+
|
|
541
633
|
/** Map a match offset inside a logical unit back to a 0-based physical line. */
|
|
542
634
|
function physicalIdx(unit, offset) {
|
|
543
635
|
const newlines = unit.text.slice(0, offset).match(/\n/g);
|
|
@@ -557,10 +649,13 @@ function parseAssign(text) {
|
|
|
557
649
|
}
|
|
558
650
|
|
|
559
651
|
/**
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
652
|
+
* Heuristic intra-file LLM-output propagation (NOT true taint analysis). Marks
|
|
653
|
+
* variables assigned from an LLM call as tainted, propagates by name-substring
|
|
654
|
+
* across simple assignments (bounded fixed point), then emits a finding wherever
|
|
655
|
+
* a tainted name appears in a code-exec sink's arguments — the prompt-injection →
|
|
656
|
+
* RCE shape a single-sink regex can't see. No scope, kill, sanitizer or
|
|
657
|
+
* interprocedural tracking, so it can over- and under-report; findings are HIGH
|
|
658
|
+
* at low confidence with hedged wording, i.e. leads to confirm, not proof.
|
|
564
659
|
*/
|
|
565
660
|
function taintFindings(lines, units, file, cfg) {
|
|
566
661
|
const tainted = new Set();
|
|
@@ -602,18 +697,19 @@ function taintFindings(lines, units, file, cfg) {
|
|
|
602
697
|
const sink = m[0].replace(/\s*\($/, '').trim();
|
|
603
698
|
out.push({
|
|
604
699
|
ruleId: cfg.ruleId,
|
|
605
|
-
title: 'LLM output
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
700
|
+
title: 'LLM output may reach a code-execution sink (heuristic)',
|
|
701
|
+
// HIGH, not CRITICAL: name-propagation heuristic, not proven dataflow.
|
|
702
|
+
severity: 'HIGH',
|
|
703
|
+
category: 'taint-heuristic',
|
|
704
|
+
confidence: 0.5,
|
|
609
705
|
file,
|
|
610
706
|
line: idx + 1,
|
|
611
707
|
sink: sink.slice(0, 120),
|
|
612
708
|
source: 'LLM output',
|
|
613
|
-
taint: `${via} (LLM output) → ${sink}`,
|
|
709
|
+
taint: `${via} (LLM output) → ${sink} (heuristic name match — confirm)`,
|
|
614
710
|
...contextChunk(lines, idx),
|
|
615
|
-
message: `A
|
|
616
|
-
remediation: '
|
|
711
|
+
message: `A variable that appears to derive from an LLM call ("${via}") is used in ${sink}. If that value really is model-controlled, a prompt-injected instruction becomes code execution in the host — a critical agent vulnerability. Flagged by a name-propagation heuristic (no dataflow proof), so confirm the value is actually the model output and not reassigned/sanitized before this line.`,
|
|
712
|
+
remediation: 'If confirmed, never pass model output to eval/exec/subprocess. Constrain the model to structured output (a fixed schema / tool-call allowlist), validate it, and dispatch on named handlers — never execute it.',
|
|
617
713
|
cwe: 'CWE-94',
|
|
618
714
|
});
|
|
619
715
|
}
|
|
@@ -673,6 +769,9 @@ function scanLines(text, file, rules, taintCfg) {
|
|
|
673
769
|
rule.re.lastIndex = 0;
|
|
674
770
|
const m = rule.re.exec(unit.text);
|
|
675
771
|
if (!m) continue;
|
|
772
|
+
// Drop code-construct rules whose match lands inside a string literal
|
|
773
|
+
// (docstring / log message / usage example) — the Falcon-class FP.
|
|
774
|
+
if (rule.codeOnly && isInsideString(unit.text, m.index)) continue;
|
|
676
775
|
const idx = physicalIdx(unit, m.index);
|
|
677
776
|
const trimmed = (lines[idx] ?? '').trim();
|
|
678
777
|
if (!trimmed || isCommentLine(trimmed)) continue;
|
package/discovery.mjs
CHANGED
|
@@ -66,9 +66,9 @@ function firstExisting(paths) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// ── workspace root discovery ─────────────────────────────────────
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
69
|
+
// Real AI assets live scattered across a developer's project folders, so
|
|
70
|
+
// discovery walks those folders rather than assuming the CLI was launched from
|
|
71
|
+
// inside one.
|
|
72
72
|
|
|
73
73
|
const IGNORE_DIRS = new Set([
|
|
74
74
|
'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next', '.nuxt',
|
package/guard-signals.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tier-0 local guard signals — a dependency-free, high-confidence subset of the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* ON-BOX, with zero network round-trip.
|
|
3
|
+
* server-side detection engine, ported so the runtime firewall can decide the
|
|
4
|
+
* DANGEROUS majority of tool calls ON-BOX, with zero network round-trip.
|
|
6
5
|
*
|
|
7
6
|
* Why this exists: the pre-tool-call hook fires on every action. Routing every
|
|
8
7
|
* call through the backend put a network dependency on the hot path — slow when
|
|
@@ -19,11 +18,50 @@
|
|
|
19
18
|
* escalates policy-relevant calls to it; the local tier is the floor, not a
|
|
20
19
|
* replacement.
|
|
21
20
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* The pattern lists below mirror the server engine. Drift only costs recall on
|
|
22
|
+
* the local floor — the server remains the full check.
|
|
24
23
|
*/
|
|
25
24
|
|
|
26
|
-
// ──
|
|
25
|
+
// ── precision guards ──
|
|
26
|
+
|
|
27
|
+
/** Build output every README tells you to wipe — regenerable, not real data. */
|
|
28
|
+
const EPHEMERAL_RM_TARGET_RE =
|
|
29
|
+
/^(\.\/)?(node_modules|dist|build|out|coverage|target|\.next|\.nuxt|\.turbo|\.svelte-kit|\.cache|\.parcel-cache|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.tox|venv|\.venv|\.eggs|[\w.-]+\.egg-info)\/?\*?$/i;
|
|
30
|
+
|
|
31
|
+
/** True when an `rm -rf` line deletes something other than build output. */
|
|
32
|
+
function rmTargetsRealData(line) {
|
|
33
|
+
const m = /\brm\s+((?:-[a-zA-Z]+\s+)+)(.*)$/.exec(line);
|
|
34
|
+
if (!m) return true; // unparsed shape → keep the finding (fail open)
|
|
35
|
+
const targets = m[2]
|
|
36
|
+
.split(/&&|\|\||[;|>&]/)[0]
|
|
37
|
+
.split(/\s+/)
|
|
38
|
+
.filter((t) => t && !t.startsWith('-'));
|
|
39
|
+
if (!targets.length) return true;
|
|
40
|
+
return !targets.every((t) => EPHEMERAL_RM_TARGET_RE.test(t.replace(/^["']|["']$/g, '')));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The TEXT of the line `index` falls on — the unit a `refine` guard reasons
|
|
45
|
+
* about. Distinct from lineAt() (line NUMBER) and lineOf() (locate a needle).
|
|
46
|
+
*/
|
|
47
|
+
function lineTextAt(text, index) {
|
|
48
|
+
const start = text.lastIndexOf('\n', index - 1) + 1;
|
|
49
|
+
const end = text.indexOf('\n', index);
|
|
50
|
+
return text.slice(start, end === -1 ? undefined : end);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when `sig` fires AND at least one hit survives its precision guard. */
|
|
54
|
+
export function matchesShellSignal(sig, text) {
|
|
55
|
+
if (!sig.refine) return sig.re.test(text);
|
|
56
|
+
const g = new RegExp(sig.re.source, sig.re.flags.includes('g') ? sig.re.flags : sig.re.flags + 'g');
|
|
57
|
+
for (const m of text.matchAll(g)) {
|
|
58
|
+
if (m.index == null) continue;
|
|
59
|
+
if (sig.refine(lineTextAt(text, m.index))) return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── dangerous shell ──
|
|
27
65
|
export const DANGEROUS_SHELL = [
|
|
28
66
|
{ name: 'Pipe-to-shell installer (curl … | sh)', re: /\b(curl|wget)\b[^\n|]{0,200}\|\s*(sudo\s+)?(ba|z|k)?sh\b/i, severity: 'CRITICAL' },
|
|
29
67
|
{ name: 'PowerShell download-and-run (iwr/curl … | iex)', re: /\b(iwr|curl|wget|invoke-webrequest|invoke-restmethod|irm)\b[^\n|]{0,200}\|\s*(iex|invoke-expression)\b/i, severity: 'CRITICAL' },
|
|
@@ -34,8 +72,10 @@ export const DANGEROUS_SHELL = [
|
|
|
34
72
|
{ name: 'Command output piped into a network call', re: /\b(curl|wget|invoke-restmethod|invoke-webrequest|irm|iwr)\b[^\n]{0,220}(\$\(|`[^`\n]+`|<\()/i, severity: 'HIGH' },
|
|
35
73
|
{ name: 'Fetches from a raw IP address', re: /\b(curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b[^\n]{0,220}https?:\/\/\d{1,3}(\.\d{1,3}){3}/i, severity: 'HIGH' },
|
|
36
74
|
{ name: 'Writes to shell profile / SSH keys / crontab', re: /(\.bashrc|\.zshrc|\.bash_profile|\.profile|authorized_keys|id_rsa\b|\bcrontab\b)/i, severity: 'HIGH' },
|
|
37
|
-
{ name: 'Recursive force delete (rm -rf)', re: /\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r/i, severity: 'HIGH' },
|
|
38
|
-
|
|
75
|
+
{ name: 'Recursive force delete (rm -rf)', re: /\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r/i, severity: 'HIGH', refine: rmTargetsRealData },
|
|
76
|
+
// BARE `eval(`/`exec(` only — the lookbehind drops method calls that merely end
|
|
77
|
+
// in those letters (`db.exec(`, `RE.exec(`, `page.$eval(`, `$pdo->exec(`).
|
|
78
|
+
{ name: 'Inline eval / exec of a string', re: /(?<![.\w$>:])(eval|exec)\s*[("`']/i, severity: 'HIGH' },
|
|
39
79
|
{ name: 'Pipes an env dump to the network', re: /\b(env|printenv|set)\b[^\n|]{0,80}\|[^\n]{0,80}(curl|wget|nc\b|http)/i, severity: 'HIGH' },
|
|
40
80
|
{ name: 'Disables TLS / cert verification', re: /(NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0|GIT_SSL_NO_VERIFY|--no-check-certificate|--insecure\b|verify\s*=\s*False)/i, severity: 'MEDIUM' },
|
|
41
81
|
{ name: 'python -c one-liner', re: /python[0-9.]*\s+-c\b/i, severity: 'MEDIUM' },
|
|
@@ -43,35 +83,46 @@ export const DANGEROUS_SHELL = [
|
|
|
43
83
|
{ name: 'Netcat / socket exfil', re: /\bnc\s+-[a-z]*\b|\bncat\b/i, severity: 'MEDIUM' },
|
|
44
84
|
];
|
|
45
85
|
|
|
46
|
-
// ── injection
|
|
86
|
+
// ── injection ──
|
|
87
|
+
// Broad phrases that dominate benign dev prose ("system prompt", "run the
|
|
88
|
+
// following command", "do not tell the user", "never mention") are deliberately
|
|
89
|
+
// excluded — their real-attack shapes are caught by scanShell and the tightened
|
|
90
|
+
// regexes below. The injection loop also applies PRECEDING_NEGATION, so "never
|
|
91
|
+
// ignore previous instructions" reads as legitimate.
|
|
47
92
|
export const INJECTION_PHRASES = [
|
|
48
93
|
'ignore previous instructions', 'ignore all previous', 'disregard previous', 'disregard the above',
|
|
49
|
-
'
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'run this first', 'run the following command', 'run the following commands', 'copy this command',
|
|
54
|
-
'copy and run', 'execute the following', 'seed the current host',
|
|
94
|
+
'without informing the user', 'without telling the user',
|
|
95
|
+
'new instructions:', 'override your', 'exfiltrate',
|
|
96
|
+
'send them to', 'post them to',
|
|
97
|
+
'run this first', 'copy and run', 'seed the current host',
|
|
55
98
|
'ignore all instructions', 'ignore your instructions', 'ignore your safety', 'ignore all content policies',
|
|
56
99
|
'disregard your instructions', 'disregard the guidelines', 'system override', 'follow only my instructions',
|
|
57
100
|
'do anything now', 'reveal any credential',
|
|
58
101
|
'save this to your memory', 'in all future sessions', 'remember this forever',
|
|
59
102
|
];
|
|
60
103
|
export const INJECTION_REGEXES = [
|
|
61
|
-
{ label: 'Instruction-override phrasing', re: /\b(ignore|disregard|
|
|
62
|
-
{ label: '
|
|
63
|
-
{ label: 'Bulk destructive command', re: /\b(delete|remove|wipe|erase|destroy|drop|purge|
|
|
64
|
-
{ label: '
|
|
65
|
-
{ label: 'Destructive SQL statement', re: /\b(drop|truncate)\s+(table|database|schema|index)\b/i },
|
|
104
|
+
{ label: 'Instruction-override phrasing', re: /\b(ignore|disregard|override|bypass|circumvent)\b[\s\w,'"()-]{0,40}?\b(instruction|instructions|directive|directives|safety|safeguards?|guardrails?|system\s+prompt|content\s+polic\w+)\b/i },
|
|
105
|
+
{ label: 'Instructs the agent to conceal an action from the user', re: /\b(?:do\s*n['o]?t|never|without)\s+(?:tell|telling|inform|informing|notify|notifying|alert|alerting|mention|mentioning|disclos\w+|reveal\w*)\s+(?:it\s+|this\s+|them\s+)?(?:to\s+)?(?:the\s+)?(?:user|users|human|operator|owner)\b(?!['']s)(?!\s+(?:to\b|how\s+to\b|when\s+to\b|that\s+they\b|about\b))/i },
|
|
106
|
+
{ label: 'Bulk destructive command', re: /\b(delete|remove|wipe|erase|destroy|drop|purge|nuke|truncate)\b[\s\w,'"()-]{0,20}?\b(all|every|each|entire|whole)\b[\s\w,'"()-]{0,15}?\b(folder|folders|file|files|directory|directories|table|tables|database|databases|record|records|repo|repos|repositor\w*|account|accounts|user|users|row|rows|document|documents|data)\b/i },
|
|
107
|
+
{ label: 'Destructive SQL statement', re: /\b(drop|truncate)\s+(table|database|schema)\b/i },
|
|
66
108
|
];
|
|
109
|
+
// Negation flips an override phrase into a hardening rule; a bulk-destructive hit
|
|
110
|
+
// on a build/test artifact is a clean step, not an attack. Applied in localScan.
|
|
111
|
+
const PRECEDING_NEGATION = /\b(never|not|do not|don'?t|cannot|can'?t|must not|mustn'?t|should not|shouldn'?t|avoid|refuse to|forbidden to|prohibited from|without)\s*$/i;
|
|
112
|
+
const BUILD_ARTIFACT = /\b(node_modules|dist|build|out|coverage|target|cache|generated|tmp|temp|__pycache__|artifacts?|logs?|tests?|test|fixtures?|staging|scratch|migrations?)\b/i;
|
|
67
113
|
// zero-width / bidi / tag-block chars used to smuggle instructions (ASCII smuggling).
|
|
68
|
-
|
|
114
|
+
// Excludes U+200D ZWJ and U+FE00–FE0F variation selectors — those render ordinary
|
|
115
|
+
// emoji ("⚠️", "👨💻") and are not a smuggling channel.
|
|
116
|
+
export const INVISIBLE_CHARS_RE = /[ᅟᅠ---ㅤᅠ-]|[\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/u;
|
|
69
117
|
|
|
70
|
-
// ── secrets
|
|
118
|
+
// ── secrets ──
|
|
71
119
|
export const SECRET_PATTERNS = [
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
120
|
+
// Prefix-style keys are \b-anchored (backend parity, checks/patterns.ts): a
|
|
121
|
+
// slug that merely CONTAINS the prefix ("task-0123456789abcdefghij",
|
|
122
|
+
// "disk-…") must not read as a live credential — these are CRITICAL and BLOCK.
|
|
123
|
+
{ name: 'Stripe live key', re: /\bsk_live_[0-9a-zA-Z]{16,}/ },
|
|
124
|
+
{ name: 'OpenAI key', re: /\bsk-[A-Za-z0-9]{20,}/ },
|
|
125
|
+
{ name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}/ },
|
|
75
126
|
{ name: 'GitHub token', re: /ghp_[0-9A-Za-z]{20,}/ },
|
|
76
127
|
{ name: 'Slack token', re: /xox[baprs]-[0-9A-Za-z-]{10,}/ },
|
|
77
128
|
{ name: 'Generic bearer', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
|
|
@@ -84,7 +135,7 @@ export const RISKY_CONFIG_MARKERS = [
|
|
|
84
135
|
'disable safety', 'bypass approval', 'full access', 'unrestricted',
|
|
85
136
|
];
|
|
86
137
|
|
|
87
|
-
// ── PII (
|
|
138
|
+
// ── PII (patterns + Luhn gate) ──
|
|
88
139
|
export const PII_PATTERNS = [
|
|
89
140
|
{ name: 'Email address', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
|
|
90
141
|
{ name: 'US SSN', re: /\b\d{3}-\d{2}-\d{4}\b/ },
|
|
@@ -92,6 +143,10 @@ export const PII_PATTERNS = [
|
|
|
92
143
|
{ name: 'Phone number', re: /\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b/ },
|
|
93
144
|
{ 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/ },
|
|
94
145
|
];
|
|
146
|
+
// Reserved / RFC-1918 / doc / public-DNS IPs (not personal data), and a version
|
|
147
|
+
// context ("v1.0.0.0") that merely looks like an IP.
|
|
148
|
+
const RESERVED_IPV4 = /^(0\.|255\.255\.255\.255|127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|8\.8\.(8\.8|4\.4)|1\.1\.1\.1|1\.0\.0\.1|224\.)/;
|
|
149
|
+
const VERSION_CONTEXT = /\b(v|ver|version|release|rev|build|semver|tag)\.?\s*$/i;
|
|
95
150
|
|
|
96
151
|
// Luhn check keeps the loose credit-card regex from firing on any digit run.
|
|
97
152
|
function luhnValid(value) {
|
|
@@ -123,8 +178,67 @@ export function containsAny(haystack, needles) {
|
|
|
123
178
|
return null;
|
|
124
179
|
}
|
|
125
180
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
181
|
+
// Like containsAny, but the needle must START at a word boundary — 'aws' must
|
|
182
|
+
// not fire inside "flaws", 'cat ' inside "concat ", 'token' is fine ("tokens"
|
|
183
|
+
// still hits: only the START is guarded, because these lists match prose where
|
|
184
|
+
// words inflect at the end). Mirrors the backend's containsWord.
|
|
185
|
+
const WORD_RE_CACHE = new Map();
|
|
186
|
+
function leadingBoundaryRe(needle) {
|
|
187
|
+
let re = WORD_RE_CACHE.get(needle);
|
|
188
|
+
if (!re) {
|
|
189
|
+
const esc = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
190
|
+
re = new RegExp(/^\w/.test(needle) ? `(?<!\\w)${esc}` : esc, 'i');
|
|
191
|
+
WORD_RE_CACHE.set(needle, re);
|
|
192
|
+
}
|
|
193
|
+
return re;
|
|
194
|
+
}
|
|
195
|
+
export function containsWord(haystack, needles) {
|
|
196
|
+
const h = String(haystack ?? '');
|
|
197
|
+
for (const n of needles) if (leadingBoundaryRe(n).test(h)) return n;
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── risky-config: mention vs configuration ──
|
|
202
|
+
// `\w`-only boundaries, NOT `[\w-]`: markers legitimately butt against dashes
|
|
203
|
+
// (--dangerously-skip-permissions), so excluding '-' would suppress the flag
|
|
204
|
+
// form; excluding `\w` is what stops 'dangerously' firing on
|
|
205
|
+
// dangerouslySetInnerHTML. Mirrors the backend (checks/text-inspector.ts).
|
|
206
|
+
const MARKER_RE_CACHE = new Map();
|
|
207
|
+
function markerRe(marker) {
|
|
208
|
+
let re = MARKER_RE_CACHE.get(marker);
|
|
209
|
+
if (!re) {
|
|
210
|
+
re = new RegExp(`(?<!\\w)${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\w)`, 'gi');
|
|
211
|
+
MARKER_RE_CACHE.set(marker, re);
|
|
212
|
+
}
|
|
213
|
+
re.lastIndex = 0; // shared instance: an early return leaves lastIndex dirty
|
|
214
|
+
return re;
|
|
215
|
+
}
|
|
216
|
+
const FLAG_BEFORE = /(?:^|\s)--?[\w-]*$/; // --yolo, --dangerously-skip-permissions
|
|
217
|
+
const ENABLE_AFTER = /^["'`\]]?\s*[:=]/; // "yolo": true, AUTO_APPROVE=1
|
|
218
|
+
const ENABLE_BEFORE = /[:=]\s*["'`\[]?\s*$/; // "mode": "unrestricted" — one delimiter; two (`= ['`) is a definition LIST
|
|
219
|
+
function isEnablement(text, at, len) {
|
|
220
|
+
const before = text.slice(Math.max(0, at - 24), at);
|
|
221
|
+
const after = text.slice(at + len, at + len + 12);
|
|
222
|
+
return FLAG_BEFORE.test(before) || ENABLE_AFTER.test(after) || ENABLE_BEFORE.test(before);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* First occurrence of a risky-config marker that reads as a setting being
|
|
226
|
+
* ENABLED (word-bounded + enablement-shaped), or null. Unlike the backend twin
|
|
227
|
+
* this does NOT suppress on the mask: the CLI mask is binary (string ≡ comment),
|
|
228
|
+
* and JSON config keys ARE string literals — the hooks' codeContext downrank
|
|
229
|
+
* handles the literal/comment case instead.
|
|
230
|
+
*/
|
|
231
|
+
function riskyConfigHit(text, marker) {
|
|
232
|
+
const re = markerRe(marker);
|
|
233
|
+
let m;
|
|
234
|
+
while ((m = re.exec(text)) !== null) {
|
|
235
|
+
if (isEnablement(text, m.index, m[0].length)) return { start: m.index, end: m.index + m[0].length };
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Attacker-controlled data sinks — a tool call/result referencing one is an
|
|
241
|
+
// exfiltration endpoint.
|
|
128
242
|
export const SUSPICIOUS_EGRESS_HOSTS = [
|
|
129
243
|
'webhook.site', 'requestbin', 'pipedream.net', 'ngrok.io', 'ngrok-free.app', 'ngrok.app',
|
|
130
244
|
'trycloudflare.com', 'serveo.net', 'localhost.run', 'interact.sh', 'oastify.com', 'oast.pro',
|
|
@@ -138,7 +252,7 @@ export const SUSPICIOUS_EGRESS_HOSTS = [
|
|
|
138
252
|
const SEV_RANK = { INFO: 1, LOW: 2, MEDIUM: 3, HIGH: 4, CRITICAL: 5 };
|
|
139
253
|
|
|
140
254
|
// Decode suspicious base64 blobs so payloads hidden in an "echo <blob>|base64 -d|sh"
|
|
141
|
-
// trick are inspected too.
|
|
255
|
+
// trick are inspected too. Decoding is purely to READ the bytes; nothing runs.
|
|
142
256
|
const BASE64_BLOB_RE = /\b[A-Za-z0-9+/]{32,}={0,2}/g;
|
|
143
257
|
const DECODED_PAYLOAD_RE = /(\/bin\/(ba|z|k)?sh|\b(ba|z|k)?sh\s+-c|\bcurl\b|\bwget\b|\beval\b|\bexec\b|https?:\/\/|invoke-expression|\biex\b|powershell|\bnc\b|\bncat\b|\bchmod\b|\bbase64\b)/i;
|
|
144
258
|
function deobfuscate(text) {
|
|
@@ -154,11 +268,26 @@ function deobfuscate(text) {
|
|
|
154
268
|
return { text: decoded.length ? `${text}\n${decoded.join('\n')}` : text, decodedPayload: decoded.length > 0 };
|
|
155
269
|
}
|
|
156
270
|
|
|
157
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* Reference to a known exfiltration sink host, or null. Host-boundary matched,
|
|
273
|
+
* NOT a raw substring — `includes('ix.io')` fired inside "matrix.io" and
|
|
274
|
+
* `includes('file.io')` inside "profile.io", and this feeds a HIGH/FLAG on live
|
|
275
|
+
* tool calls. The char before must not be a host label char (a leading '.' IS
|
|
276
|
+
* allowed so "paste.c-net.org" still hits); the char after must end the host.
|
|
277
|
+
*/
|
|
278
|
+
const EGRESS_RE_CACHE = new Map();
|
|
279
|
+
function egressHostRe(host) {
|
|
280
|
+
let re = EGRESS_RE_CACHE.get(host);
|
|
281
|
+
if (!re) {
|
|
282
|
+
re = new RegExp(`(^|[^a-z0-9-])${host.replace(/[.]/g, '\\.')}($|[^a-z0-9.-])`, 'i');
|
|
283
|
+
EGRESS_RE_CACHE.set(host, re);
|
|
284
|
+
}
|
|
285
|
+
return re;
|
|
286
|
+
}
|
|
158
287
|
export function egressHost(text) {
|
|
159
288
|
if (!text) return null;
|
|
160
289
|
const low = text.toLowerCase();
|
|
161
|
-
return SUSPICIOUS_EGRESS_HOSTS.find((h) =>
|
|
290
|
+
return SUSPICIOUS_EGRESS_HOSTS.find((h) => egressHostRe(h).test(low)) ?? null;
|
|
162
291
|
}
|
|
163
292
|
|
|
164
293
|
/** 1-based line number of a character offset inside `text`. */
|
|
@@ -215,13 +344,8 @@ function codeMask(text) {
|
|
|
215
344
|
while (i < n) {
|
|
216
345
|
const c = text[i], c2 = text[i + 1];
|
|
217
346
|
if (state === 0) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (c === '`') { state = 3; mask[i++] = 1; continue; }
|
|
221
|
-
if (c === '/' && c2 === '/') { state = 4; mask[i++] = 1; continue; }
|
|
222
|
-
if (c === '#' && (i === 0 || /\s/.test(text[i - 1]))) { state = 4; mask[i++] = 1; continue; }
|
|
223
|
-
if (c === '/' && c2 === '*') { state = 5; mask[i++] = 1; continue; }
|
|
224
|
-
if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] = 1; continue; }
|
|
347
|
+
// The fence test MUST precede the backtick-string test, or ``` is consumed
|
|
348
|
+
// as a template-literal opener and the fence handler below never runs.
|
|
225
349
|
if (text.startsWith('```', i) || text.startsWith('~~~', i)) { // fenced block → mask the whole span, delimiters included
|
|
226
350
|
const fence = text.slice(i, i + 3);
|
|
227
351
|
const nl = text.indexOf('\n', i);
|
|
@@ -234,6 +358,13 @@ function codeMask(text) {
|
|
|
234
358
|
for (let k = i; k < end; k++) mask[k] = 1;
|
|
235
359
|
prevSig = ''; i = end; continue;
|
|
236
360
|
}
|
|
361
|
+
if (c === "'") { state = 1; mask[i++] = 1; continue; }
|
|
362
|
+
if (c === '"') { state = 2; mask[i++] = 1; continue; }
|
|
363
|
+
if (c === '`') { state = 3; mask[i++] = 1; continue; }
|
|
364
|
+
if (c === '/' && c2 === '/') { state = 4; mask[i++] = 1; continue; }
|
|
365
|
+
if (c === '#' && (i === 0 || /\s/.test(text[i - 1]))) { state = 4; mask[i++] = 1; continue; }
|
|
366
|
+
if (c === '/' && c2 === '*') { state = 5; mask[i++] = 1; continue; }
|
|
367
|
+
if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] = 1; continue; }
|
|
237
368
|
if (c === '/' && REGEX_START.has(prevSig)) { state = 7; inClass = false; mask[i++] = 1; continue; }
|
|
238
369
|
if (!/\s/.test(c)) prevSig = c;
|
|
239
370
|
i++;
|
|
@@ -305,12 +436,27 @@ export function localScan(text, opts = {}) {
|
|
|
305
436
|
if (cats.includes('shell')) {
|
|
306
437
|
const aug = deobfuscate(t);
|
|
307
438
|
if (aug.decodedPayload) findings.push({ label: 'Base64-encoded shell / RCE payload', severity: 'CRITICAL', category: 'shell' });
|
|
308
|
-
for (const sig of DANGEROUS_SHELL) if (sig
|
|
439
|
+
for (const sig of DANGEROUS_SHELL) if (matchesShellSignal(sig, aug.text)) findings.push({ label: sig.name, severity: sig.severity, category: 'shell', ...locate(t, sig.re, mask) });
|
|
309
440
|
}
|
|
310
441
|
if (cats.includes('injection')) {
|
|
311
442
|
const low = t.toLowerCase();
|
|
312
|
-
|
|
313
|
-
|
|
443
|
+
// First NON-NEGATED phrase (a negation right before flips it into a hardening
|
|
444
|
+
// rule — "never ignore previous instructions").
|
|
445
|
+
for (const p of INJECTION_PHRASES) {
|
|
446
|
+
const at = low.indexOf(p);
|
|
447
|
+
if (at < 0) continue;
|
|
448
|
+
if (PRECEDING_NEGATION.test(t.slice(Math.max(0, at - 20), at))) continue;
|
|
449
|
+
findings.push({ label: `Injected instruction: "${p}"`, severity: 'HIGH', category: 'injection', ...locate(t, p, mask) });
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
for (const { label, re } of INJECTION_REGEXES) {
|
|
453
|
+
const m = t.match(re);
|
|
454
|
+
if (!m) continue;
|
|
455
|
+
const at = m.index ?? 0;
|
|
456
|
+
if (PRECEDING_NEGATION.test(t.slice(Math.max(0, at - 20), at))) continue;
|
|
457
|
+
if (label === 'Bulk destructive command' && BUILD_ARTIFACT.test(m[0])) continue; // build/test cleanup
|
|
458
|
+
findings.push({ label, severity: 'HIGH', category: 'injection', ...locate(t, re, mask) });
|
|
459
|
+
}
|
|
314
460
|
if (INVISIBLE_CHARS_RE.test(t)) findings.push({ label: 'Invisible / zero-width characters', severity: 'MEDIUM', category: 'injection', ...locate(t, INVISIBLE_CHARS_RE, mask) });
|
|
315
461
|
}
|
|
316
462
|
if (cats.includes('secret')) {
|
|
@@ -321,12 +467,30 @@ export function localScan(text, opts = {}) {
|
|
|
321
467
|
const m = t.match(re);
|
|
322
468
|
if (!m) continue;
|
|
323
469
|
if (name === 'Credit card number' && !luhnValid(m[0])) continue; // gate the loose CC regex
|
|
470
|
+
// Infra / reserved / doc / public-DNS IPs and version strings ("v1.0.0.0")
|
|
471
|
+
// are not personal data.
|
|
472
|
+
if (name === 'IPv4 address') {
|
|
473
|
+
if (RESERVED_IPV4.test(m[0])) continue;
|
|
474
|
+
if (VERSION_CONTEXT.test(t.slice(Math.max(0, (m.index ?? 0) - 12), m.index ?? 0))) continue;
|
|
475
|
+
}
|
|
476
|
+
// A separator-less digit run is an ID / Unix timestamp, not a phone number.
|
|
477
|
+
if (name === 'Phone number' && /^\d+$/.test(m[0])) continue;
|
|
324
478
|
findings.push({ label: `Personal data: ${name}`, severity: 'MEDIUM', category: 'pii', ...locate(t, re, mask) });
|
|
325
479
|
}
|
|
326
480
|
}
|
|
327
481
|
if (cats.includes('config')) {
|
|
328
|
-
|
|
329
|
-
|
|
482
|
+
// A marker counts only where a setting is being TURNED ON — `"yolo": true`,
|
|
483
|
+
// AUTO_APPROVE=1, --dangerously-skip-permissions — not merely named:
|
|
484
|
+
// 'dangerously' inside dangerouslySetInnerHTML, a marker-definition array
|
|
485
|
+
// (this very file), "yolo mode" in prose. Word-bounded + enablement-gated,
|
|
486
|
+
// skipping comment/fence mentions; mirrors the backend's riskyConfigHit.
|
|
487
|
+
for (const m of RISKY_CONFIG_MARKERS) {
|
|
488
|
+
const hit = riskyConfigHit(t, m);
|
|
489
|
+
if (hit) {
|
|
490
|
+
findings.push({ label: `Risky setting: "${m}"`, severity: 'MEDIUM', category: 'config', line: lineAt(t, hit.start), codeContext: mask[hit.start] === 1 });
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
330
494
|
}
|
|
331
495
|
if (cats.includes('egress')) {
|
|
332
496
|
const h = egressHost(t);
|
|
@@ -352,7 +516,7 @@ export function downrankCodeContext(findings) {
|
|
|
352
516
|
}
|
|
353
517
|
|
|
354
518
|
// ── local artifact gate (offline `shomra gate`) ──
|
|
355
|
-
// Social-engineering "install-lure" prose
|
|
519
|
+
// Social-engineering "install-lure" prose.
|
|
356
520
|
const INSTALL_LURE = [
|
|
357
521
|
{ name: 'Instructs downloading an executable/archive to run', re: /\b(download|install|fetch|grab|extract)\b[^\n]{0,180}\.(zip|exe|dmg|pkg|msi|bin|appimage|jar|scr|apk|deb|rpm|tar\.gz|tgz)\b/i, severity: 'MEDIUM' },
|
|
358
522
|
{ name: 'Password-protected archive (evades AV / scanners)', re: /\b(extract|unzip|decompress|archive|zip|password)\b[^\n]{0,50}\b(pass(word|phrase)?|pwd)\s*[:=]\s*\S/i, severity: 'HIGH' },
|
|
@@ -360,7 +524,7 @@ const INSTALL_LURE = [
|
|
|
360
524
|
{ name: 'Coercion: re-run / retry until it succeeds', re: /\b(re-?run (if needed|until|the command)|run (it |the command )?again|try again after)/i, severity: 'LOW' },
|
|
361
525
|
];
|
|
362
526
|
|
|
363
|
-
// ── typosquat / malicious-package intel
|
|
527
|
+
// ── typosquat / malicious-package intel ──
|
|
364
528
|
const MALICIOUS_PACKAGE_SEED = new Set([
|
|
365
529
|
'event-stream', 'eslint-scope-malware', 'electron-native-notify', 'rc-malware',
|
|
366
530
|
'crossenv', 'mongose', 'expresss',
|
|
@@ -447,7 +611,7 @@ function frontmatter(text) {
|
|
|
447
611
|
return data;
|
|
448
612
|
}
|
|
449
613
|
|
|
450
|
-
// ── structured MCP-config checks
|
|
614
|
+
// ── structured MCP-config checks ──
|
|
451
615
|
// Parses the JSON and inspects each server: plaintext HTTP (weak auth), a
|
|
452
616
|
// hard-coded secret in the env block / launch line, and a typosquat / known-
|
|
453
617
|
// malicious launch package — structural findings a raw-text scan can't produce.
|
|
@@ -486,7 +650,7 @@ function localMcp(content) {
|
|
|
486
650
|
return out;
|
|
487
651
|
}
|
|
488
652
|
|
|
489
|
-
// ── structured agent-card checks
|
|
653
|
+
// ── structured agent-card checks ──
|
|
490
654
|
// Grades every URL the card declares (assessUrl: metadata SSRF, private-network
|
|
491
655
|
// pivot, plaintext, raw IP) and flags a public card with no auth scheme.
|
|
492
656
|
function localAgentCard(content) {
|
|
@@ -518,7 +682,7 @@ function localAgentCard(content) {
|
|
|
518
682
|
return out;
|
|
519
683
|
}
|
|
520
684
|
|
|
521
|
-
// ── slash-command extras (
|
|
685
|
+
// ── slash-command extras (`!`-bang + `@`-file) ──
|
|
522
686
|
function localCommandExtras(content) {
|
|
523
687
|
const out = [];
|
|
524
688
|
const body = content || '';
|
|
@@ -533,7 +697,7 @@ function localCommandExtras(content) {
|
|
|
533
697
|
return out;
|
|
534
698
|
}
|
|
535
699
|
|
|
536
|
-
// ── memory / rules poisoning
|
|
700
|
+
// ── memory / rules poisoning ──
|
|
537
701
|
// A persistent memory note or an AI rules file (CLAUDE.md, .cursorrules, …) is
|
|
538
702
|
// re-injected as high-authority context every session. This grades the two by a
|
|
539
703
|
// different baseline: MEMORY should record facts (any standing directive is
|
|
@@ -587,7 +751,7 @@ function scanDirectives(text) {
|
|
|
587
751
|
* 'MEMORY' (agent-writable scratchpad — any standing directive is anomalous) or
|
|
588
752
|
* 'INSTRUCTION' (curated rules file — only universally-malicious signals count).
|
|
589
753
|
* Returns findings shaped like localGate's ({ severity, title, remediationText,
|
|
590
|
-
* line }).
|
|
754
|
+
* line }).
|
|
591
755
|
*/
|
|
592
756
|
export function localMemory(content, { kind = 'MEMORY' } = {}) {
|
|
593
757
|
const text = content || '';
|
|
@@ -622,10 +786,10 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
|
|
|
622
786
|
|
|
623
787
|
// Executable payload / egress sink / lifecycle-hook references have no business
|
|
624
788
|
// in a note or rules file.
|
|
625
|
-
for (const sig of DANGEROUS_SHELL) if (sig
|
|
789
|
+
for (const sig of DANGEROUS_SHELL) if (matchesShellSignal(sig, text)) { push(sig.severity === 'MEDIUM' || sig.severity === 'LOW' ? 'HIGH' : 'CRITICAL', `Executable payload staged in ${noun}: ${sig.name}`, `Delete the command from the ${noun}; treat the writer as untrusted.`, sig.re); break; }
|
|
626
790
|
const host = egressHost(text);
|
|
627
791
|
if (host) push('HIGH', `${isInstruction ? 'Rules file' : 'Memory'} references a data-exfiltration host (${host})`, 'Remove the reference and roll back to the approved baseline.', host);
|
|
628
|
-
if (hasImperative &&
|
|
792
|
+
if (hasImperative && containsWord(text, SENSITIVE_READ) && containsWord(text, NETWORK_VERBS)) {
|
|
629
793
|
push('HIGH', `Toxic instruction in ${noun}: reads sensitive data + reaches the network`, 'Remove the entry; gate any network step behind explicit approval and an egress allow-list.');
|
|
630
794
|
}
|
|
631
795
|
if (LIFECYCLE_VECTOR.test(text)) push('MEDIUM', `${isInstruction ? 'Rules file' : 'Memory'} references a package-lifecycle hook (MemoryTrap vector)`, 'Verify no dependency writes to this store during install; pin dependencies and audit lifecycle scripts.', LIFECYCLE_VECTOR);
|
|
@@ -645,7 +809,7 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
|
|
|
645
809
|
return findings.filter((f) => (seen.has(f.title) ? false : (seen.add(f.title), true)));
|
|
646
810
|
}
|
|
647
811
|
|
|
648
|
-
// Basenames of AI rules / instruction files
|
|
812
|
+
// Basenames of AI rules / instruction files.
|
|
649
813
|
const INSTRUCTION_BASENAMES = new Set([
|
|
650
814
|
'claude.md', 'agents.md', 'agent.md', 'gemini.md', 'llms.txt', 'llms-full.txt',
|
|
651
815
|
'.cursorrules', '.windsurfrules', '.clinerules', '.aiderrules', '.continuerules',
|
|
@@ -734,8 +898,8 @@ export function localGate(content, { kind, path } = {}) {
|
|
|
734
898
|
}
|
|
735
899
|
|
|
736
900
|
// Deterministic verdict + 0–100 risk score for a set of findings, aligned with
|
|
737
|
-
// the server default policy
|
|
738
|
-
//
|
|
901
|
+
// the server default policy: any CRITICAL → BLOCK, any HIGH → FLAG.
|
|
902
|
+
// Exported so callers that fold in extra findings (e.g.
|
|
739
903
|
// the CLI merging bundled-script SAST hits) re-grade the same way.
|
|
740
904
|
export function grade(findings) {
|
|
741
905
|
const WEIGHT = { INFO: 2, LOW: 8, MEDIUM: 20, HIGH: 40, CRITICAL: 70 };
|
package/model-refs.mjs
CHANGED
|
@@ -43,12 +43,20 @@ const TORCH_HUB = /torch\.hub\.load\s*\(\s*['"]([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][
|
|
|
43
43
|
|
|
44
44
|
// Reject ids that are really file paths, packages, or non-model strings.
|
|
45
45
|
const ASSET_EXT = /\.(py|pyc|ipynb|[mc]?[jt]sx?|json|ya?ml|toml|txt|md|lock|cfg|ini|sh|env|png|jpg|svg|css|html?|csv|tsv|parquet)$/i;
|
|
46
|
+
// First path segment on huggingface.co that is a SITE section, not an org — so
|
|
47
|
+
// huggingface.co/docs/datasets isn't mistaken for the model "docs/datasets".
|
|
48
|
+
const NONMODEL_ORGS = new Set([
|
|
49
|
+
'docs', 'blog', 'spaces', 'datasets', 'models', 'join', 'login', 'settings',
|
|
50
|
+
'pricing', 'tasks', 'learn', 'papers', 'collections', 'organizations', 'new',
|
|
51
|
+
'search', 'chat', 'posts', 'enterprise', 'inference-endpoints',
|
|
52
|
+
]);
|
|
46
53
|
function looksLikeModelId(id) {
|
|
47
54
|
if (!id || id.startsWith('@') || id.startsWith('.') || id.startsWith('/')) return false;
|
|
48
55
|
if (id.includes('..') || id.split('/').length !== 2) return false;
|
|
49
56
|
if (ASSET_EXT.test(id)) return false; // a model id never ends in a code/asset ext
|
|
50
57
|
const [a, b] = id.split('/');
|
|
51
58
|
if (!/[A-Za-z]/.test(a) || !/[A-Za-z]/.test(b)) return false; // kills "123/456"
|
|
59
|
+
if (NONMODEL_ORGS.has(a.toLowerCase())) return false; // site path, not an org
|
|
52
60
|
return true;
|
|
53
61
|
}
|
|
54
62
|
// A bare id (no org) from a high-confidence position — accept unless it's clearly
|
package/package.json
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Shomra — a local-first security scanner and runtime firewall for AI agents, MCP servers, prompts, and models. Gates AI artifacts in your editor and CI.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"shomra": "./shomra.mjs"
|
|
8
|
-
},
|
|
9
|
-
"engines": {
|
|
10
|
-
"node": ">=18"
|
|
11
|
-
},
|
|
12
|
-
"scripts": {
|
|
13
|
-
"test": "node --test
|
|
14
|
-
},
|
|
15
|
-
"files": [
|
|
16
|
-
"shomra.mjs",
|
|
17
|
-
"discovery.mjs",
|
|
18
|
-
"guard-signals.mjs",
|
|
19
|
-
"code-sast.mjs",
|
|
20
|
-
"model-refs.mjs",
|
|
21
|
-
"README.md",
|
|
22
|
-
"LICENSE",
|
|
23
|
-
"NOTICE"
|
|
24
|
-
],
|
|
25
|
-
"keywords": [
|
|
26
|
-
"ai-security",
|
|
27
|
-
"mcp",
|
|
28
|
-
"prompt-injection",
|
|
29
|
-
"sast",
|
|
30
|
-
"static-analysis",
|
|
31
|
-
"supply-chain",
|
|
32
|
-
"ci",
|
|
33
|
-
"sarif",
|
|
34
|
-
"llm",
|
|
35
|
-
"agent-security",
|
|
36
|
-
"devsecops"
|
|
37
|
-
],
|
|
38
|
-
"homepage": "https://shomra.ai",
|
|
39
|
-
"repository": {
|
|
40
|
-
"type": "git",
|
|
41
|
-
"url": "git+https://github.com/shomra-org/agent.git"
|
|
42
|
-
},
|
|
43
|
-
"bugs": {
|
|
44
|
-
"url": "https://github.com/shomra-org/agent/issues"
|
|
45
|
-
},
|
|
46
|
-
"author": "Shomra",
|
|
47
|
-
"license": "Apache-2.0",
|
|
48
|
-
"publishConfig": {
|
|
49
|
-
"access": "public"
|
|
50
|
-
}
|
|
51
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@shomra/agent",
|
|
3
|
+
"version": "0.2.7",
|
|
4
|
+
"description": "Shomra — a local-first security scanner and runtime firewall for AI agents, MCP servers, prompts, and models. Gates AI artifacts in your editor and CI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"shomra": "./shomra.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"shomra.mjs",
|
|
17
|
+
"discovery.mjs",
|
|
18
|
+
"guard-signals.mjs",
|
|
19
|
+
"code-sast.mjs",
|
|
20
|
+
"model-refs.mjs",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"NOTICE"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"ai-security",
|
|
27
|
+
"mcp",
|
|
28
|
+
"prompt-injection",
|
|
29
|
+
"sast",
|
|
30
|
+
"static-analysis",
|
|
31
|
+
"supply-chain",
|
|
32
|
+
"ci",
|
|
33
|
+
"sarif",
|
|
34
|
+
"llm",
|
|
35
|
+
"agent-security",
|
|
36
|
+
"devsecops"
|
|
37
|
+
],
|
|
38
|
+
"homepage": "https://shomra.ai",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/shomra-org/agent.git"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/shomra-org/agent/issues"
|
|
45
|
+
},
|
|
46
|
+
"author": "Shomra",
|
|
47
|
+
"license": "Apache-2.0",
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/shomra.mjs
CHANGED
|
@@ -15,12 +15,25 @@ import path from 'node:path';
|
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import crypto from 'node:crypto';
|
|
17
17
|
import { execSync } from 'node:child_process';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
18
19
|
import { discoverAll } from './discovery.mjs';
|
|
19
20
|
import { localScan, localGate, grade, downrankCodeContext, SECRET_PATTERNS } from './guard-signals.mjs';
|
|
20
21
|
import { scanSourceFile, isScannableSource, isModelConfig } from './code-sast.mjs';
|
|
21
22
|
import { scanModelRefs, isModelRefScannable } from './model-refs.mjs';
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
// Read from package.json rather than hardcoding: the two spellings drifted (this
|
|
25
|
+
// const said 0.2.0 while the package was already 0.2.4), so `shomra --version`
|
|
26
|
+
// and the `x-shomra-agent` header both under-reported the running build — which
|
|
27
|
+
// is exactly the value you need to trust when triaging a bad scan in the field.
|
|
28
|
+
// Falls back to the package version being unreadable rather than crashing the CLI.
|
|
29
|
+
const VERSION = (() => {
|
|
30
|
+
try {
|
|
31
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
return JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf8')).version ?? '0.0.0';
|
|
33
|
+
} catch {
|
|
34
|
+
return '0.0.0';
|
|
35
|
+
}
|
|
36
|
+
})();
|
|
24
37
|
const CONFIG_DIR = path.join(os.homedir(), '.shomra');
|
|
25
38
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
26
39
|
|
|
@@ -310,7 +323,7 @@ async function sendReport(cfg, assets, flags) {
|
|
|
310
323
|
}
|
|
311
324
|
|
|
312
325
|
// Human name for a key scope, inferred from its prefix. Accepts the current
|
|
313
|
-
// `shm_` prefix and the legacy
|
|
326
|
+
// `shm_` prefix and the legacy `dgx_` one, so older keys keep working.
|
|
314
327
|
function keyScope(key) {
|
|
315
328
|
if (!key) return null;
|
|
316
329
|
if (/^(shm|dgx)_gw_/.test(key)) return 'gateway';
|
|
@@ -324,7 +337,7 @@ function cmdStatus() {
|
|
|
324
337
|
const enrolled = !!apiKey;
|
|
325
338
|
console.log(bold(cyan('\n Shomra agent')) + dim(` v${VERSION}`));
|
|
326
339
|
|
|
327
|
-
// Mode banner —
|
|
340
|
+
// Mode banner — what works right now, and what a key adds.
|
|
328
341
|
if (enrolled) {
|
|
329
342
|
console.log(` ${dim('Mode ')} ${green('● Enrolled')} ${dim(`(${keyScope(apiKey)} key)`)} — org policy, platform AI & dashboard telemetry active`);
|
|
330
343
|
} else {
|
|
@@ -473,7 +486,7 @@ async function cmdGate(flags, positional) {
|
|
|
473
486
|
const cfg = loadConfig();
|
|
474
487
|
const { apiKey, url } = resolveSettings(cfg);
|
|
475
488
|
|
|
476
|
-
// Batch mode: gate every AI artifact under a directory
|
|
489
|
+
// Batch mode: gate every AI artifact under a directory.
|
|
477
490
|
if (flags.all) {
|
|
478
491
|
return cmdGateAll(flags, positional, { apiKey, url });
|
|
479
492
|
}
|
|
@@ -576,7 +589,7 @@ async function cmdGate(flags, positional) {
|
|
|
576
589
|
// usual env var (the SDK sends it; Shomra passes it through) — or set the org
|
|
577
590
|
// key on the backend and use your shm_ key as the provider key.
|
|
578
591
|
//
|
|
579
|
-
// Providers mirror the backend registry
|
|
592
|
+
// Providers mirror the backend registry:
|
|
580
593
|
// openai + every OpenAI-compatible API (groq/mistral/xai/deepseek/openrouter/
|
|
581
594
|
// together) speak the OpenAI wire format; anthropic and gemini have their own.
|
|
582
595
|
|
|
@@ -1041,9 +1054,8 @@ async function gateArtifactList(artifacts, { apiKey, url, env, flags, root }) {
|
|
|
1041
1054
|
prepared.push({ a, content, local, sast });
|
|
1042
1055
|
}
|
|
1043
1056
|
|
|
1044
|
-
// ── Phase 2: backend enrich, BOUNDED-PARALLEL
|
|
1045
|
-
//
|
|
1046
|
-
// calls (a down backend never costs N timeouts).
|
|
1057
|
+
// ── Phase 2: backend enrich, BOUNDED-PARALLEL. Order-preserving; on the first
|
|
1058
|
+
// outage stop starting new calls (a down backend never costs N timeouts).
|
|
1047
1059
|
const server = new Array(prepared.length).fill(null);
|
|
1048
1060
|
if (apiKey) {
|
|
1049
1061
|
const conc = clampInt(process.env.SHOMRA_GATE_CONCURRENCY, 8, 1, 32);
|
|
@@ -1348,7 +1360,6 @@ function gitChangedVsBase(root, base) {
|
|
|
1348
1360
|
const GH_LEVEL = { CRITICAL: 'failure', HIGH: 'failure', MEDIUM: 'warning', LOW: 'notice', INFO: 'notice' };
|
|
1349
1361
|
|
|
1350
1362
|
async function cmdPr(flags, positional) {
|
|
1351
|
-
// Scaffold the workflow and exit.
|
|
1352
1363
|
if (flags.init) {
|
|
1353
1364
|
const wf = path.resolve('.github/workflows/shomra.yml');
|
|
1354
1365
|
if (fs.existsSync(wf) && !flags.force) { console.error(red('✗') + ` ${path.relative(process.cwd(), wf)} exists. Use ${bold('--force')}.`); process.exit(1); }
|
|
@@ -2106,8 +2117,8 @@ async function cmdMemoryScan(flags, positional) {
|
|
|
2106
2117
|
//
|
|
2107
2118
|
// Replays the adversarial scenario library against your OWN LLM Guard (probe
|
|
2108
2119
|
// mode — nothing is persisted as a real attack) or model, scores resilience,
|
|
2109
|
-
// and flags regressions vs the previous run.
|
|
2110
|
-
//
|
|
2120
|
+
// and flags regressions vs the previous run. In CI, gate a merge/deploy on
|
|
2121
|
+
// `--min <resilience>` and/or `--fail-on-regression`. Exit: 0 = pass,
|
|
2111
2122
|
// 2 = below the resilience floor or a regression appeared.
|
|
2112
2123
|
|
|
2113
2124
|
async function cmdRedteam(flags) {
|
|
@@ -2241,7 +2252,7 @@ async function cmdCampaign(flags) {
|
|
|
2241
2252
|
// signatures for whatever breached, verifies each against a benign corpus (must
|
|
2242
2253
|
// catch the attack AND cause zero false positives), and — with --apply — pushes
|
|
2243
2254
|
// the survivors live as a SignaturePack (no redeploy) and re-runs to prove the
|
|
2244
|
-
// resilience lift.
|
|
2255
|
+
// resilience lift. Suited to a scheduled CI step after `shomra redteam`.
|
|
2245
2256
|
async function cmdHarden(flags) {
|
|
2246
2257
|
const cfg = loadConfig();
|
|
2247
2258
|
const { apiKey, url } = resolveSettings(cfg);
|
|
@@ -2303,8 +2314,8 @@ async function cmdHarden(flags) {
|
|
|
2303
2314
|
// distinct principal and authorize every call against its capability policy.
|
|
2304
2315
|
// Present the handle via SHOMRA_AGENT (or --agent-id); govern its capabilities,
|
|
2305
2316
|
// approve break-glass requests and revoke it (a live kill-switch) in the
|
|
2306
|
-
// dashboard. Listing/governing is JWT-only
|
|
2307
|
-
//
|
|
2317
|
+
// dashboard. Listing/governing is JWT-only — not exposed to a machine key — so
|
|
2318
|
+
// the CLI only self-registers.
|
|
2308
2319
|
async function cmdAgentIdentity(flags, positional) {
|
|
2309
2320
|
const sub = (positional[0] || 'register').toLowerCase();
|
|
2310
2321
|
const cfg = loadConfig();
|
|
@@ -2375,14 +2386,13 @@ function resolveAgentIdentityHandle(flags) {
|
|
|
2375
2386
|
// Shomra LLM Guard proxy instead of a tool hook (.aider.conf.yml).
|
|
2376
2387
|
// `shomra install-hook --agent <name>` writes the right shape; the installed
|
|
2377
2388
|
// hook command carries `--agent <name>` so tool-guard/result-guard know which
|
|
2378
|
-
// contract to speak at runtime. Default agent is `claude
|
|
2379
|
-
//
|
|
2389
|
+
// contract to speak at runtime. Default agent is `claude`, so an unqualified
|
|
2390
|
+
// hook invocation keeps working.
|
|
2380
2391
|
//
|
|
2381
|
-
//
|
|
2382
|
-
// firing
|
|
2383
|
-
//
|
|
2384
|
-
//
|
|
2385
|
-
// the backend is down) — set SHOMRA_GUARD_STRICT=1 to fail closed.
|
|
2392
|
+
// Vendor hook schemas change often, and a drifted schema shows up as a hook that
|
|
2393
|
+
// silently stops firing. Each adapter below is isolated so tracking a change is a
|
|
2394
|
+
// local edit. Fail-OPEN by default (never break the session if the backend is
|
|
2395
|
+
// down) — set SHOMRA_GUARD_STRICT=1 to fail closed.
|
|
2386
2396
|
|
|
2387
2397
|
const AGENT_LABELS = {
|
|
2388
2398
|
claude: 'Claude Code',
|
|
@@ -2563,7 +2573,7 @@ const AGENT_INSTALLERS = {
|
|
|
2563
2573
|
// Cline (VS Code) is tool-dispatching like Claude Code, so it gets a real
|
|
2564
2574
|
// blocking pre/post hook in the same grouped shape. Matcher covers Cline's
|
|
2565
2575
|
// tool vocabulary (execute_command/write_to_file/replace_in_file/use_mcp_tool),
|
|
2566
|
-
// all of which
|
|
2576
|
+
// all of which the server-side guard already recognises.
|
|
2567
2577
|
cline(global) {
|
|
2568
2578
|
const dir = global ? path.join(os.homedir(), '.cline') : path.join(process.cwd(), '.cline');
|
|
2569
2579
|
const file = path.join(dir, 'hooks.json');
|
|
@@ -2610,9 +2620,9 @@ const AGENT_INSTALLERS = {
|
|
|
2610
2620
|
};
|
|
2611
2621
|
|
|
2612
2622
|
// Normalize each agent's own hook payload into the {tool_name, tool_input,
|
|
2613
|
-
// tool_response, cwd, session_id} shape
|
|
2614
|
-
//
|
|
2615
|
-
//
|
|
2623
|
+
// tool_response, cwd, session_id} shape the server-side guards already
|
|
2624
|
+
// understand — including Cursor/Cline/Aider-style tool names like
|
|
2625
|
+
// run_terminal_cmd/create_file.
|
|
2616
2626
|
function normalizeGuardInput(agent, payload) {
|
|
2617
2627
|
switch (agent) {
|
|
2618
2628
|
case 'cursor': {
|
|
@@ -2665,7 +2675,7 @@ function normalizeGuardInput(agent, payload) {
|
|
|
2665
2675
|
|
|
2666
2676
|
// ── tiered-guard classification (Tier 0 local vs Tier 2 escalate) ──
|
|
2667
2677
|
// Paths that ARE an AI artifact — a write here is install-time behaviour the
|
|
2668
|
-
// server's full gate must vet against org policy
|
|
2678
|
+
// server's full gate must vet against org policy.
|
|
2669
2679
|
const ARTIFACT_PATH_RE = /(^|\/)(\.?mcp\.json|SKILL\.md|CLAUDE\.md|AGENTS\.md|GEMINI\.md|\.cursorrules|\.windsurfrules|\.aider\.conf\.yml|agent[-_]card\.json)$|(^|\/)\.claude\/(commands|agents)\/[^/]+\.md$|(^|\/)\.claude\/settings(\.local)?\.json$|(^|\/)\.well-known\/agent(-card)?\.json$|(^|\/)\.clinerules|(^|\/)\.github\/copilot-instructions\.md$/i;
|
|
2670
2680
|
const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'create_file', 'str_replace_editor', 'str_replace_based_edit_tool', 'write_to_file', 'replace_in_file', 'new_rule']);
|
|
2671
2681
|
const SHELL_TOOLS_RE = /^(bash|shell|sh|run_command|run_terminal_cmd|execute_command|terminal|exec)$/i;
|
|
@@ -2689,7 +2699,7 @@ function guardText(tool, input) {
|
|
|
2689
2699
|
}
|
|
2690
2700
|
|
|
2691
2701
|
// ── false-positive control: path allowlist for the runtime hooks ──────────────
|
|
2692
|
-
//
|
|
2702
|
+
// Both the static `shomra check` and the runtime firewall honor .shomraignore.
|
|
2693
2703
|
// A dev needs a friction-free way to mark files known-safe (the security tool's
|
|
2694
2704
|
// own detection source, test fixtures, generated code) so a benign pattern in
|
|
2695
2705
|
// source isn't withheld. Two layers, both keyed on the target file path: a repo
|
|
@@ -2833,9 +2843,17 @@ async function screenModelLoad(agent, tool, input, url) {
|
|
|
2833
2843
|
if (!refs.length) return; // modelLookup is cache-first + breaker-aware, so don't bail here
|
|
2834
2844
|
|
|
2835
2845
|
const flagged = [];
|
|
2846
|
+
// ONE budget for the whole screen, not one per ref. This runs inside the
|
|
2847
|
+
// PreToolUse hook, so its cost is added to a tool call the dev is watching: a
|
|
2848
|
+
// file citing five uncached models must not be able to spend 5× the guard
|
|
2849
|
+
// timeout. Cache hits and a tripped breaker short-circuit before any network,
|
|
2850
|
+
// so the common path never touches this.
|
|
2851
|
+
const deadline = Date.now() + guardTimeoutMs();
|
|
2836
2852
|
for (const r of refs) {
|
|
2853
|
+
const left = deadline - Date.now();
|
|
2854
|
+
if (left <= 0) break; // budget spent — flag what we screened, never stall the call
|
|
2837
2855
|
let lk;
|
|
2838
|
-
try { lk = await modelLookup(url, r.id, r.revision); } catch { return; } // uncached + backend down → can't judge, stay silent
|
|
2856
|
+
try { lk = await modelLookup(url, r.id, r.revision, left); } catch { return; } // uncached + backend down → can't judge, stay silent
|
|
2839
2857
|
const findings = (lk && lk.findings) || [];
|
|
2840
2858
|
const worst = findings.reduce((m, f) => Math.max(m, MODEL_SEV_RANK[f.severity] || 0), 0);
|
|
2841
2859
|
const bad = lk && lk.found && (lk.verdict === 'FAIL' || lk.verdict === 'REVIEW' || worst >= MODEL_SEV_RANK.HIGH);
|
|
@@ -3006,6 +3024,26 @@ async function cmdToolGuard(flags) {
|
|
|
3006
3024
|
signal: ctrl.signal,
|
|
3007
3025
|
});
|
|
3008
3026
|
clearTimeout(timer);
|
|
3027
|
+
// fetch() does NOT reject on 4xx/5xx. Without this check a rejected key
|
|
3028
|
+
// returned its error body, r.json() parsed it happily, breakerReset() marked
|
|
3029
|
+
// the backend healthy, res.decision came back undefined — and every
|
|
3030
|
+
// escalated call silently ALLOWed. Org policy off, no error, no breaker, no
|
|
3031
|
+
// signal, indefinitely. Non-2xx must reach the failure path below.
|
|
3032
|
+
if (!r.ok) {
|
|
3033
|
+
// An auth failure is a misconfiguration, not an outage: it will not heal
|
|
3034
|
+
// on its own, so it gets a visible line rather than a 30s breaker cooldown
|
|
3035
|
+
// that would hide it (and skip even this warning on the calls after it).
|
|
3036
|
+
if (r.status === 401 || r.status === 403) {
|
|
3037
|
+
process.stderr.write(
|
|
3038
|
+
`[shomra] guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). ` +
|
|
3039
|
+
`Local Tier-0 screening still ran; org policy, agent identity and flow control did not. ` +
|
|
3040
|
+
`Re-enroll with \`shomra init --key <key>\`.\n`,
|
|
3041
|
+
);
|
|
3042
|
+
if (strict) emitGuardDeny(agent, `Shomra guard could not authenticate (HTTP ${r.status}); blocked by fail-closed policy.`);
|
|
3043
|
+
process.exit(0);
|
|
3044
|
+
}
|
|
3045
|
+
throw new Error(`HTTP ${r.status}`); // 5xx / 429 → a real outage, trip the breaker
|
|
3046
|
+
}
|
|
3009
3047
|
res = await r.json();
|
|
3010
3048
|
breakerReset(); // healthy response — clear any tripped breaker
|
|
3011
3049
|
} catch (e) {
|
|
@@ -3098,6 +3136,20 @@ async function cmdResultGuard(flags) {
|
|
|
3098
3136
|
signal: ctrl.signal,
|
|
3099
3137
|
});
|
|
3100
3138
|
clearTimeout(timer);
|
|
3139
|
+
// See cmdToolGuard: fetch() does not reject on 4xx, so an error body would
|
|
3140
|
+
// parse cleanly and `res.decision` would be undefined → silent fail-open.
|
|
3141
|
+
if (!r.ok) {
|
|
3142
|
+
if (r.status === 401 || r.status === 403) {
|
|
3143
|
+
process.stderr.write(
|
|
3144
|
+
`[shomra] result-guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). ` +
|
|
3145
|
+
`Local Tier-0 screening still ran; server-side flow taint did not. ` +
|
|
3146
|
+
`Re-enroll with \`shomra init --key <key>\`.\n`,
|
|
3147
|
+
);
|
|
3148
|
+
if (strict) emitResultBlock(agent, `Shomra result-guard could not authenticate (HTTP ${r.status}); blocked by fail-closed policy.`);
|
|
3149
|
+
process.exit(0);
|
|
3150
|
+
}
|
|
3151
|
+
throw new Error(`HTTP ${r.status}`); // 5xx / 429 → a real outage, trip the breaker
|
|
3152
|
+
}
|
|
3101
3153
|
res = await r.json();
|
|
3102
3154
|
breakerReset();
|
|
3103
3155
|
} catch (e) {
|
|
@@ -3115,9 +3167,8 @@ async function cmdResultGuard(flags) {
|
|
|
3115
3167
|
}
|
|
3116
3168
|
|
|
3117
3169
|
// Wire the runtime firewall into one or more coding agents' hook systems.
|
|
3118
|
-
// Default (no --agent) targets Claude Code only
|
|
3119
|
-
//
|
|
3120
|
-
// installs into others too.
|
|
3170
|
+
// Default (no --agent) targets Claude Code only. `--agent cursor,windsurf` or
|
|
3171
|
+
// `--agent all` installs into others too.
|
|
3121
3172
|
function cmdInstallHook(flags) {
|
|
3122
3173
|
const global = !!flags.global;
|
|
3123
3174
|
const requested = flags.agent
|
|
@@ -3163,8 +3214,8 @@ function cmdInstallHook(flags) {
|
|
|
3163
3214
|
//
|
|
3164
3215
|
// Discovers the AI tooling on this box (coding agents, MCP servers, rules files,
|
|
3165
3216
|
// model keys, AI tools), locally scans the scannable ones, and prints a posture
|
|
3166
|
-
// score + the top fixes.
|
|
3167
|
-
//
|
|
3217
|
+
// score + the top fixes. Runs fully offline. Pairs with `shomra protect`
|
|
3218
|
+
// (unguarded agents) and `shomra check`.
|
|
3168
3219
|
function cmdDoctor(flags) {
|
|
3169
3220
|
const assets = discoverAll();
|
|
3170
3221
|
const by = (t) => assets.filter((a) => a.type === t);
|
|
@@ -3240,9 +3291,8 @@ function cmdDoctor(flags) {
|
|
|
3240
3291
|
// shomra protect [--local] [--force]
|
|
3241
3292
|
//
|
|
3242
3293
|
// `install-hook` protects one named agent; this discovers every supported coding
|
|
3243
|
-
// agent on the machine and wires the Pre/Post firewall for each unguarded one
|
|
3244
|
-
//
|
|
3245
|
-
// default; --local scopes to this repo's .<agent> dirs.
|
|
3294
|
+
// agent on the machine and wires the Pre/Post firewall for each unguarded one.
|
|
3295
|
+
// Global (machine-wide) by default; --local scopes to this repo's .<agent> dirs.
|
|
3246
3296
|
function cmdProtect(flags) {
|
|
3247
3297
|
const assets = discoverAll();
|
|
3248
3298
|
const labelToKey = Object.fromEntries(Object.entries(AGENT_LABELS).map(([k, v]) => [v, k]));
|
|
@@ -3280,7 +3330,7 @@ function cmdProtect(flags) {
|
|
|
3280
3330
|
//
|
|
3281
3331
|
// Generates the artifact from a least-privilege template (explicit narrow tool
|
|
3282
3332
|
// grants, env-referenced secrets, https + auth on cards) and gates it to prove
|
|
3283
|
-
// it starts clean
|
|
3333
|
+
// it starts clean.
|
|
3284
3334
|
const NEW_TEMPLATES = {
|
|
3285
3335
|
skill: (name) => ({
|
|
3286
3336
|
file: path.join(name, 'SKILL.md'),
|
|
@@ -3331,7 +3381,6 @@ function cmdNew(flags, positional) {
|
|
|
3331
3381
|
}
|
|
3332
3382
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
3333
3383
|
fs.writeFileSync(target, content);
|
|
3334
|
-
// Prove it starts clean.
|
|
3335
3384
|
const g = localGate(content, { kind: kind === 'agent-card' ? 'agent-card' : kind === 'mcp' ? 'mcp' : kind === 'rules' ? 'rules' : kind, path: file });
|
|
3336
3385
|
if (flags.json) { console.log(JSON.stringify({ created: file, kind, verdict: g.verdict }, null, 2)); return; }
|
|
3337
3386
|
console.log(`\n ${green('✓ Created')} ${bold(file)} ${dim(`(${kind})`)}`);
|
|
@@ -3409,7 +3458,7 @@ function worstMcpVerdict(local, idxAlert) {
|
|
|
3409
3458
|
* the LLM can call Shomra's checks as native tools in its own loop: after it
|
|
3410
3459
|
* edits files it can `shomra_check` / `shomra_scan_models`, then `shomra_fix`.
|
|
3411
3460
|
* Each tool is a thin bridge to the corresponding CLI verb with `--json`, so it
|
|
3412
|
-
* reuses the
|
|
3461
|
+
* reuses the same engine as the CLI and editor.
|
|
3413
3462
|
*/
|
|
3414
3463
|
async function cmdMcpServe(flags) {
|
|
3415
3464
|
const { createInterface } = await import('node:readline');
|
|
@@ -3550,7 +3599,6 @@ async function cmdMcp(flags, positional) {
|
|
|
3550
3599
|
return;
|
|
3551
3600
|
}
|
|
3552
3601
|
|
|
3553
|
-
// Merge into the target config.
|
|
3554
3602
|
let cfg = {};
|
|
3555
3603
|
if (fs.existsSync(configFile)) { try { cfg = JSON.parse(fs.readFileSync(configFile, 'utf8')); } catch { console.error(red('✗') + ` ${configFile} is not valid JSON.`); process.exit(1); } }
|
|
3556
3604
|
cfg.mcpServers = cfg.mcpServers || {};
|
|
@@ -3685,7 +3733,11 @@ function modelCacheOff() { return process.env.SHOMRA_MODEL_CACHE === '0' || Stri
|
|
|
3685
3733
|
function loadModelCache() { try { return JSON.parse(fs.readFileSync(MODEL_CACHE_FILE, 'utf8')) || {}; } catch { return {}; } }
|
|
3686
3734
|
function saveModelCache(c) { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(MODEL_CACHE_FILE, JSON.stringify(c)); } catch { /* cache is best-effort */ } }
|
|
3687
3735
|
|
|
3688
|
-
|
|
3736
|
+
// `timeoutMs` overrides the interactive API budget. The PreToolUse hook MUST
|
|
3737
|
+
// pass the guard budget: this function's default is sized for a human waiting on
|
|
3738
|
+
// `shomra models`, and inheriting it on the hot path froze a dev's terminal for
|
|
3739
|
+
// 15s per uncached ref against a cold backend.
|
|
3740
|
+
async function modelLookup(url, id, sha, timeoutMs) {
|
|
3689
3741
|
const key = `${id}@${sha || 'latest'}`;
|
|
3690
3742
|
const ttl = clampInt(process.env.SHOMRA_MODEL_CACHE_TTL_MS, 7 * 24 * 3600 * 1000, 0, 365 * 24 * 3600 * 1000);
|
|
3691
3743
|
const cache = modelCacheOff() ? {} : loadModelCache();
|
|
@@ -3706,7 +3758,7 @@ async function modelLookup(url, id, sha) {
|
|
|
3706
3758
|
|
|
3707
3759
|
const q = `id=${encodeURIComponent(id)}${sha ? `&sha=${encodeURIComponent(sha)}` : ''}`;
|
|
3708
3760
|
const ctrl = new AbortController();
|
|
3709
|
-
const timer = setTimeout(() => ctrl.abort(), clampInt(process.env.SHOMRA_API_TIMEOUT_MS, 15000, 1000, 60000));
|
|
3761
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs ?? clampInt(process.env.SHOMRA_API_TIMEOUT_MS, 15000, 1000, 60000));
|
|
3710
3762
|
try {
|
|
3711
3763
|
const res = await fetch(`${url}/models/lookup?${q}`, { signal: ctrl.signal, headers: { Accept: 'application/json', 'User-Agent': 'shomra-agent' } });
|
|
3712
3764
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|