@shomra/agent 0.3.17 → 0.3.18
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 +37 -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
|
@@ -1,33 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* shomra design — threat-model a system that does not exist yet.
|
|
3
|
-
*
|
|
4
|
-
* Every other Shomra surface needs an artifact: a file to gate, a call to
|
|
5
|
-
* screen, a repo to scan. This one reads a DESCRIPTION — a design doc, an RFC, a
|
|
6
|
-
* Jira/Linear ticket, a PR body — and answers the only question worth asking
|
|
7
|
-
* before the first line is written: does the thing being described hand an
|
|
8
|
-
* attacker a path from untrusted input to a consequence?
|
|
9
|
-
*
|
|
10
|
-
* The engine is the platform's, not a new one. `attack-graph.ts` models an
|
|
11
|
-
* entity as six capability flags split into SOURCES (untrusted input, sensitive
|
|
12
|
-
* reads, filesystem) and SINKS (network egress, execution, destructive action),
|
|
13
|
-
* and calls a closed source→sink pair an attack path. That model does not care
|
|
14
|
-
* whether the capabilities came from a scan or from a sentence. Here they come
|
|
15
|
-
* from a sentence.
|
|
16
|
-
*
|
|
17
|
-
* ⚠ THE INVARIANT THAT MATTERS: absence of a described capability is NOT absence
|
|
18
|
-
* of the capability. Prose is written by people who leave things out. Every
|
|
19
|
-
* verdict this module can return names what it FOUND; none of them says the
|
|
20
|
-
* design is safe, and `NOT_DESCRIBED` is not a pass. Getting this wrong would
|
|
21
|
-
* turn a thinking aid into false assurance at the exact moment — before the
|
|
22
|
-
* build — when false assurance is cheapest to act on and most expensive to
|
|
23
|
-
* discover.
|
|
24
|
-
*
|
|
25
|
-
* Zero dependencies (Node built-ins only), like every other module here.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
// The capability vocabulary, mirroring `Caps` in the backend's attack-graph.ts.
|
|
29
|
-
// Keep the split identical: a divergence here would produce a CLI threat model
|
|
30
|
-
// that disagrees with the platform's for the same system.
|
|
1
|
+
|
|
31
2
|
export const SOURCE_CAPS = ['injection', 'readsSensitive', 'filesystem'];
|
|
32
3
|
export const SINK_CAPS = ['network', 'exec', 'destructive'];
|
|
33
4
|
|
|
@@ -40,35 +11,16 @@ export const CAP_LABEL = {
|
|
|
40
11
|
destructive: 'destructive action',
|
|
41
12
|
};
|
|
42
13
|
|
|
43
|
-
/**
|
|
44
|
-
* Prose → capability. Each rule carries the phrasing a designer actually uses,
|
|
45
|
-
* not the phrasing a scanner would emit. `what` is the human noun that goes into
|
|
46
|
-
* the attack story, so a path reads as a sentence about the system rather than a
|
|
47
|
-
* list of flags.
|
|
48
|
-
*
|
|
49
|
-
* Rules are matched per line so the evidence can cite one.
|
|
50
|
-
*
|
|
51
|
-
* ⚠ A line that DISCLAIMS a capability must not grant it. This originally said
|
|
52
|
-
* the opposite — that a "we will not do X" sentence was a design decision worth
|
|
53
|
-
* surfacing — and that was wrong: granting the flag from a disclaimer invents a
|
|
54
|
-
* phantom attack path out of the one sentence that rules it out, which is the
|
|
55
|
-
* most misleading output this tool can produce. "The tool has no access to
|
|
56
|
-
* customer data" is the designer telling you the source does not exist.
|
|
57
|
-
*/
|
|
58
14
|
const DISCLAIMER_RE = /\b(no|never|not|without|excludes?|excluding|neither|nor)\b[^.\n]{0,30}\b(access|read|write|permission|abilit|able|connection|integration)\w*|\b(does |do |will |can |must )(not|n't)\b|\bout of scope\b|\bnon-goals?\b|\bis not (able|permitted|allowed)\b/i;
|
|
59
15
|
const CAP_RULES = [
|
|
60
|
-
|
|
16
|
+
|
|
61
17
|
{ cap: 'injection', what: 'end-user or customer text', re: /\b(user|customer|client|end[- ]user)[- ]?(input|message|text|query|prompt|request|content|submission)\b/i },
|
|
62
18
|
{ cap: 'injection', what: 'inbound email', re: /\b(inbound |incoming |receiv\w+ )?e-?mails?\b|\bmailbox\b|\bimap\b|\bsupport inbox\b/i },
|
|
63
19
|
{ cap: 'injection', what: 'support tickets', re: /\b(support |help[- ]?desk |zendesk |intercom |freshdesk )?tickets?\b|\bcase notes?\b/i },
|
|
64
20
|
{ cap: 'injection', what: 'issues and PR descriptions', re: /\b(github |gitlab |jira |linear )?(issues?|pull[- ]requests?|PR) (body|description|comments?)\b|\bissue tracker\b/i },
|
|
65
|
-
|
|
66
|
-
// uploaded PDFs"), and an `\bpdf\b` that cannot match "PDFs" is a rule that
|
|
67
|
-
// misses the common phrasing while looking correct in a unit test.
|
|
21
|
+
|
|
68
22
|
{ cap: 'injection', what: 'uploaded documents', re: /\b(upload(ed|s)?|attach(ed|ment)s?)\b.{0,30}\b(files?|documents?|pdfs?|images?|csvs?|spreadsheets?)\b|\b(pdf|docx|csv)s? (upload|ingest|pars\w+)/i },
|
|
69
|
-
|
|
70
|
-
// boundary is untrusted whatever shape it arrives in. Anchored on a receiving
|
|
71
|
-
// verb + "from" + the party, so ordinary prose about customers does not fire.
|
|
23
|
+
|
|
72
24
|
{ cap: 'injection', what: 'content received from outside', re: /\b(ingest|receiv|accept|import|process|pull|collect|read|fetch)\w*\b[^.\n]{0,40}\bfrom\b[^.\n]{0,25}\b(customers?|users?|clients?|end[- ]users?|the public|third[- ]part\w+|external|partners?|vendors?|suppliers?)\b/i },
|
|
73
25
|
{ cap: 'injection', what: 'scraped or fetched web content', re: /\b(scrap\w+|crawl\w+|fetch\w+|browse\w*)\b.{0,30}\b(web|site|page|url|internet)\b|\bweb (page|content|search results?)\b/i },
|
|
74
26
|
{ cap: 'injection', what: 'retrieved documents (RAG)', re: /\bRAG\b|\bretrieval[- ]augmented\b|\b(retriev\w+|search\w*) (documents?|chunks?|context|corpus)\b|\bvector (store|db|database|search)\b|\bknowledge base\b/i },
|
|
@@ -84,13 +36,9 @@ const CAP_RULES = [
|
|
|
84
36
|
{ cap: 'readsSensitive', what: 'private source code', re: /\bprivate (repo|repositor\w+|source|code)\b|\bproprietary (code|source)\b|\binternal (repo|codebase|wiki|docs?)\b/i },
|
|
85
37
|
{ cap: 'readsSensitive', what: 'object storage', re: /\bS3 bucket\b|\b(blob|object) storage\b|\bGCS bucket\b|\bdata lake\b/i },
|
|
86
38
|
|
|
87
|
-
// Plurals throughout: "writes the generated files to the repo" is the normal
|
|
88
|
-
// phrasing, and `\bfile\b` cannot match it. Same blind spot as `\bpdf\b` vs
|
|
89
|
-
// "PDFs" — assume every noun here arrives plural at least half the time.
|
|
90
39
|
{ cap: 'filesystem', what: 'file writes', re: /\bwrit\w+\b[^.\n]{0,25}\b(files?|disks?|filesystems?|director(y|ies)|folders?|repos?|repositor(y|ies))\b|\b(file ?system|local files?) (access|write)\b|\bcommits? (code|files?|changes?)\b/i },
|
|
91
40
|
{ cap: 'filesystem', what: 'workspace or repo checkout', re: /\b(clones?|checks? out|checkout)\b.{0,25}\b(repo|repositor\w+)\b|\bworkspace (access|mount|volume)\b/i },
|
|
92
41
|
|
|
93
|
-
// ── SINKS ──────────────────────────────────────────────────────────────────
|
|
94
42
|
{ cap: 'network', what: 'outbound API calls', re: /\bcalls?\b.{0,30}\b(external|third[- ]party|public|remote|partner)\b.{0,20}\bapi\b|\boutbound (request|call|http|traffic)\b|\begress\b/i },
|
|
95
43
|
{ cap: 'network', what: 'webhooks', re: /\bwebhooks?\b|\bpost(s|ing|ed)?\b[^.\n]{0,30}\bto\b[^.\n]{0,25}\b(endpoints?|urls?|callbacks?|apis?|services?|partners?|systems?)\b/i },
|
|
96
44
|
{ cap: 'network', what: 'sending email or messages', re: /\bsends?\b.{0,25}\b(e-?mail|message|notification|sms|slack|dm)\b|\bnotif(y|ies|ication)\b.{0,25}\b(user|customer|channel|slack|teams|email)\b|\bsmtp\b/i },
|
|
@@ -103,25 +51,17 @@ const CAP_RULES = [
|
|
|
103
51
|
{ cap: 'exec', what: 'agent tool calls', re: /\b(tool[- ]call|function[- ]call|tool use|agentic loop|autonomous(ly)?)\b|\bagent (executes?|acts?|takes? actions?)\b/i },
|
|
104
52
|
|
|
105
53
|
{ cap: 'destructive', what: 'deleting data', re: /\bdelet\w+|\bremov\w+\b.{0,20}\b(record|row|file|user|account|data)\b|\bpurge\b|\bdrop (table|database)\b|\btruncat\w+/i },
|
|
106
|
-
|
|
107
|
-
// history" is a read. The line has to name an act that MOVES the money, so the
|
|
108
|
-
// verb is required and read-only verbs are not on the list.
|
|
54
|
+
|
|
109
55
|
{ cap: 'destructive', what: 'moving money', re: /\b(issues?|issuing|processes|processing|triggers?|initiates?|creates?|approves?|grants?|sends?|makes?|charges?|refunds?|voids?|cancels?)\b[^.\n]{0,30}\b(refunds?|payments?|charges?|invoices?|payouts?|transfers?|subscriptions?|purchases?|orders?)\b|\bmoves? money\b|\bthrough stripe\b|\bvia stripe\b/i },
|
|
110
56
|
{ cap: 'destructive', what: 'changing access or state', re: /\b(revok\w+|disabl\w+|suspend\w+|deactivat\w+|cancel\w*|ban\w*)\b.{0,25}\b(user|account|access|key|token|subscription|service)\b|\bgrants? (access|permission|role)\b/i },
|
|
111
57
|
{ cap: 'destructive', what: 'writing to production', re: /\bwrit\w+\b.{0,25}\bprod(uction)?\b|\bprod(uction)?\b.{0,20}\bwrite (access|path)\b|\bmutat\w+\b.{0,25}\b(prod|live|customer) (data|state)\b/i },
|
|
112
58
|
];
|
|
113
59
|
|
|
114
|
-
/** Lines that are headings, code fences or list scaffolding carry no design intent. */
|
|
115
60
|
function isSkippableLine(line) {
|
|
116
61
|
const t = line.trim();
|
|
117
62
|
return !t || t === '---' || /^```/.test(t) || /^\|[\s-:|]+\|$/.test(t);
|
|
118
63
|
}
|
|
119
64
|
|
|
120
|
-
/**
|
|
121
|
-
* Extract the capability set a document DESCRIBES, with the line and phrase that
|
|
122
|
-
* evidenced each — the evidence is the point: a reader has to be able to check
|
|
123
|
-
* the machine's reading against their own words and disagree with it.
|
|
124
|
-
*/
|
|
125
65
|
export function capsFromProse(text) {
|
|
126
66
|
const caps = { injection: false, readsSensitive: false, filesystem: false, network: false, exec: false, destructive: false };
|
|
127
67
|
const evidence = {};
|
|
@@ -131,11 +71,9 @@ export function capsFromProse(text) {
|
|
|
131
71
|
for (let i = 0; i < lines.length; i++) {
|
|
132
72
|
const line = lines[i];
|
|
133
73
|
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
|
|
134
|
-
|
|
135
|
-
// intent — and they are exactly where a scanner's vocabulary produces noise.
|
|
74
|
+
|
|
136
75
|
if (inFence || isSkippableLine(line)) continue;
|
|
137
|
-
|
|
138
|
-
// flag here would invent an attack path from the sentence that removes it.
|
|
76
|
+
|
|
139
77
|
if (DISCLAIMER_RE.test(line)) continue;
|
|
140
78
|
|
|
141
79
|
for (const r of CAP_RULES) {
|
|
@@ -143,22 +81,15 @@ export function capsFromProse(text) {
|
|
|
143
81
|
if (!m) continue;
|
|
144
82
|
caps[r.cap] = true;
|
|
145
83
|
const list = (evidence[r.cap] = evidence[r.cap] || []);
|
|
146
|
-
if (list.some((e) => e.what === r.what)) continue;
|
|
84
|
+
if (list.some((e) => e.what === r.what)) continue;
|
|
147
85
|
list.push({ what: r.what, line: i + 1, quote: line.trim().slice(0, 160), match: m[0].slice(0, 60), score: evidenceScore(line) });
|
|
148
86
|
}
|
|
149
87
|
}
|
|
150
|
-
|
|
151
|
-
// line that DESCRIBES the behaviour, not the first line the word appeared on.
|
|
152
|
-
// "Reduce first-response time on support tickets" (a goal) and "polls the
|
|
153
|
-
// support inbox and reads inbound emails" (the design) both mention tickets;
|
|
154
|
-
// quoting the goal makes the finding look like a keyword hit and is the
|
|
155
|
-
// fastest way for a reader to stop trusting the output.
|
|
88
|
+
|
|
156
89
|
for (const k of Object.keys(evidence)) evidence[k].sort((a, b) => b.score - a.score || a.line - b.line);
|
|
157
90
|
return { caps, evidence };
|
|
158
91
|
}
|
|
159
92
|
|
|
160
|
-
// A line that says what the system DOES, rather than what it is for. Used only
|
|
161
|
-
// to order evidence — never to grant or withhold a capability.
|
|
162
93
|
const ACTION_LINE_RE = /\b(reads?|writes?|polls?|fetch\w*|calls?|sends?|runs?|executes?|issues?|looks? up|quer\w+|retriev\w+|ingest\w*|receiv\w+|access\w*|store[sd]?|upload\w*|post\w*|delet\w+|creat\w+|updat\w+|has|have|will|can|must)\b/i;
|
|
163
94
|
const GOAL_LINE_RE = /^\s{0,3}#{1,6}\s|^\s*(goal|motivation|background|summary|context|out of scope|non-goals?)\b/i;
|
|
164
95
|
|
|
@@ -166,12 +97,10 @@ function evidenceScore(line) {
|
|
|
166
97
|
let s = 0;
|
|
167
98
|
if (ACTION_LINE_RE.test(line)) s += 3;
|
|
168
99
|
if (GOAL_LINE_RE.test(line)) s -= 3;
|
|
169
|
-
if (line.trim().length > 60) s += 1;
|
|
100
|
+
if (line.trim().length > 60) s += 1;
|
|
170
101
|
return s;
|
|
171
102
|
}
|
|
172
103
|
|
|
173
|
-
/** Severity of a closed path. Mirrors chainSeverity() in attack-graph.ts:
|
|
174
|
-
* untrusted input reaching a hard sink is the worst case in the model. */
|
|
175
104
|
function pathSeverity(source, sink) {
|
|
176
105
|
const hardSink = sink === 'exec' || sink === 'destructive';
|
|
177
106
|
if (hardSink && source === 'injection') return 'CRITICAL';
|
|
@@ -180,9 +109,6 @@ function pathSeverity(source, sink) {
|
|
|
180
109
|
return 'MEDIUM';
|
|
181
110
|
}
|
|
182
111
|
|
|
183
|
-
// What has to be true for a given source→sink pair to be safe to build. These
|
|
184
|
-
// are stated as testable conditions rather than advice, because their job is to
|
|
185
|
-
// become the acceptance criteria on the ticket that describes the system.
|
|
186
112
|
const CONTROLS = {
|
|
187
113
|
'injection→exec': [
|
|
188
114
|
'The set of commands the agent can run is a fixed allowlist in code. Model output selects WHICH allowlisted action runs, never the command string itself.',
|
|
@@ -226,20 +152,9 @@ const CONTROLS = {
|
|
|
226
152
|
const GENERIC_CONTROLS = [
|
|
227
153
|
'Give the agent its own identity with its own credentials, so its actions are attributable and revocable independently of a human user.',
|
|
228
154
|
'Record every tool call the agent makes, with the input that caused it, so an incident can be reconstructed.',
|
|
229
|
-
'Decide now what the agent must NOT be able to do, and enforce it in code rather than in the prompt
|
|
155
|
+
'Decide now what the agent must NOT be able to do, and enforce it in code rather than in the prompt - a prompt is a request, not a control.',
|
|
230
156
|
];
|
|
231
157
|
|
|
232
|
-
/**
|
|
233
|
-
* Threat-model a described system.
|
|
234
|
-
*
|
|
235
|
-
* `verdict` deliberately has no clean value:
|
|
236
|
-
* OPEN_PATH — a source and a sink are both described; the path is closed.
|
|
237
|
-
* PARTIAL — only one side is described. Not safety: the other side may
|
|
238
|
-
* simply be unwritten, or may arrive in the next sprint.
|
|
239
|
-
* NOT_DESCRIBED — neither side was recognised. The likeliest reading is that
|
|
240
|
-
* the document does not describe capabilities in a way this
|
|
241
|
-
* matched, NOT that the system has none.
|
|
242
|
-
*/
|
|
243
158
|
export function analyzeDesign(text, { name = 'design' } = {}) {
|
|
244
159
|
const { caps, evidence } = capsFromProse(text);
|
|
245
160
|
const sources = SOURCE_CAPS.filter((c) => caps[c]);
|
|
@@ -258,8 +173,8 @@ export function analyzeDesign(text, { name = 'design' } = {}) {
|
|
|
258
173
|
key: `${s}→${k}`,
|
|
259
174
|
story:
|
|
260
175
|
`${cap(srcEv ? srcEv.what : CAP_LABEL[s])} reaches ${sinkEv ? sinkEv.what : CAP_LABEL[k]}` +
|
|
261
|
-
`${s === 'injection' ? '
|
|
262
|
-
`${s === 'readsSensitive' && k === 'network' ? '
|
|
176
|
+
`${s === 'injection' ? ' - whoever writes that input is choosing what the agent does' : ''}` +
|
|
177
|
+
`${s === 'readsSensitive' && k === 'network' ? ' - the data and the way out are held by the same component' : ''}.`,
|
|
263
178
|
sourceEvidence: srcEv || null,
|
|
264
179
|
sinkEvidence: sinkEv || null,
|
|
265
180
|
controls: CONTROLS[`${s}→${k}`] || [],
|
|
@@ -271,8 +186,6 @@ export function analyzeDesign(text, { name = 'design' } = {}) {
|
|
|
271
186
|
const verdict = paths.length ? 'OPEN_PATH' : sources.length || sinks.length ? 'PARTIAL' : 'NOT_DESCRIBED';
|
|
272
187
|
const worst = paths.length ? paths[0].severity : null;
|
|
273
188
|
|
|
274
|
-
// Deduplicate controls across paths, worst-severity first, then append the
|
|
275
|
-
// ones that apply to any agent with a consequence.
|
|
276
189
|
const seen = new Set();
|
|
277
190
|
const controls = [];
|
|
278
191
|
for (const p of paths) for (const c of p.controls) if (!seen.has(c)) { seen.add(c); controls.push({ text: c, from: p.key, severity: p.severity }); }
|
|
@@ -287,27 +200,25 @@ function cap(s) {
|
|
|
287
200
|
return String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1);
|
|
288
201
|
}
|
|
289
202
|
|
|
290
|
-
/** The result as a markdown task list — the form that becomes the ticket's
|
|
291
|
-
* acceptance criteria, which is the only form anyone acts on. */
|
|
292
203
|
export function designChecklist(result) {
|
|
293
204
|
const out = [];
|
|
294
|
-
out.push(`## Security acceptance criteria
|
|
205
|
+
out.push(`## Security acceptance criteria - ${result.name}`);
|
|
295
206
|
out.push('');
|
|
296
207
|
if (result.verdict !== 'OPEN_PATH') {
|
|
297
208
|
out.push(
|
|
298
209
|
result.verdict === 'PARTIAL'
|
|
299
210
|
? `Only one side of an attack path is described here (${[...result.sources, ...result.sinks].map((c) => CAP_LABEL[c]).join(', ')}). Re-run this when the design names what the agent can *do* with it.`
|
|
300
|
-
: 'No capabilities were recognised in this document. That is a statement about the document, not about the system
|
|
211
|
+
: 'No capabilities were recognised in this document. That is a statement about the document, not about the system - if the agent will read anything untrusted or take any action, write that down and re-run.',
|
|
301
212
|
);
|
|
302
213
|
out.push('');
|
|
303
214
|
return out.join('\n') + '\n';
|
|
304
215
|
}
|
|
305
216
|
out.push(`This design closes ${result.paths.length} attack path${result.paths.length === 1 ? '' : 's'}. Each item below is a condition to satisfy before it ships.`);
|
|
306
217
|
out.push('');
|
|
307
|
-
for (const p of result.paths.slice(0, 6)) out.push(`- **${p.severity} · ${CAP_LABEL[p.source]} → ${CAP_LABEL[p.sink]}**
|
|
218
|
+
for (const p of result.paths.slice(0, 6)) out.push(`- **${p.severity} · ${CAP_LABEL[p.source]} → ${CAP_LABEL[p.sink]}** - ${p.story}`);
|
|
308
219
|
out.push('');
|
|
309
220
|
for (const c of result.controls) out.push(`- [ ] ${c.text}`);
|
|
310
221
|
out.push('');
|
|
311
|
-
out.push('_Derived by `shomra design` from the description above. It reads prose, so it sees only what was written down
|
|
222
|
+
out.push('_Derived by `shomra design` from the description above. It reads prose, so it sees only what was written down - a capability nobody documented is not a capability you do not have._');
|
|
312
223
|
return out.join('\n') + '\n';
|
|
313
224
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { autonomySeverity, localAutonomy } from './signals/autonomy.mjs';
|
|
2
|
+
export { RISKY_CONFIG_MARKERS } from './signals/config-markers.mjs';
|
|
3
|
+
export { detectCredentialHarvest } from './signals/credential-harvest.mjs';
|
|
4
|
+
export { claimSeverity, detectDurableClaims } from './signals/durable-claims.mjs';
|
|
5
|
+
export { SUSPICIOUS_EGRESS_HOSTS, egressHost } from './signals/egress.mjs';
|
|
6
|
+
export { detectExecutionHijack } from './signals/execution-hijack.mjs';
|
|
7
|
+
export { localGate } from './signals/gate.mjs';
|
|
8
|
+
export { INJECTION_PHRASES, INJECTION_REGEXES, INVISIBLE_CHARS_RE } from './signals/injection.mjs';
|
|
9
|
+
export { localMemory } from './signals/memory.mjs';
|
|
10
|
+
export { AGENT_ROOT_RE, isAgentAdjacentPath, localPropagation } from './signals/propagation.mjs';
|
|
11
|
+
export { citationGoverns, isDocumentationLine, prohibitsAt } from './signals/prose-context.mjs';
|
|
12
|
+
export { downrankCodeContext, localScan } from './signals/scan.mjs';
|
|
13
|
+
export { PII_PATTERNS, SECRET_PATTERNS } from './signals/secrets.mjs';
|
|
14
|
+
export { NETWORK_VERBS, SENSITIVE_READ } from './signals/sensitive.mjs';
|
|
15
|
+
export { grade } from './signals/severity.mjs';
|
|
16
|
+
export { DANGEROUS_SHELL, matchesShellSignal } from './signals/shell.mjs';
|
|
17
|
+
export { scanStagedFetchExec } from './signals/staged-fetch.mjs';
|
|
18
|
+
export { containsAny, containsWord } from './signals/text-match.mjs';
|
|
@@ -1,49 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
* Model-reference extractor — finds the AI MODELS a dev's code loads, so the CLI
|
|
3
|
-
* can look each one up in the platform's Model Security Index (GET /models/lookup)
|
|
4
|
-
* and surface known vulnerabilities. This is the "you reference a model → we tell
|
|
5
|
-
* you if it's dangerous" path: you can't read a model's weights from source, but
|
|
6
|
-
* you CAN see which model id the code pulls, and the platform has already scanned
|
|
7
|
-
* the popular ones.
|
|
8
|
-
*
|
|
9
|
-
* Dependency-free, line-oriented, low-false-positive: model ids are only extracted
|
|
10
|
-
* from lines that carry a real loader hint (from_pretrained / SentenceTransformer /
|
|
11
|
-
* hf_hub_download / snapshot_download / pipeline(model=…) / a huggingface.co URL /
|
|
12
|
-
* torch.hub.load / ollama pull|run), never from arbitrary "a/b" strings (which are
|
|
13
|
-
* usually file paths or npm packages).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
// A line must carry one of these to be considered a model load.
|
|
1
|
+
|
|
17
2
|
const LOADER_HINT = /\b(from_pretrained|SentenceTransformer|CrossEncoder|hf_hub_download|snapshot_download|InferenceClient|AutoModel\w*|AutoTokenizer|AutoConfig|AutoProcessor|AutoFeatureExtractor|from_hf_hub|hf_hub|load_dataset|torch\.hub\.load|ollama)\b|\bpipeline\s*\(|\bmodel\s*[=:]\s*['"]|huggingface\.co|\bhf\.co\b/i;
|
|
18
3
|
|
|
19
|
-
// A quoted HF-style id: "org/model" (one slash, HF-legal chars, no path/URL/ext).
|
|
20
4
|
const QUOTED_ID = /['"]([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*)['"]/g;
|
|
21
|
-
|
|
22
|
-
// accept BARE ids (no org, e.g. "gpt2", "distilbert-base-uncased") from them.
|
|
5
|
+
|
|
23
6
|
const FROM_PRETRAINED_ARG = /\bfrom_pretrained\s*\(\s*(?:[A-Za-z_][\w.]*\s*,\s*)?['"]([\w./-]+)['"]/g;
|
|
24
7
|
const ST_ARG = /\b(?:SentenceTransformer|CrossEncoder)\s*\(\s*['"]([\w./-]+)['"]/g;
|
|
25
|
-
|
|
26
|
-
// else: JS/TS object literals and JSON all write `model: "gpt-4o"`, which is how
|
|
27
|
-
// every Node OpenAI/Anthropic SDK call declares its model. A JS repo full of
|
|
28
|
-
// model calls reported "✓ No AI model references found in the code." — a green
|
|
29
|
-
// pass over a blind spot, and no vuln lookups ran for the whole estate.
|
|
30
|
-
//
|
|
31
|
-
// The VALUE STAYS QUOTED. An unquoted value matches any identifier, so
|
|
32
|
-
// `const model = keyword` and `model: capabilities` came back as models named
|
|
33
|
-
// "keyword" and "capabilities" — an inventory of things that do not exist is
|
|
34
|
-
// worse than a short one. YAML's unquoted form is handled by YAML_KW below,
|
|
35
|
-
// where line anchoring makes it safe.
|
|
8
|
+
|
|
36
9
|
const KW_ID = /\b(?:model|repo_id|model_name|model_id|model_name_or_path|pretrained_model_name_or_path|checkpoint|base_model)\s*[=:]\s*['"]([\w./-]+)['"]/gi;
|
|
37
10
|
|
|
38
|
-
// YAML: `model: gpt-4o` with no quotes. Anchored to the start of a line and to
|
|
39
|
-
// end-of-value, so it cannot fire inside expressions the way a free-floating
|
|
40
|
-
// pattern does. The value must carry a digit, a slash or a dot — model ids do
|
|
41
|
-
// (`gpt-4o`, `openai-community/gpt2`, `claude-opus-4-8`); bare English words
|
|
42
|
-
// like `capabilities` do not.
|
|
43
11
|
const YAML_KW = /^[ \t-]*(?:model|model_name|model_id|base_model|checkpoint)\s*:\s*([A-Za-z0-9][\w./-]*[\w/.-])\s*(?:#.*)?$/gim;
|
|
44
|
-
|
|
12
|
+
|
|
45
13
|
const REVISION = /\b(?:revision|commit|sha)\s*=\s*['"]([\w.-]{4,})['"]/i;
|
|
46
|
-
|
|
14
|
+
|
|
47
15
|
const ID_STOPWORDS = new Set([
|
|
48
16
|
'auto', 'cpu', 'cuda', 'mps', 'none', 'true', 'false', 'main', 'default',
|
|
49
17
|
'text-classification', 'token-classification', 'question-answering', 'fill-mask',
|
|
@@ -52,18 +20,13 @@ const ID_STOPWORDS = new Set([
|
|
|
52
20
|
'image-classification', 'object-detection', 'automatic-speech-recognition',
|
|
53
21
|
'conversational', 'ner', 'sentiment-analysis', 'embeddings', 'chat', 'completion',
|
|
54
22
|
]);
|
|
55
|
-
|
|
23
|
+
|
|
56
24
|
const HF_URL = /https?:\/\/(?:www\.)?(?:huggingface\.co|hf\.co)\/([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*)(?:\/tree\/([\w.-]+))?/gi;
|
|
57
|
-
|
|
25
|
+
|
|
58
26
|
const OLLAMA = /\bollama\s+(?:pull|run|cp|create)\s+([a-z0-9][\w.:\/-]*)/gi;
|
|
59
|
-
|
|
27
|
+
|
|
60
28
|
const TORCH_HUB = /torch\.hub\.load\s*\(\s*['"]([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*)['"]/g;
|
|
61
29
|
|
|
62
|
-
// Hosted-API model families. A bare `model="gpt-4o"` / `model="claude-…"` is an
|
|
63
|
-
// OpenAI/Anthropic/Google/etc API call, NOT a Hugging Face repo — but `model=` is
|
|
64
|
-
// their SDK param too, so KW_ID/from_pretrained would otherwise tag these 'hf' and
|
|
65
|
-
// trigger a doomed HF-Index lookup ("gpt-4o (hf) lookup failed"). Recognize them
|
|
66
|
-
// and tag 'api' with the provider. Prefix-anchored to avoid matching HF repos.
|
|
67
30
|
const API_MODEL = /^(?:gpt-|gpt4|o[1-4](?:-|$)|text-embedding-|text-(?:davinci|curie|babbage|ada)|davinci|dall-e|whisper-|tts-|chatgpt|claude[-\d]|gemini[-.]|gemini$|models\/gemini|mistral-|mixtral-|codestral-|command(?:-|$)|command-r|grok-|deepseek-(?:chat|coder|reasoner)|sonar-)/i;
|
|
68
31
|
function apiProvider(id) {
|
|
69
32
|
const s = String(id || '').toLowerCase();
|
|
@@ -78,10 +41,8 @@ function apiProvider(id) {
|
|
|
78
41
|
return 'api';
|
|
79
42
|
}
|
|
80
43
|
|
|
81
|
-
// Reject ids that are really file paths, packages, or non-model strings.
|
|
82
44
|
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;
|
|
83
|
-
|
|
84
|
-
// huggingface.co/docs/datasets isn't mistaken for the model "docs/datasets".
|
|
45
|
+
|
|
85
46
|
const NONMODEL_ORGS = new Set([
|
|
86
47
|
'docs', 'blog', 'spaces', 'datasets', 'models', 'join', 'login', 'settings',
|
|
87
48
|
'pricing', 'tasks', 'learn', 'papers', 'collections', 'organizations', 'new',
|
|
@@ -90,14 +51,13 @@ const NONMODEL_ORGS = new Set([
|
|
|
90
51
|
function looksLikeModelId(id) {
|
|
91
52
|
if (!id || id.startsWith('@') || id.startsWith('.') || id.startsWith('/')) return false;
|
|
92
53
|
if (id.includes('..') || id.split('/').length !== 2) return false;
|
|
93
|
-
if (ASSET_EXT.test(id)) return false;
|
|
54
|
+
if (ASSET_EXT.test(id)) return false;
|
|
94
55
|
const [a, b] = id.split('/');
|
|
95
|
-
if (!/[A-Za-z]/.test(a) || !/[A-Za-z]/.test(b)) return false;
|
|
96
|
-
if (NONMODEL_ORGS.has(a.toLowerCase())) return false;
|
|
56
|
+
if (!/[A-Za-z]/.test(a) || !/[A-Za-z]/.test(b)) return false;
|
|
57
|
+
if (NONMODEL_ORGS.has(a.toLowerCase())) return false;
|
|
97
58
|
return true;
|
|
98
59
|
}
|
|
99
|
-
|
|
100
|
-
// a task/device token or an asset path.
|
|
60
|
+
|
|
101
61
|
function validBareId(id) {
|
|
102
62
|
if (!id || id.startsWith('@') || id.startsWith('.') || id.startsWith('/') || id.includes('..')) return false;
|
|
103
63
|
if (ID_STOPWORDS.has(id.toLowerCase())) return false;
|
|
@@ -106,23 +66,14 @@ function validBareId(id) {
|
|
|
106
66
|
return true;
|
|
107
67
|
}
|
|
108
68
|
|
|
109
|
-
/**
|
|
110
|
-
* Extract model references from one source file's text. Returns
|
|
111
|
-
* `[{ id, revision?, source, line, via }]` — `source` is the origin registry
|
|
112
|
-
* ('hf' | 'github' | 'ollama'), `via` names the matched loader for evidence.
|
|
113
|
-
* De-duplicated per (id, revision) within the file, keeping the first line.
|
|
114
|
-
*/
|
|
115
69
|
export function scanModelRefs(text, file = '') {
|
|
116
70
|
if (!text) return [];
|
|
117
71
|
const out = [];
|
|
118
72
|
const seen = new Set();
|
|
119
|
-
|
|
120
|
-
// position (from_pretrained/SentenceTransformer/model=); ollama ids are freeform.
|
|
73
|
+
|
|
121
74
|
const add = (id, { revision, source, line, via, bare }) => {
|
|
122
75
|
if (!id) return;
|
|
123
|
-
|
|
124
|
-
// from_pretrained). It is not an HF repo — reclassify to 'api' + provider so it
|
|
125
|
-
// is labeled correctly and skips the HF-Index lookup. See API_MODEL.
|
|
76
|
+
|
|
126
77
|
if (source === 'hf' && !id.includes('/') && API_MODEL.test(id)) {
|
|
127
78
|
source = 'api';
|
|
128
79
|
via = `${via} · ${apiProvider(id)} API`;
|
|
@@ -141,38 +92,29 @@ export function scanModelRefs(text, file = '') {
|
|
|
141
92
|
if (!trimmed) continue;
|
|
142
93
|
const ln = i + 1;
|
|
143
94
|
|
|
144
|
-
// 1. Full HF URLs — scanned on EVERY line (incl. comments / README / markdown
|
|
145
|
-
// headers), since a huggingface.co URL is an unambiguous model reference.
|
|
146
95
|
for (const m of raw.matchAll(HF_URL)) add(m[1], { revision: m[2], source: 'hf', line: ln, via: 'huggingface.co URL' });
|
|
147
96
|
|
|
148
|
-
// Code-oriented extractors below skip pure comment lines (a commented-out load).
|
|
149
97
|
if (trimmed.startsWith('#') || trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
|
|
150
98
|
|
|
151
|
-
// 2. torch.hub.load("owner/repo", …) → GitHub-hosted, runs hubconf.py.
|
|
152
99
|
const isTorchHub = /torch\.hub\.load/.test(raw);
|
|
153
100
|
for (const m of raw.matchAll(TORCH_HUB)) add(m[1], { source: 'github', line: ln, via: 'torch.hub.load' });
|
|
154
101
|
|
|
155
|
-
// 3. ollama pull/run <model>.
|
|
156
102
|
const isOllama = /\bollama\b/.test(raw);
|
|
157
103
|
for (const m of raw.matchAll(OLLAMA)) add(m[1], { source: 'ollama', line: ln, via: 'ollama' });
|
|
158
104
|
|
|
159
|
-
// Lines already fully handled by a specific matcher shouldn't also be mined
|
|
160
|
-
// by the generic HF extractors (avoids torch.hub's owner/repo re-added as hf).
|
|
161
105
|
if (isTorchHub || isOllama || /huggingface\.co|hf\.co/.test(raw)) continue;
|
|
162
106
|
|
|
163
107
|
const rev = (raw.match(REVISION) || [])[1];
|
|
164
|
-
|
|
108
|
+
|
|
165
109
|
for (const m of raw.matchAll(FROM_PRETRAINED_ARG)) add(m[1], { revision: rev, source: 'hf', line: ln, via: 'from_pretrained', bare: true });
|
|
166
110
|
for (const m of raw.matchAll(ST_ARG)) add(m[1], { revision: rev, source: 'hf', line: ln, via: 'sentence-transformers', bare: true });
|
|
167
111
|
for (const m of raw.matchAll(KW_ID)) add(m[1], { revision: rev, source: 'hf', line: ln, via: 'model= keyword', bare: true });
|
|
168
|
-
|
|
112
|
+
|
|
169
113
|
for (const m of raw.matchAll(YAML_KW)) {
|
|
170
|
-
if (!/[0-9./]/.test(m[1])) continue;
|
|
114
|
+
if (!/[0-9./]/.test(m[1])) continue;
|
|
171
115
|
add(m[1], { revision: rev, source: 'hf', line: ln, via: 'model: yaml key', bare: true });
|
|
172
116
|
}
|
|
173
117
|
|
|
174
|
-
// 4b. Any other loader line with a quoted org/model id (hf_hub_download,
|
|
175
|
-
// snapshot_download, InferenceClient(model=…), etc.).
|
|
176
118
|
if (LOADER_HINT.test(raw)) {
|
|
177
119
|
for (const m of raw.matchAll(QUOTED_ID)) add(m[1], { revision: rev, source: 'hf', line: ln, via: 'model loader' });
|
|
178
120
|
}
|
|
@@ -180,7 +122,6 @@ export function scanModelRefs(text, file = '') {
|
|
|
180
122
|
return out;
|
|
181
123
|
}
|
|
182
124
|
|
|
183
|
-
/** Extensions worth scanning for model references. */
|
|
184
125
|
const SCAN_EXT = /\.(py|ipynb|[mc]?[jt]sx?|ya?ml|yml|toml|txt|md|env|cfg|ini|json)$/i;
|
|
185
126
|
export function isModelRefScannable(file) {
|
|
186
127
|
return SCAN_EXT.test(String(file || ''));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CHAINS } from './rules-chains.mjs';
|
|
2
|
+
import { contextChunk } from './source-lines.mjs';
|
|
3
|
+
|
|
4
|
+
export function chainFindings(lines, findings, file) {
|
|
5
|
+
const ids = new Set(findings.map((f) => f.ruleId));
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const chain of CHAINS) {
|
|
8
|
+
if (!chain.needs(ids)) continue;
|
|
9
|
+
|
|
10
|
+
const anchor = findings.find((f) => chain.anchor.includes(f.ruleId));
|
|
11
|
+
const line = anchor ? anchor.line : 1;
|
|
12
|
+
const idx = Math.max(0, line - 1);
|
|
13
|
+
out.push({
|
|
14
|
+
ruleId: chain.id,
|
|
15
|
+
title: chain.title,
|
|
16
|
+
severity: chain.severity,
|
|
17
|
+
category: chain.category,
|
|
18
|
+
confidence: chain.confidence,
|
|
19
|
+
file,
|
|
20
|
+
line,
|
|
21
|
+
sink: 'multi-signal',
|
|
22
|
+
chain: [...new Set(findings.filter((f) => chain.parts.includes(f.ruleId)).map((f) => f.ruleId))],
|
|
23
|
+
...contextChunk(lines, idx),
|
|
24
|
+
message: chain.message,
|
|
25
|
+
remediation: chain.remediation,
|
|
26
|
+
cwe: chain.cwe,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { escapeRe } from './source-lines.mjs';
|
|
2
|
+
|
|
3
|
+
const DECL_PREFIX_RE = /\b(?:function|async|get|set|static|private|public|protected|readonly)\s*\*?\s*$/;
|
|
4
|
+
|
|
5
|
+
export function isNotAModuleLoad(m, unitText, argText) {
|
|
6
|
+
if (DECL_PREFIX_RE.test(unitText.slice(Math.max(0, m.index - 24), m.index))) return true;
|
|
7
|
+
if (/^\s*import\b/.test(m[0])) return false;
|
|
8
|
+
let depth = 0;
|
|
9
|
+
let quote = '';
|
|
10
|
+
for (let i = 0; i < argText.length; i++) {
|
|
11
|
+
const ch = argText[i];
|
|
12
|
+
if (quote) {
|
|
13
|
+
if (ch === '\\') i++;
|
|
14
|
+
else if (ch === quote) quote = '';
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (ch === '"' || ch === "'" || ch === '`') quote = ch;
|
|
18
|
+
else if ('([{'.includes(ch)) depth++;
|
|
19
|
+
else if (')]}'.includes(ch)) depth--;
|
|
20
|
+
else if (ch === ',' && depth === 0) return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PATH_BIND_RE =
|
|
26
|
+
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"](?:node:)?path(?:\/(?:posix|win32))?['"]\s*\)|import\s+(?:\*\s+as\s+)?([A-Za-z_$][\w$]*)\s+from\s*['"](?:node:)?path(?:\/(?:posix|win32))?['"]/g;
|
|
27
|
+
|
|
28
|
+
export function pathBindings(text) {
|
|
29
|
+
const ns = new Set(['path']);
|
|
30
|
+
PATH_BIND_RE.lastIndex = 0;
|
|
31
|
+
for (let m = PATH_BIND_RE.exec(text); m; m = PATH_BIND_RE.exec(text)) ns.add(m[1] || m[2]);
|
|
32
|
+
return ns;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CONST_DECL_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n;]+)/g;
|
|
36
|
+
|
|
37
|
+
const PATHISH_RE = /__dirname|__filename|import\.meta\.url|process\.cwd|os\.(?:homedir|tmpdir)|fileURLToPath|new\s+URL|\.(?:join|resolve|normalize)\s*\(/;
|
|
38
|
+
|
|
39
|
+
export function constPathBindings(text, pathNs) {
|
|
40
|
+
const found = new Set();
|
|
41
|
+
for (let round = 0; round < 2; round++) {
|
|
42
|
+
CONST_DECL_RE.lastIndex = 0;
|
|
43
|
+
for (let m = CONST_DECL_RE.exec(text); m; m = CONST_DECL_RE.exec(text)) {
|
|
44
|
+
const [, name, rhsRaw] = m;
|
|
45
|
+
if (found.has(name)) continue;
|
|
46
|
+
const rhs = rhsRaw.replace(/[,;]\s*$/, '').trim();
|
|
47
|
+
|
|
48
|
+
const buildsOnKnown = [...found].some((n) => new RegExp(`\\b${escapeRe(n)}\\b`).test(rhs));
|
|
49
|
+
if (!PATHISH_RE.test(rhs) && !buildsOnKnown) continue;
|
|
50
|
+
if (isStaticPathExpr(rhs, pathNs, found)) found.add(name);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PATH_FNS = 'join|resolve|normalize|relative|dirname|basename|extname';
|
|
57
|
+
|
|
58
|
+
export function isStaticPathExpr(argText, pathNs = new Set(['path']), constPaths = new Set()) {
|
|
59
|
+
if (!argText.trim()) return false;
|
|
60
|
+
const ns = [...pathNs].map(escapeRe).join('|');
|
|
61
|
+
const consts = constPaths.size ? `|${[...constPaths].map(escapeRe).join('|')}` : '';
|
|
62
|
+
const staticTokens = new RegExp(
|
|
63
|
+
`\\b(?:(?:${ns})(?:\\.(?:posix|win32))?\\.(?:${PATH_FNS})|__dirname|__filename|import\\.meta\\.url|process\\.cwd|os\\.(?:homedir|tmpdir)|fileURLToPath|require\\.resolve|new\\s+URL|String\\.raw${consts})\\b`,
|
|
64
|
+
'g',
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const pureMembers = /\.(?:href|pathname|toString|toLowerCase|toUpperCase|trim|normalize|valueOf)\b/g;
|
|
68
|
+
|
|
69
|
+
let t = argText
|
|
70
|
+
.replace(/`(?:[^`\\]|\\.)*`/g, (lit) => ` ${[...lit.matchAll(/\$\{([^{}]*)\}/g)].map((x) => x[1]).join(' , ')} `)
|
|
71
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, ' ')
|
|
72
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, ' ');
|
|
73
|
+
t = t.replace(pureMembers, ' ').replace(staticTokens, ' ');
|
|
74
|
+
|
|
75
|
+
return !/[A-Za-z0-9_$]/.test(t.replace(/[\s(),.+[\]/\\:-]/g, ''));
|
|
76
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const CHAINS = [
|
|
2
|
+
{
|
|
3
|
+
id: 'chain.decode_exec',
|
|
4
|
+
title: 'Decode-and-execute packer (multi-signal)',
|
|
5
|
+
severity: 'CRITICAL',
|
|
6
|
+
category: 'chain',
|
|
7
|
+
confidence: 0.6,
|
|
8
|
+
|
|
9
|
+
parts: ['python.encoded_payload', 'js.decode_and_run', 'python.dangerous_sinks', 'js.code_exec', 'python.dynamic_import'],
|
|
10
|
+
needs: (ids) => (ids.has('python.encoded_payload') || ids.has('js.decode_and_run')) &&
|
|
11
|
+
(ids.has('python.dangerous_sinks') || ids.has('js.code_exec') || ids.has('python.dynamic_import')),
|
|
12
|
+
anchor: ['python.dangerous_sinks', 'js.code_exec', 'python.encoded_payload', 'js.decode_and_run'],
|
|
13
|
+
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.',
|
|
14
|
+
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.',
|
|
15
|
+
cwe: 'CWE-506',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'chain.remote_code_egress',
|
|
19
|
+
title: 'Remote-code model that also phones out',
|
|
20
|
+
severity: 'CRITICAL',
|
|
21
|
+
category: 'chain',
|
|
22
|
+
confidence: 0.6,
|
|
23
|
+
|
|
24
|
+
parts: ['python.trust_remote_code', 'python.torch_remote_code', 'json.automodel_usage', 'json.autotokenizer_usage', 'python.network_egress', 'js.network_egress'],
|
|
25
|
+
needs: (ids) => (ids.has('python.trust_remote_code') || ids.has('python.torch_remote_code') ||
|
|
26
|
+
ids.has('json.automodel_usage') || ids.has('json.autotokenizer_usage')) &&
|
|
27
|
+
(ids.has('python.network_egress') || ids.has('js.network_egress')),
|
|
28
|
+
anchor: ['python.network_egress', 'js.network_egress', 'python.trust_remote_code', 'python.torch_remote_code'],
|
|
29
|
+
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.',
|
|
30
|
+
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.',
|
|
31
|
+
cwe: 'CWE-494',
|
|
32
|
+
},
|
|
33
|
+
];
|