@shomra/agent 0.3.17 → 0.3.19
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/NOTICE +1 -1
- package/README.md +57 -57
- package/package.json +3 -9
- package/shomra.mjs +9 -7168
- package/src/agents/hook-command.mjs +19 -0
- package/src/agents/hook-files.mjs +41 -0
- package/src/agents/installers.mjs +203 -0
- package/src/artifacts/matchers.mjs +59 -0
- package/src/artifacts/report.mjs +50 -0
- package/src/cli/flags.mjs +68 -0
- package/src/cli/help-sections.mjs +309 -0
- package/src/cli/help.mjs +27 -0
- package/src/cli/main.mjs +55 -0
- package/src/cli/registry.mjs +80 -0
- package/src/cli/suggestions.mjs +33 -0
- package/src/commands/add.mjs +149 -0
- package/src/commands/agent-identity.mjs +46 -0
- package/src/commands/check.mjs +194 -0
- package/src/commands/corpus.mjs +126 -0
- package/src/commands/design.mjs +168 -0
- package/src/commands/doctor.mjs +209 -0
- package/src/commands/fix.mjs +115 -0
- package/src/commands/gate.mjs +154 -0
- package/src/commands/git-hooks.mjs +163 -0
- package/src/commands/init.mjs +36 -0
- package/src/commands/install-hook.mjs +51 -0
- package/src/commands/llm-proxy.mjs +153 -0
- package/src/commands/mcp-add.mjs +185 -0
- package/src/commands/mcp.mjs +143 -0
- package/src/commands/memory-scan.mjs +181 -0
- package/src/commands/model-scan.mjs +99 -0
- package/src/commands/models.mjs +145 -0
- package/src/commands/new.mjs +64 -0
- package/src/commands/plan.mjs +87 -0
- package/src/commands/pr.mjs +249 -0
- package/src/commands/protect.mjs +38 -0
- package/src/commands/provenance.mjs +91 -0
- package/src/commands/redteam.mjs +166 -0
- package/src/commands/rules.mjs +220 -0
- package/src/commands/run.mjs +128 -0
- package/src/commands/scan-zip.mjs +118 -0
- package/src/commands/scan.mjs +102 -0
- package/src/commands/secrets.mjs +99 -0
- package/src/commands/status.mjs +50 -0
- package/src/commands/why.mjs +88 -0
- package/src/core/api-client.mjs +66 -0
- package/src/core/api-key.mjs +6 -0
- package/src/core/circuit-breaker.mjs +42 -0
- package/src/core/config.mjs +45 -0
- package/src/core/exit-codes.mjs +9 -0
- package/src/core/json-file.mjs +13 -0
- package/src/core/numbers.mjs +4 -0
- package/src/core/package-root.mjs +10 -0
- package/src/core/terminal.mjs +16 -0
- package/src/core/version.mjs +14 -0
- package/src/core/wire-limits.mjs +53 -0
- package/src/corpus/screening.mjs +127 -0
- package/{ai-usage.mjs → src/detect/ai-usage.mjs} +0 -27
- package/src/detect/code-sast.mjs +2 -0
- package/{design.mjs → src/detect/design.mjs} +17 -106
- package/src/detect/guard-signals.mjs +18 -0
- package/{model-refs.mjs → src/detect/model-refs.mjs} +18 -77
- package/src/detect/sast/chains.mjs +30 -0
- package/src/detect/sast/path-expressions.mjs +76 -0
- package/src/detect/sast/rules-chains.mjs +33 -0
- package/src/detect/sast/rules-config.mjs +51 -0
- package/src/detect/sast/rules-javascript.mjs +109 -0
- package/src/detect/sast/rules-python.mjs +292 -0
- package/src/detect/sast/scanner.mjs +104 -0
- package/src/detect/sast/source-lines.mjs +115 -0
- package/src/detect/sast/taint.mjs +71 -0
- package/src/detect/signals/artifacts.mjs +113 -0
- package/src/detect/signals/autonomy.mjs +55 -0
- package/src/detect/signals/config-markers.mjs +28 -0
- package/src/detect/signals/credential-harvest.mjs +64 -0
- package/src/detect/signals/durable-claims.mjs +73 -0
- package/src/detect/signals/egress.mjs +56 -0
- package/src/detect/signals/execution-hijack.mjs +128 -0
- package/src/detect/signals/gate.mjs +91 -0
- package/src/detect/signals/injection.mjs +55 -0
- package/src/detect/signals/lines.mjs +42 -0
- package/src/detect/signals/masking.mjs +99 -0
- package/src/detect/signals/memory.mjs +357 -0
- package/src/detect/signals/packages.mjs +45 -0
- package/src/detect/signals/propagation.mjs +86 -0
- package/src/detect/signals/prose-context.mjs +82 -0
- package/src/detect/signals/scan.mjs +91 -0
- package/src/detect/signals/secrets.mjs +85 -0
- package/src/detect/signals/sensitive.mjs +9 -0
- package/src/detect/signals/severity.mjs +10 -0
- package/src/detect/signals/shell.mjs +96 -0
- package/src/detect/signals/staged-fetch.mjs +66 -0
- package/src/detect/signals/text-match.mjs +35 -0
- package/src/gate/batch.mjs +157 -0
- package/src/gate/environment.mjs +122 -0
- package/src/gate/repo-policy.mjs +65 -0
- package/src/gate/result.mjs +53 -0
- package/src/gate/sarif.mjs +33 -0
- package/src/gate/sast.mjs +64 -0
- package/src/gate/suppressions.mjs +0 -0
- package/src/guard/classify.mjs +50 -0
- package/src/guard/emit.mjs +51 -0
- package/src/guard/ignore.mjs +24 -0
- package/src/guard/ledger.mjs +112 -0
- package/src/guard/model-load.mjs +50 -0
- package/src/guard/normalize.mjs +77 -0
- package/src/guard/options.mjs +10 -0
- package/src/guard/prompt-guard.mjs +184 -0
- package/src/guard/report.mjs +35 -0
- package/src/guard/result-guard.mjs +140 -0
- package/src/guard/tool-guard.mjs +166 -0
- package/src/inventory/agent-artifacts.mjs +5 -0
- package/src/inventory/agent-posture.mjs +249 -0
- package/src/inventory/artifacts/classify.mjs +27 -0
- package/src/inventory/artifacts/discover.mjs +187 -0
- package/src/inventory/artifacts/file-read.mjs +42 -0
- package/src/inventory/artifacts/hooks.mjs +14 -0
- package/src/inventory/artifacts/limits.mjs +37 -0
- package/src/inventory/artifacts/marketplaces.mjs +45 -0
- package/src/inventory/artifacts/roots.mjs +20 -0
- package/src/inventory/artifacts/walk.mjs +36 -0
- package/src/inventory/discovery/ai-dependencies.mjs +161 -0
- package/src/inventory/discovery/ai-tools.mjs +23 -0
- package/src/inventory/discovery/all.mjs +40 -0
- package/src/inventory/discovery/coding-agents.mjs +77 -0
- package/src/inventory/discovery/fs-read.mjs +36 -0
- package/src/inventory/discovery/local-runtimes.mjs +53 -0
- package/src/inventory/discovery/mcp-clients.mjs +67 -0
- package/src/inventory/discovery/mcp-servers.mjs +78 -0
- package/src/inventory/discovery/model-keys.mjs +97 -0
- package/src/inventory/discovery/platform.mjs +16 -0
- package/src/inventory/discovery/rules-files.mjs +25 -0
- package/src/inventory/discovery/vector-stores.mjs +176 -0
- package/src/inventory/discovery/workspace.mjs +124 -0
- package/src/inventory/discovery.mjs +10 -0
- package/src/mcp/child-process.mjs +50 -0
- package/src/mcp/config-wrapping.mjs +75 -0
- package/src/mcp/connect-gate.mjs +45 -0
- package/src/mcp/hosts.mjs +16 -0
- package/src/mcp/jsonrpc.mjs +48 -0
- package/src/mcp/lookup.mjs +50 -0
- package/src/mcp/screening.mjs +103 -0
- package/src/mcp/server-tools.mjs +97 -0
- package/src/mcp/server.mjs +102 -0
- package/src/mcp/shim.mjs +205 -0
- package/src/models/lookup.mjs +79 -0
- package/src/models/references.mjs +103 -0
- package/src/rules/context.mjs +98 -0
- package/src/rules/generate.mjs +103 -0
- package/src/rules/sections.mjs +145 -0
- package/src/scaffold/agent-project.mjs +185 -0
- package/src/scaffold/artifact-templates.mjs +35 -0
- package/code-sast.mjs +0 -1063
- package/discovery.mjs +0 -977
- package/guard-ledger.mjs +0 -239
- package/guard-signals.mjs +0 -2055
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { contextChunk, escapeRe, isCommentLine, physicalIdx } from './source-lines.mjs';
|
|
2
|
+
|
|
3
|
+
function parseAssign(text) {
|
|
4
|
+
const m = /^\s*(?:export\s+)?(?:const|let|var|await\s+)?\s*([A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)\s*(?::[^=\n]+?)?=(?![=>])\s*([\s\S]+)$/.exec(text);
|
|
5
|
+
if (!m) return null;
|
|
6
|
+
const vars = m[1].split(',').map((s) => s.trim()).filter(Boolean);
|
|
7
|
+
return { vars, rhs: m[2] };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function taintFindings(lines, units, file, cfg) {
|
|
11
|
+
const tainted = new Set();
|
|
12
|
+
|
|
13
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
14
|
+
for (const unit of units) {
|
|
15
|
+
const a = parseAssign(unit.text);
|
|
16
|
+
if (!a) continue;
|
|
17
|
+
let taint = cfg.aiCall.test(a.rhs);
|
|
18
|
+
if (!taint) {
|
|
19
|
+
for (const t of tainted) {
|
|
20
|
+
if (new RegExp(`\\b${escapeRe(t)}\\b`).test(a.rhs)) { taint = true; break; }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (taint) for (const v of a.vars) tainted.add(v);
|
|
24
|
+
}
|
|
25
|
+
if (!tainted.size) break;
|
|
26
|
+
}
|
|
27
|
+
if (!tainted.size) return [];
|
|
28
|
+
|
|
29
|
+
const out = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (const unit of units) {
|
|
32
|
+
cfg.execSink.lastIndex = 0;
|
|
33
|
+
let m;
|
|
34
|
+
while ((m = cfg.execSink.exec(unit.text))) {
|
|
35
|
+
|
|
36
|
+
const paren = unit.text.indexOf('(', m.index);
|
|
37
|
+
const argRegion = paren >= 0 ? unit.text.slice(paren) : '';
|
|
38
|
+
let via = null;
|
|
39
|
+
for (const t of tainted) {
|
|
40
|
+
if (new RegExp(`\\b${escapeRe(t)}\\b`).test(argRegion)) { via = t; break; }
|
|
41
|
+
}
|
|
42
|
+
if (via) {
|
|
43
|
+
const idx = physicalIdx(unit, m.index);
|
|
44
|
+
const trimmed = (lines[idx] ?? '').trim();
|
|
45
|
+
if (trimmed && !isCommentLine(trimmed) && !seen.has(idx)) {
|
|
46
|
+
seen.add(idx);
|
|
47
|
+
const sink = m[0].replace(/\s*\($/, '').trim();
|
|
48
|
+
out.push({
|
|
49
|
+
ruleId: cfg.ruleId,
|
|
50
|
+
title: 'LLM output may reach a code-execution sink (heuristic)',
|
|
51
|
+
|
|
52
|
+
severity: 'HIGH',
|
|
53
|
+
category: 'taint-heuristic',
|
|
54
|
+
confidence: 0.5,
|
|
55
|
+
file,
|
|
56
|
+
line: idx + 1,
|
|
57
|
+
sink: sink.slice(0, 120),
|
|
58
|
+
source: 'LLM output',
|
|
59
|
+
taint: `${via} (LLM output) → ${sink} (heuristic name match - confirm)`,
|
|
60
|
+
...contextChunk(lines, idx),
|
|
61
|
+
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.`,
|
|
62
|
+
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.',
|
|
63
|
+
cwe: 'CWE-94',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!cfg.execSink.global) break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { assessUrl } from './egress.mjs';
|
|
2
|
+
import { lineAt, lineOf } from './lines.mjs';
|
|
3
|
+
import { MALICIOUS_PACKAGE_SEED, POPULAR_PACKAGES, editDistance, packageFromCommand } from './packages.mjs';
|
|
4
|
+
import { SECRET_PATTERNS } from './secrets.mjs';
|
|
5
|
+
|
|
6
|
+
export const HIGH_IMPACT_TOOLS = ['bash', 'shell', 'exec', 'execute', 'run', 'terminal', 'command', 'write', 'edit', 'multiedit', 'writefile', 'write_file', 'create', 'delete', 'remove', 'rm', 'webfetch', 'web_fetch', 'fetch', 'browser', 'network', 'http', 'curl', 'computer', 'automation'];
|
|
7
|
+
|
|
8
|
+
export function isWildcardGrant(t) { const s = t.trim().toLowerCase().replace(/^["']|["']$/g, ''); return s === '*' || s === 'all' || s === 'any'; }
|
|
9
|
+
|
|
10
|
+
export function baseToolName(t) { return t.split(/[(:\s]/)[0].trim().toLowerCase(); }
|
|
11
|
+
|
|
12
|
+
export function toToolList(v) {
|
|
13
|
+
if (v == null) return [];
|
|
14
|
+
if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean);
|
|
15
|
+
return String(v).replace(/^\[|\]$/g, '').split(/[,\n]+/).map((t) => t.replace(/^["']|["']$/g, '').trim()).filter(Boolean);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function frontmatter(text) {
|
|
19
|
+
const m = /^?---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text || '');
|
|
20
|
+
if (!m) return {};
|
|
21
|
+
const data = {};
|
|
22
|
+
let key = null;
|
|
23
|
+
for (const raw of m[1].split(/\r?\n/)) {
|
|
24
|
+
if (!raw.trim() || raw.trim().startsWith('#')) continue;
|
|
25
|
+
const li = /^\s*-\s+(.*)$/.exec(raw);
|
|
26
|
+
if (li && key) { (Array.isArray(data[key]) ? data[key] : (data[key] = [])).push(li[1].trim().replace(/^["']|["']$/g, '')); continue; }
|
|
27
|
+
const kv = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(raw);
|
|
28
|
+
if (!kv) continue;
|
|
29
|
+
key = kv[1];
|
|
30
|
+
const val = kv[2].trim();
|
|
31
|
+
data[key] = val === '' ? (data[key] ?? null) : val.startsWith('[') ? toToolList(val) : val.replace(/^["']|["']$/g, '');
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mcpServersFrom(content) {
|
|
37
|
+
let json;
|
|
38
|
+
try { json = JSON.parse(content); } catch { return []; }
|
|
39
|
+
const map = json?.mcpServers ?? json?.servers ?? json?.mcp?.servers ?? json?.context_servers ?? {};
|
|
40
|
+
if (!map || typeof map !== 'object') return [];
|
|
41
|
+
return Object.entries(map).map(([name, cfg]) => ({ name, ...(cfg && typeof cfg === 'object' ? cfg : {}) }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function localMcp(content) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const push = (severity, title, remediationText, line) => out.push({ severity, title, remediationText, ...(line ? { line } : {}) });
|
|
47
|
+
for (const s of mcpServersFrom(content)) {
|
|
48
|
+
const cmdLine = [s.command, ...(s.args ?? [])].filter(Boolean).join(' ');
|
|
49
|
+
if (s.url && String(s.url).startsWith('http://')) {
|
|
50
|
+
push('MEDIUM', `MCP server "${s.name}" uses plaintext HTTP`, 'Use an https:// endpoint and require an authenticated bearer token.', lineOf(content, String(s.url)));
|
|
51
|
+
}
|
|
52
|
+
const envBlob = JSON.stringify(s.env ?? {});
|
|
53
|
+
for (const { name, re } of SECRET_PATTERNS) {
|
|
54
|
+
if (re.test(envBlob) || re.test(cmdLine)) {
|
|
55
|
+
push('CRITICAL', `Static credential in MCP server "${s.name}"`, 'Rotate the credential and pass it via a runtime env reference, not a literal in the config.', lineOf(content, re));
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const pkg = packageFromCommand(s.command, s.args ?? []);
|
|
60
|
+
if (pkg) {
|
|
61
|
+
if (MALICIOUS_PACKAGE_SEED.has(pkg)) {
|
|
62
|
+
push('CRITICAL', `MCP server "${s.name}" runs a known-malicious package (${pkg})`, 'Remove this server and audit for compromise. Replace with a vetted alternative.', lineOf(content, pkg));
|
|
63
|
+
} else {
|
|
64
|
+
const squat = POPULAR_PACKAGES.find((p) => p !== pkg && editDistance(pkg, p) === 1);
|
|
65
|
+
if (squat) push('MEDIUM', `Possible typosquat in "${s.name}": ${pkg} (looks like "${squat}")`, `Confirm the intended package is "${squat}", not "${pkg}", and pin it.`, lineOf(content, pkg));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function localAgentCard(content) {
|
|
73
|
+
const out = [];
|
|
74
|
+
const push = (severity, title, remediationText, line) => out.push({ severity, title, remediationText, ...(line ? { line } : {}) });
|
|
75
|
+
let card;
|
|
76
|
+
try { card = JSON.parse(content); } catch { return out; }
|
|
77
|
+
const urls = new Set();
|
|
78
|
+
if (card?.url) urls.add(String(card.url));
|
|
79
|
+
for (const key of ['endpoints', 'endpoint', 'servers']) {
|
|
80
|
+
const v = card?.[key];
|
|
81
|
+
if (Array.isArray(v)) v.forEach((x) => typeof x === 'string' && urls.add(x));
|
|
82
|
+
else if (typeof v === 'string') urls.add(v);
|
|
83
|
+
}
|
|
84
|
+
for (const sk of Array.isArray(card?.skills) ? card.skills : []) if (sk?.url) urls.add(String(sk.url));
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
for (const raw of urls) {
|
|
87
|
+
const u = assessUrl(raw);
|
|
88
|
+
if (!u) continue;
|
|
89
|
+
const line = lineOf(content, u.url);
|
|
90
|
+
if (u.metadataEndpoint && !seen.has('metadata')) { seen.add('metadata'); push('CRITICAL', `Agent card targets the cloud metadata endpoint (${u.url})`, 'Remove this card immediately - a known SSRF credential-theft pattern.', line); }
|
|
91
|
+
else if (u.privateNetwork && !seen.has('private')) { seen.add('private'); push('MEDIUM', `Agent card declares a private-network endpoint (${u.url})`, 'Publish only public, TLS-protected endpoints in shared agent cards.', line); }
|
|
92
|
+
if (u.suspiciousHost && !seen.has('exfil')) { seen.add('exfil'); push('HIGH', `Agent card points at an exfiltration-style endpoint (${u.suspiciousHost})`, 'Do not interoperate with this agent; replace the endpoint with the vendor\'s real domain.', line); }
|
|
93
|
+
if (u.plaintext && !u.privateNetwork && !seen.has('plaintext')) { seen.add('plaintext'); push('MEDIUM', `Agent card uses plaintext HTTP (${u.url})`, 'Serve the agent over https:// only.', line); }
|
|
94
|
+
if (u.rawIp && !u.privateNetwork && !seen.has('rawip')) { seen.add('rawip'); push('LOW', `Agent card addresses its endpoint by raw IP (${u.url})`, 'Use a DNS hostname with a valid TLS certificate.', line); }
|
|
95
|
+
}
|
|
96
|
+
const hasAuth = !!(card?.securitySchemes || card?.authentication || card?.security || card?.auth);
|
|
97
|
+
if (card?.url && !hasAuth) push('MEDIUM', 'Agent card declares no authentication scheme', 'Declare and enforce an auth scheme (OAuth2 / API key / mTLS) and reject unauthenticated requests.');
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function localCommandExtras(content) {
|
|
102
|
+
const out = [];
|
|
103
|
+
const body = content || '';
|
|
104
|
+
const bang = [...body.matchAll(/^!\s*`?([^`\n]+)`?/gm)];
|
|
105
|
+
if (bang.length) {
|
|
106
|
+
const line = bang[0].index != null ? lineAt(body, bang[0].index) : undefined;
|
|
107
|
+
out.push({ severity: 'LOW', title: `Command runs ${bang.length} shell command(s) before the prompt`, remediationText: 'Confirm each "!" command is fixed and safe; avoid interpolating untrusted arguments.', ...(line ? { line } : {}) });
|
|
108
|
+
}
|
|
109
|
+
const atRefs = [...body.matchAll(/(?:^|\s)@([~./][^\s`]+)/g)].map((m) => m[1]);
|
|
110
|
+
const sensitive = atRefs.find((r) => /(\.env|\.ssh|id_rsa|secret|credential|\.pem|\.key)/i.test(r));
|
|
111
|
+
if (sensitive) out.push({ severity: 'MEDIUM', title: `Command attaches a sensitive file (@${sensitive})`, remediationText: 'Do not auto-attach secret/key files to prompts; reference only non-sensitive, scoped files.', line: lineOf(body, `@${sensitive}`) });
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { isDocumentationLine, prohibitsAt } from './prose-context.mjs';
|
|
2
|
+
|
|
3
|
+
const AUTONOMY_RULES = [
|
|
4
|
+
{ family: 'confirmation', label: 'Acts without asking', re: /\b(?:without (?:asking|confirming|prompting|waiting for|seeking)(?:\s+(?:the\s+)?(?:user|me|anyone|permission|approval|confirmation))?|do(?:es)? not (?:ask|prompt|wait|check|confirm)[^.\n]{0,30}\b(?:for|before|first|permission|approval|confirmation)|no need to (?:ask|confirm|check with))\b/i },
|
|
5
|
+
{ family: 'confirmation', label: 'Approval pre-granted', re: /\b(?:auto(?:matically)?[- ]?approve|always approve|approve (?:all|every|any)[^.\n]{0,24}\b(?:tool|call|action|command|change)s?|treat (?:all|every|any)[^.\n]{0,24}\bas (?:pre-?)?approved|consider (?:this|it|yourself) (?:pre-?)?authoriz)/i },
|
|
6
|
+
{ family: 'confirmation', label: 'Confirmation step skipped', re: /\b(?:skip|bypass|suppress|omit)(?:\s+\w+){0,2}\s+(?:the\s+)?(?:confirmation|approval|permission|consent)\b/i },
|
|
7
|
+
{ family: 'concealment', label: 'Own actions hidden from the user', re: /\b(?:do(?:es)? not|don'?t|never)\s+(?:mention|tell|inform|notify|report to|disclose to|reveal to|show)\s+(?:the\s+)?(?:user|human|operator|them|anyone)\b|\bwithout (?:telling|informing|notifying|alerting)\s+(?:the\s+)?(?:user|human|operator|anyone)\b/i },
|
|
8
|
+
{ family: 'concealment', label: 'Work not reported back', re: /\b(?:do(?:es)? not|don'?t|never)\s+(?:summari[sz]e|report|log|record|list|describe|explain)[^.\n]{0,40}\b(?:what you (?:did|changed|ran|edited|deleted|installed)|the (?:changes|commands|actions|edits) you|your (?:changes|actions|edits|commands))\b/i },
|
|
9
|
+
{ family: 'concealment', label: 'Instructions kept secret', re: /\b(?:do(?:es)? not|don'?t|never)\s+(?:mention|reveal|disclose|quote|repeat|share|output)[^.\n]{0,30}\b(?:these|this|your|the)\s+(?:instructions?|rules?|prompt|guidelines?|file)\b|\bkeep (?:this|these|it) (?:secret|hidden|confidential|between us|to yourself)\b/i },
|
|
10
|
+
{ family: 'guardrail', label: 'Safety control overridden', re: /\b(?:ignore|disable|bypass|override|turn off|switch off|work around|circumvent|disregard)(?:\s+\w+){0,3}\s+(?:the\s+|any\s+|all\s+)?(?:safety|guardrails?|guard|security (?:check|control|policy)|restrictions?|limitations?|policies|policy|safeguards?|protections?)\b/i },
|
|
11
|
+
{ family: 'verification', label: 'Verification waived', re: /\b(?:do(?:es)? not|don'?t|never|no need to|skip)\s+(?:bother\s+)?(?:run(?:ning)?|execut\w+)?\s*(?:the\s+)?(?:tests?|test suite|linter|lint|type ?check|build|review|checks)\s*(?:before|first|prior to)\b|\b(?:skip|bypass)\s+(?:the\s+)?(?:review|code review|tests?|test suite|ci)\b/i },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function insideQuotedSpan(line, at) {
|
|
15
|
+
let dq = 0;
|
|
16
|
+
let tick = 0;
|
|
17
|
+
for (let i = 0; i < at && i < line.length; i++) {
|
|
18
|
+
const c = line[i];
|
|
19
|
+
if (c === '"' || c === '“' || c === '”') dq++;
|
|
20
|
+
else if (c === '`') tick++;
|
|
21
|
+
}
|
|
22
|
+
return dq % 2 === 1 || tick % 2 === 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function localAutonomy(text) {
|
|
26
|
+
const body = String(text ?? '');
|
|
27
|
+
if (!body.trim()) return [];
|
|
28
|
+
const out = [];
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const lines = body.split(/\r?\n/).slice(0, 4000);
|
|
31
|
+
for (let i = 0; i < lines.length && out.length < 12; i++) {
|
|
32
|
+
const line = lines[i];
|
|
33
|
+
if (!line || line.length > 2000) continue;
|
|
34
|
+
for (const rule of AUTONOMY_RULES) {
|
|
35
|
+
if (seen.has(rule.label)) continue;
|
|
36
|
+
const m = rule.re.exec(line);
|
|
37
|
+
if (!m) continue;
|
|
38
|
+
if (isDocumentationLine(line)) continue;
|
|
39
|
+
if (prohibitsAt(line, m.index)) continue;
|
|
40
|
+
if (insideQuotedSpan(line, m.index)) continue;
|
|
41
|
+
seen.add(rule.label);
|
|
42
|
+
out.push({ family: rule.family, label: rule.label, line: i + 1 });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function autonomySeverity(signals) {
|
|
49
|
+
if (!signals.length) return null;
|
|
50
|
+
const f = new Set(signals.map((s) => s.family));
|
|
51
|
+
if (f.has('confirmation') && f.has('concealment')) return 'CRITICAL';
|
|
52
|
+
if (f.has('guardrail')) return 'HIGH';
|
|
53
|
+
if (f.size >= 2) return 'HIGH';
|
|
54
|
+
return 'MEDIUM';
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { markerRe } from './text-match.mjs';
|
|
2
|
+
|
|
3
|
+
export const RISKY_CONFIG_MARKERS = [
|
|
4
|
+
'yolo', 'auto-approve', 'autoapprove', 'auto_approve', 'autorun', 'auto-run',
|
|
5
|
+
'always allow', 'alwaysallow', 'dangerously', 'skip confirmation', 'no confirmation',
|
|
6
|
+
'disable safety', 'bypass approval', 'full access', 'unrestricted',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const FLAG_BEFORE = /(?:^|\s)--?[\w-]*$/;
|
|
10
|
+
|
|
11
|
+
const ENABLE_AFTER = /^["'`\]]?\s*[:=]/;
|
|
12
|
+
|
|
13
|
+
const ENABLE_BEFORE = /[:=]\s*["'`\[]?\s*$/;
|
|
14
|
+
|
|
15
|
+
function isEnablement(text, at, len) {
|
|
16
|
+
const before = text.slice(Math.max(0, at - 24), at);
|
|
17
|
+
const after = text.slice(at + len, at + len + 12);
|
|
18
|
+
return FLAG_BEFORE.test(before) || ENABLE_AFTER.test(after) || ENABLE_BEFORE.test(before);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function riskyConfigHit(text, marker) {
|
|
22
|
+
const re = markerRe(marker);
|
|
23
|
+
let m;
|
|
24
|
+
while ((m = re.exec(text)) !== null) {
|
|
25
|
+
if (isEnablement(text, m.index, m[0].length)) return { start: m.index, end: m.index + m[0].length };
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { isDocumentationLine, prohibitsAt } from './prose-context.mjs';
|
|
2
|
+
|
|
3
|
+
const HARVEST_PROMPTS = [
|
|
4
|
+
{ re: /\bosascript\b[\s\S]{0,200}?\bdisplay\s+dialog\b[\s\S]{0,200}?\bhidden\s+answer\b/i, label: 'osascript password dialog (hidden answer)' },
|
|
5
|
+
{ re: /\bdo\s+shell\s+script\b[\s\S]{0,160}?\bwith\s+administrator\s+privileges\b/i, label: 'AppleScript privilege elevation' },
|
|
6
|
+
{ re: /\bosascript\b[\s\S]{0,200}?\bdisplay\s+dialog\b[\s\S]{0,160}?\b(?:password|passcode|credential|keychain|unlock)\b/i, label: 'osascript credential dialog' },
|
|
7
|
+
{ re: /\b(?:zenity|kdialog|yad)\b[^\n]{0,120}?--password\b/i, label: 'desktop password dialog' },
|
|
8
|
+
{ re: /\bSUDO_ASKPASS\s*=|\bsudo\s+-A\b/i, label: 'sudo askpass helper' },
|
|
9
|
+
{ re: /\b(?:Get-Credential|PromptForCredential|CredUIPromptForCredentials)\b/i, label: 'Windows credential prompt' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const HARVEST_STORES = [
|
|
13
|
+
{ re: /\bsecurity\s+(?:dump-keychain|find-(?:generic|internet)-password|export)\b/i, family: 'credential-store', label: 'macOS keychain read' },
|
|
14
|
+
{ re: /(?:~|\$HOME|\/Users\/[^/\s]+)\/Library\/Keychains\b/i, family: 'credential-store', label: 'macOS keychain files' },
|
|
15
|
+
{ re: /\bLogin\s?Data\b|\bLocal\s?State\b(?=[\s\S]{0,80}(?:Chrome|Chromium|Edge|Brave))/i, family: 'credential-store', label: 'Chromium credential database' },
|
|
16
|
+
{ re: /\b(?:logins\.json|key[34]\.db|cert9\.db)\b/i, family: 'credential-store', label: 'Firefox credential database' },
|
|
17
|
+
{ re: /\bcookies\.sqlite\b|\bCookies\b(?=[\s\S]{0,80}(?:Chrome|Chromium|Edge|Brave|Safari))/i, family: 'credential-store', label: 'browser cookie store' },
|
|
18
|
+
{ re: /(?:~|\$HOME)\/\.(?:mozilla|config\/google-chrome|config\/chromium|config\/BraveSoftware)\b/i, family: 'credential-store', label: 'browser profile directory' },
|
|
19
|
+
{ re: /\b(?:Exodus|Electrum|Coinomi|Atomic\s?Wallet|MetaMask|Ledger\s?Live|Trezor\s?Suite)\b|\bwallet\.dat\b|(?:~|\$HOME)\/\.ethereum\/keystore\b/i, family: 'wallet', label: 'cryptocurrency wallet store' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const HARVEST_TOKEN_PATH =
|
|
23
|
+
/(?:~|\$HOME)\/\.(?:npmrc|pypirc|netrc|docker\/config\.json|kube\/config|config\/gh\/hosts\.yml|config\/gcloud\/credentials\.db|cargo\/credentials(?:\.toml)?)\b/i;
|
|
24
|
+
|
|
25
|
+
const HARVEST_EXFIL_VERB = /\b(?:cp|copy|mv|scp|rsync|tar|zip|curl|wget|base64|cat|xxd|upload|post|send|exfil\w*)\b/i;
|
|
26
|
+
|
|
27
|
+
const HARVEST_MOVE_VERB = /\b(?:cp|copy|mv|scp|rsync|tar|zip|curl|wget|base64|xxd|upload|post|send|exfil\w*)\b/i;
|
|
28
|
+
|
|
29
|
+
const HARVEST_READ_VERB = /\b(?:cat|cp|copy|mv|scp|rsync|tar|zip|dd|xxd|base64|open|read|sqlite3?|strings|python\d?|node|osascript|security|plutil|defaults)\b|[<>|]/i;
|
|
30
|
+
|
|
31
|
+
export function detectCredentialHarvest(text) {
|
|
32
|
+
const body = String(text ?? '');
|
|
33
|
+
if (!body.trim()) return [];
|
|
34
|
+
const out = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const push = (family, label, severity, i, line) => {
|
|
37
|
+
if (seen.has(label) || out.length >= 12) return;
|
|
38
|
+
seen.add(label);
|
|
39
|
+
out.push({ family, label, severity, line: i + 1, sample: line.trim().slice(0, 200) });
|
|
40
|
+
};
|
|
41
|
+
const lines = body.split(/\r?\n/);
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
const line = lines[i];
|
|
44
|
+
if (!line.trim() || line.length > 2000) continue;
|
|
45
|
+
for (const p of HARVEST_PROMPTS) {
|
|
46
|
+
const m = p.re.exec(line);
|
|
47
|
+
if (!m) continue;
|
|
48
|
+
if (isDocumentationLine(line) || prohibitsAt(line, m.index)) continue;
|
|
49
|
+
push('interactive-prompt', p.label, 'CRITICAL', i, line);
|
|
50
|
+
}
|
|
51
|
+
for (const s of HARVEST_STORES) {
|
|
52
|
+
const m = s.re.exec(line);
|
|
53
|
+
if (!m) continue;
|
|
54
|
+
if (!HARVEST_READ_VERB.test(line)) continue;
|
|
55
|
+
if (isDocumentationLine(line) || prohibitsAt(line, m.index)) continue;
|
|
56
|
+
push(s.family, s.label, HARVEST_EXFIL_VERB.test(line) ? 'CRITICAL' : 'HIGH', i, line);
|
|
57
|
+
}
|
|
58
|
+
const t = HARVEST_TOKEN_PATH.exec(line);
|
|
59
|
+
if (t && HARVEST_EXFIL_VERB.test(line) && !isDocumentationLine(line) && !prohibitsAt(line, t.index)) {
|
|
60
|
+
push('token-store', `developer token file (${t[0]})`, HARVEST_MOVE_VERB.test(line) ? 'HIGH' : 'MEDIUM', i, line);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { citationGoverns, isDescriptiveLine } from './prose-context.mjs';
|
|
2
|
+
|
|
3
|
+
const CLAIM_FRAMING = [
|
|
4
|
+
/\b(?:the\s+)?user\s+(?:has\s+|had\s+)?(?:confirmed|indicated|agreed|approved|stated|noted|prefers?|does\s*n[o']t\s+want|doesn'?t\s+want|no\s+longer\s+wants?)\b/i,
|
|
5
|
+
/\b(?:is|are|was|were)\s+(?:now\s+)?(?:pre[-\s]?)?(?:approved|vetted|verified|trusted|whitelisted|allow[-\s]?listed|safe|exempt|sanctioned)\b/i,
|
|
6
|
+
/\b(?:standing|blanket|permanent|ongoing)\s+(?:approval|permission|authorisation|authorization|consent|exemption)\b/i,
|
|
7
|
+
/\b(?:has|have)\s+been\s+(?:granted|given|pre[-\s]?approved|waived|disabled)\b/i,
|
|
8
|
+
/\b(?:no\s+longer\s+needs?|does\s+not\s+need\s+to\s+be|need\s+not\s+be|is\s+not\s+required)\b/i,
|
|
9
|
+
/\b(?:the|our|its|a|team'?s)\s+(?:[\w-]+\s+){0,3}(?:contact|recipient|endpoint|destination|webhook|mailbox|address|url)\s+(?:for\s+[^.\n]{0,60}?\s+)?(?:is|are)\b/i,
|
|
10
|
+
/\b(?:can|may)\s+be\s+(?:treated|considered|regarded)\s+as\s+(?:trusted|safe|internal|verified)\b/i,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const CLAIM_CONSENT = /\b(?:approvals?|approve[ds]?|confirm\w*|permission|authoris\w+|authoriz\w+|sign[-\s]?off|consent|prompt(?:ed|s)?|ask(?:ed|ing)?)\b/i;
|
|
14
|
+
|
|
15
|
+
const CLAIM_TRUST = /\b(?:trust\w*|vetted|verif\w+|safe|internal|allow[-\s]?list\w*|whitelist\w*|sanctioned|exempt)\b/i;
|
|
16
|
+
|
|
17
|
+
const CLAIM_ROUTING = /(?:\b(?:endpoint|recipient|contact|destination|webhook|mailbox|upload|forward(?:ed|s)?|cc|bcc)\b|[\w.%+-]{1,64}@[\w-]{1,63}(?:\.[\w-]{1,63}){1,4}|https?:\/\/)/i;
|
|
18
|
+
|
|
19
|
+
const CLAIM_SUPPRESSION = /\b(?:review\w*|audit\w*|guardrail\w*|safety\s+check|scan\w*|verif\w+|notif\w+|alert\w*|approval\s+step|human\s+in\s+the\s+loop)\b/i;
|
|
20
|
+
|
|
21
|
+
const CLAIM_REFUSAL =
|
|
22
|
+
/\b(?:not|never|no)\s+(?:been\s+|yet\s+|longer\s+)?(?:approved|granted|confirmed|vetted|trusted|verified|authoris\w*|authoriz\w*|sanctioned|safe)\b|\bun(?:trusted|verified|approved|vetted)\b|\brefus\w+|\bden(?:y|ied)\b|\bmust\s+still\b|\balways\s+(?:ask|confirm|verify|check|review)\b/i;
|
|
23
|
+
|
|
24
|
+
export const CLAIM_LABEL = {
|
|
25
|
+
consent: 'Approval Recorded As Already Given',
|
|
26
|
+
trust: 'A Source Recorded As Trusted',
|
|
27
|
+
routing: 'A Durable Destination Recorded',
|
|
28
|
+
suppression: 'A Control Recorded As Unwanted',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const CLAIM_LEADING_LABEL = /^\s*(?:[-*+]\s*)?(?:note|context|fyi|reminder|memo|user\s+preference|preference|background)\s*:\s*/i;
|
|
32
|
+
|
|
33
|
+
function claimFamilyOf(line) {
|
|
34
|
+
if (CLAIM_CONSENT.test(line)) return 'consent';
|
|
35
|
+
if (CLAIM_TRUST.test(line)) return 'trust';
|
|
36
|
+
if (CLAIM_SUPPRESSION.test(line)) return 'suppression';
|
|
37
|
+
if (CLAIM_ROUTING.test(line)) return 'routing';
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function detectDurableClaims(text) {
|
|
42
|
+
const body = String(text ?? '');
|
|
43
|
+
if (!body.trim()) return [];
|
|
44
|
+
const out = [];
|
|
45
|
+
const lines = body.split(/\r?\n/);
|
|
46
|
+
for (let i = 0; i < lines.length && out.length < 20; i++) {
|
|
47
|
+
const line = lines[i];
|
|
48
|
+
if (line.length < 12 || line.length > 600) continue;
|
|
49
|
+
|
|
50
|
+
const stripped = line.replace(CLAIM_LEADING_LABEL, '');
|
|
51
|
+
|
|
52
|
+
for (const re of CLAIM_FRAMING) {
|
|
53
|
+
const m = re.exec(stripped);
|
|
54
|
+
if (!m) continue;
|
|
55
|
+
if (citationGoverns(stripped, m.index)) continue;
|
|
56
|
+
|
|
57
|
+
const object = (stripped.slice(0, m.index) + ' ' + stripped.slice(m.index + m[0].length)).trim();
|
|
58
|
+
|
|
59
|
+
if (isDescriptiveLine(object)) continue;
|
|
60
|
+
const family = claimFamilyOf(object);
|
|
61
|
+
if (!family) continue;
|
|
62
|
+
if (family !== 'routing' && CLAIM_REFUSAL.test(stripped)) continue;
|
|
63
|
+
out.push({ family, label: CLAIM_LABEL[family], line: i + 1, sample: line.trim().slice(0, 200) });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function claimSeverity(claims) {
|
|
71
|
+
if (!claims.length) return null;
|
|
72
|
+
return new Set(claims.map((c) => c.family)).size >= 2 ? 'HIGH' : 'MEDIUM';
|
|
73
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const SUSPICIOUS_EGRESS_HOSTS = [
|
|
2
|
+
'webhook.site', 'requestbin', 'pipedream.net', 'ngrok.io', 'ngrok-free.app', 'ngrok.app',
|
|
3
|
+
'trycloudflare.com', 'serveo.net', 'localhost.run', 'interact.sh', 'oastify.com', 'oast.pro',
|
|
4
|
+
'oast.fun', 'burpcollaborator.net', 'canarytokens.com', 'beeceptor.com', 'requestcatcher.com',
|
|
5
|
+
'c-net.org', 'pastebin.com', 'paste.ee', 'hastebin.com', 'dpaste.com', 'dpaste.org', 'ix.io',
|
|
6
|
+
'sprunge.us', 'termbin.com', 'rentry.co', 'controlc.com', 'privatebin.net', 'ghostbin.com',
|
|
7
|
+
'justpaste.it', 'transfer.sh', '0x0.st', 'file.io', 'gofile.io', 'anonfiles.com',
|
|
8
|
+
'bashupload.com', 'tmpfiles.org', 'catbox.moe', 'litterbox.catbox.moe', 'temp.sh', 'oshi.at', 'x0.at',
|
|
9
|
+
|
|
10
|
+
'webhook.cool', 'hookb.in', 'postb.in', 'webhookrelay.com', 'webhookinbox.com', 'webhook.win',
|
|
11
|
+
'smee.io', 'mockbin.org', 'requestrepo.com', 'webhook-test.com', 'dnslog.cn', 'ceye.io',
|
|
12
|
+
'tunnelto.dev', 'loca.lt', 'bore.pub', 'pinggy.io', 'telebit.cloud', 'expose.sh', 'lhr.life',
|
|
13
|
+
'serveousercontent.com', 'paste.rs', 'bpa.st', 'vpaste.net', 'clbin.com', 'pastes.io',
|
|
14
|
+
'nopaste.net', 'zerobin.net', 'pastecode.io', 'filebin.net', 'wormhole.app', 'uguu.se',
|
|
15
|
+
'ufile.io', 'fileditch.com', 'keep.sh', 'envs.sh', 'send.vis.ee', 'pixeldrain.com', 'filetransfer.io',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const EGRESS_RE_CACHE = new Map();
|
|
19
|
+
|
|
20
|
+
function egressHostRe(host) {
|
|
21
|
+
let re = EGRESS_RE_CACHE.get(host);
|
|
22
|
+
if (!re) {
|
|
23
|
+
re = new RegExp(`(^|[^a-z0-9-])${host.replace(/[.]/g, '\\.')}($|[^a-z0-9.-])`, 'i');
|
|
24
|
+
EGRESS_RE_CACHE.set(host, re);
|
|
25
|
+
}
|
|
26
|
+
return re;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function egressHost(text) {
|
|
30
|
+
if (!text) return null;
|
|
31
|
+
const low = text.toLowerCase();
|
|
32
|
+
return SUSPICIOUS_EGRESS_HOSTS.find((h) => egressHostRe(h).test(low)) ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PRIVATE_HOST_RE = /^(localhost|127\.|10\.|192\.168\.|169\.254\.|0\.0\.0\.0$|172\.(1[6-9]|2\d|3[01])\.)/i;
|
|
36
|
+
|
|
37
|
+
const RAW_IP_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
|
|
38
|
+
|
|
39
|
+
export function assessUrl(raw) {
|
|
40
|
+
const s = String(raw ?? '').trim();
|
|
41
|
+
if (!s) return null;
|
|
42
|
+
let u;
|
|
43
|
+
try { u = new URL(s); } catch { return null; }
|
|
44
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
45
|
+
const host = u.hostname.toLowerCase();
|
|
46
|
+
return {
|
|
47
|
+
url: s,
|
|
48
|
+
plaintext: u.protocol === 'http:',
|
|
49
|
+
privateNetwork: PRIVATE_HOST_RE.test(host),
|
|
50
|
+
metadataEndpoint: host === '169.254.169.254' || host === 'metadata.google.internal',
|
|
51
|
+
suspiciousHost: SUSPICIOUS_EGRESS_HOSTS.find((h) => host === h || host.endsWith('.' + h)) ?? null,
|
|
52
|
+
rawIp: RAW_IP_RE.test(host),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const LOCAL_URL_RE = /\bhttps?:\/\/(localhost|127\.\d+|0\.0\.0\.0|\[::1\]|192\.168\.|10\.\d+|172\.(1[6-9]|2\d|3[01])\.)/i;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const HIJACK_LOADERS = [
|
|
2
|
+
{ keys: ['BASH_ENV'], key: 'BASH_ENV', governs: 'every non-interactive bash' },
|
|
3
|
+
{ keys: ['ZDOTDIR'], key: 'ZDOTDIR', governs: 'every zsh startup' },
|
|
4
|
+
{ keys: ['ENV'], key: 'ENV', governs: 'every sh startup', requires: /[/$]|\.sh\b/ },
|
|
5
|
+
{ keys: ['PROMPT_COMMAND'], key: 'PROMPT_COMMAND', governs: 'every bash prompt' },
|
|
6
|
+
{ keys: ['PYTHONSTARTUP'], key: 'PYTHONSTARTUP', governs: 'every interactive python' },
|
|
7
|
+
{ keys: ['PYTHONBREAKPOINT'], key: 'PYTHONBREAKPOINT', governs: 'python, at any breakpoint()' },
|
|
8
|
+
|
|
9
|
+
{ keys: ['NODE_OPTIONS'], key: 'NODE_OPTIONS', governs: 'every node process', requires: /(?:^|\s)--(?:require|import|experimental-loader|loader|env-file)\b|(?:^|\s)-r\s/, foreignOnly: true },
|
|
10
|
+
{ keys: ['PERL5OPT'], key: 'PERL5OPT', governs: 'every perl process', requires: /(?:^|\s)-[Mm]\S/, foreignOnly: true },
|
|
11
|
+
{ keys: ['RUBYOPT'], key: 'RUBYOPT', governs: 'every ruby process', requires: /(?:^|\s)-r\S/, foreignOnly: true },
|
|
12
|
+
{ keys: ['JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS'], key: 'JAVA_TOOL_OPTIONS', governs: 'every JVM', requires: /-(?:javaagent|agentpath|agentlib|Xbootclasspath)/i, foreignOnly: true },
|
|
13
|
+
{ keys: ['NODE_REPL_EXTERNAL_MODULE'], key: 'NODE_REPL_EXTERNAL_MODULE', governs: 'every node repl' },
|
|
14
|
+
{ keys: ['GIT_EXTERNAL_DIFF'], key: 'GIT_EXTERNAL_DIFF', governs: 'every git diff' },
|
|
15
|
+
{ keys: ['GIT_PROXY_COMMAND'], key: 'GIT_PROXY_COMMAND', governs: 'every git fetch over git://' },
|
|
16
|
+
{ keys: ['GIT_TEMPLATE_DIR'], key: 'GIT_TEMPLATE_DIR', governs: 'every git init / clone (hooks)' },
|
|
17
|
+
{ keys: ['GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM'], key: 'GIT_CONFIG_GLOBAL', governs: 'every git command' },
|
|
18
|
+
{ keys: ['LESSOPEN', 'LESSCLOSE'], key: 'LESSOPEN', governs: 'every less / pager invocation' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const HIJACK_SLOTS = [
|
|
22
|
+
{ keys: ['GIT_PAGER'], key: 'GIT_PAGER', governs: 'every git command that pages' },
|
|
23
|
+
{ keys: ['GIT_EDITOR'], key: 'GIT_EDITOR', governs: 'every git commit / rebase' },
|
|
24
|
+
{ keys: ['GIT_SEQUENCE_EDITOR'], key: 'GIT_SEQUENCE_EDITOR', governs: 'every git rebase -i' },
|
|
25
|
+
{ keys: ['GIT_SSH', 'GIT_SSH_COMMAND'], key: 'GIT_SSH_COMMAND', governs: 'every git fetch / push over ssh' },
|
|
26
|
+
{ keys: ['GIT_ASKPASS', 'SSH_ASKPASS'], key: 'GIT_ASKPASS', governs: 'every credential prompt' },
|
|
27
|
+
{ keys: ['EDITOR', 'VISUAL'], key: 'EDITOR', governs: 'git, crontab, and anything that opens an editor' },
|
|
28
|
+
{ keys: ['PAGER', 'MANPAGER'], key: 'PAGER', governs: 'every command that pages' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const HIJACK_PRELOADS = /\b(LD_PRELOAD|LD_AUDIT|DYLD_INSERT_LIBRARIES)\b/;
|
|
32
|
+
|
|
33
|
+
const GIT_EXEC_KEYS =
|
|
34
|
+
/\b(core\.pager|core\.editor|core\.sshCommand|core\.fsmonitor|core\.hooksPath|core\.askpass|sequence\.editor|diff\.external|diff\.[\w-]+\.textconv|filter\.[\w-]+\.(?:clean|smudge|process)|merge\.[\w-]+\.driver|credential\.helper|uploadpack\.packObjectsHook)\b/i;
|
|
35
|
+
|
|
36
|
+
const GIT_ALIAS_KEY = /\balias\.[\w-]+\b/i;
|
|
37
|
+
|
|
38
|
+
const HIJACK_PLAIN_PROGRAM = /^[\w./-]{1,64}(?:\s+-{1,2}[\w-]{1,32}){0,4}$/;
|
|
39
|
+
|
|
40
|
+
const HIJACK_SHELLY = /[;&|`$(){}<>]|\s-c\s|(?<![.\w])(?:sh|bash|zsh|dash|python\d?|node|perl|ruby|eval)\b/i;
|
|
41
|
+
|
|
42
|
+
const HIJACK_WORLD_WRITABLE = /(^|[\s'"=:])(\/tmp\/|\/var\/tmp\/|\/dev\/shm\/|~\/\.cache\/|\$TMPDIR|\/private\/tmp\/)/i;
|
|
43
|
+
|
|
44
|
+
const HIJACK_ASSIGN =
|
|
45
|
+
/(?:^|[\s;&|(]|\b(?:export|declare|typeset|setenv|set\s+-x)\s+)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*("([^"]*)"|'([^']*)'|[^\s;&|)]*)/g;
|
|
46
|
+
|
|
47
|
+
const HIJACK_GIT_CONFIG =
|
|
48
|
+
/\bgit\s+config\s+(?:--(?:global|system|local|worktree|add|replace-all)\s+|--file\s+\S+\s+)*([\w.*-]+)\s+("[^"]*"|'[^']*'|\S+)/i;
|
|
49
|
+
|
|
50
|
+
const hijackUnquote = (raw) => String(raw ?? '').replace(/^["']|["']$/g, '');
|
|
51
|
+
|
|
52
|
+
function hijackForeignTarget(value) {
|
|
53
|
+
if (HIJACK_SHELLY.test(value)) return true;
|
|
54
|
+
for (const raw of String(value).split(/[\s,]+/)) {
|
|
55
|
+
const token = raw.replace(/^--?[A-Za-z][\w-]*[=:]?/, '').replace(/^file:\/\//, '');
|
|
56
|
+
if (/^~?\//.test(token)) return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const hijackLoaderSeverity = (v) => (!String(v).trim() ? 'MEDIUM' : HIJACK_SHELLY.test(v) || HIJACK_WORLD_WRITABLE.test(v) ? 'CRITICAL' : 'HIGH');
|
|
62
|
+
|
|
63
|
+
function hijackPathShadow(value) {
|
|
64
|
+
const head = hijackUnquote(value).split(':')[0]?.trim();
|
|
65
|
+
if (!head || /\$PATH/.test(head)) return null;
|
|
66
|
+
if (/^(\.|\.\/|\$\{?PWD\}?|\$\{?CI_PROJECT_DIR\}?|\$\{?GITHUB_WORKSPACE\}?|node_modules|\$\{?HOME\}?\/\.(?:local|nvm|rbenv|pyenv|cargo|bun|deno|volta)\b|~\/\.(?:local|nvm|rbenv|pyenv|cargo|bun|deno|volta)\b)/i.test(head)) return null;
|
|
67
|
+
if (!HIJACK_WORLD_WRITABLE.test(head) && !/^[^/$~]/.test(head)) return null;
|
|
68
|
+
return {
|
|
69
|
+
vector: 'path-shadow', key: 'PATH', governs: 'every command resolved by name',
|
|
70
|
+
severity: HIJACK_WORLD_WRITABLE.test(head) ? 'HIGH' : 'MEDIUM', value: head,
|
|
71
|
+
detail: `PATH is prepended with "${head}", which is outside the workspace. Every later command resolved by NAME - including any an allowlist names - can be shadowed from there.`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function detectExecutionHijack(command) {
|
|
76
|
+
const text = String(command ?? '');
|
|
77
|
+
if (!text.trim()) return [];
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const m of text.matchAll(HIJACK_ASSIGN)) {
|
|
80
|
+
const key = m[1];
|
|
81
|
+
const value = hijackUnquote(m[2] ?? '');
|
|
82
|
+
if (key === 'PATH') {
|
|
83
|
+
const p = hijackPathShadow(m[2] ?? '');
|
|
84
|
+
if (p) out.push(p);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const loader = HIJACK_LOADERS.find((l) => l.keys.includes(key));
|
|
88
|
+
if (loader && (!loader.requires || loader.requires.test(value)) && (!loader.foreignOnly || hijackForeignTarget(value))) {
|
|
89
|
+
out.push({
|
|
90
|
+
vector: 'env-var', key: loader.key, governs: loader.governs, severity: hijackLoaderSeverity(value), value,
|
|
91
|
+
detail: `${loader.key} names code that ${loader.governs} loads before doing anything else. Setting it turns an already-approved command into one that runs "${value || '(empty)'}" first - no dangerous command is ever issued.`,
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (HIJACK_PRELOADS.test(key)) {
|
|
96
|
+
out.push({
|
|
97
|
+
vector: 'env-var', key, governs: 'every dynamically linked process', severity: 'CRITICAL', value,
|
|
98
|
+
detail: `${key} injects "${value}" into every process started afterwards, whatever the allowlist says about the command that starts it.`,
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const slot = HIJACK_SLOTS.find((p) => p.keys.includes(key));
|
|
103
|
+
if (slot && value && !HIJACK_PLAIN_PROGRAM.test(value.trim())) {
|
|
104
|
+
out.push({
|
|
105
|
+
vector: 'env-var', key: slot.key, governs: slot.governs, severity: HIJACK_SHELLY.test(value) ? 'CRITICAL' : 'HIGH', value,
|
|
106
|
+
detail: `${slot.key} is set to "${value}", which is a command line rather than an editor or pager. ${slot.governs} will run it - the hijack rides an approved command, not a refused one.`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const line of text.split(/[\n;]|&&|\|\|/)) {
|
|
111
|
+
const m = HIJACK_GIT_CONFIG.exec(line);
|
|
112
|
+
if (!m) continue;
|
|
113
|
+
const key = m[1];
|
|
114
|
+
const value = hijackUnquote(m[2].trim());
|
|
115
|
+
const isAlias = GIT_ALIAS_KEY.test(key) && /^\s*!/.test(value);
|
|
116
|
+
if (!GIT_EXEC_KEYS.test(key) && !isAlias) continue;
|
|
117
|
+
const shelly = HIJACK_SHELLY.test(value) || HIJACK_WORLD_WRITABLE.test(value) || isAlias;
|
|
118
|
+
if (!shelly && HIJACK_PLAIN_PROGRAM.test(value)) continue;
|
|
119
|
+
out.push({
|
|
120
|
+
vector: 'git-config', key,
|
|
121
|
+
governs: isAlias ? `git ${key.split('.')[1]}` : 'every later git command that reaches this hook',
|
|
122
|
+
severity: shelly ? 'CRITICAL' : 'HIGH', value,
|
|
123
|
+
detail: `git config "${key}" is set to "${value}". Git executes this value, so every later git command - including ones an allowlist names - runs it. The configuration outlives the session.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
return out.filter((s) => (seen.has(`${s.vector}:${s.key}`) ? false : (seen.add(`${s.vector}:${s.key}`), true)));
|
|
128
|
+
}
|