@shomra/agent 0.3.16 → 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.
Files changed (156) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +57 -57
  3. package/package.json +3 -9
  4. package/shomra.mjs +9 -7168
  5. package/src/agents/hook-command.mjs +19 -0
  6. package/src/agents/hook-files.mjs +41 -0
  7. package/src/agents/installers.mjs +203 -0
  8. package/src/artifacts/matchers.mjs +59 -0
  9. package/src/artifacts/report.mjs +50 -0
  10. package/src/cli/flags.mjs +68 -0
  11. package/src/cli/help-sections.mjs +309 -0
  12. package/src/cli/help.mjs +27 -0
  13. package/src/cli/main.mjs +55 -0
  14. package/src/cli/registry.mjs +80 -0
  15. package/src/cli/suggestions.mjs +33 -0
  16. package/src/commands/add.mjs +149 -0
  17. package/src/commands/agent-identity.mjs +46 -0
  18. package/src/commands/check.mjs +194 -0
  19. package/src/commands/corpus.mjs +126 -0
  20. package/src/commands/design.mjs +168 -0
  21. package/src/commands/doctor.mjs +209 -0
  22. package/src/commands/fix.mjs +115 -0
  23. package/src/commands/gate.mjs +154 -0
  24. package/src/commands/git-hooks.mjs +163 -0
  25. package/src/commands/init.mjs +36 -0
  26. package/src/commands/install-hook.mjs +51 -0
  27. package/src/commands/llm-proxy.mjs +153 -0
  28. package/src/commands/mcp-add.mjs +185 -0
  29. package/src/commands/mcp.mjs +143 -0
  30. package/src/commands/memory-scan.mjs +181 -0
  31. package/src/commands/model-scan.mjs +99 -0
  32. package/src/commands/models.mjs +145 -0
  33. package/src/commands/new.mjs +64 -0
  34. package/src/commands/plan.mjs +87 -0
  35. package/src/commands/pr.mjs +249 -0
  36. package/src/commands/protect.mjs +38 -0
  37. package/src/commands/provenance.mjs +91 -0
  38. package/src/commands/redteam.mjs +166 -0
  39. package/src/commands/rules.mjs +220 -0
  40. package/src/commands/run.mjs +128 -0
  41. package/src/commands/scan-zip.mjs +118 -0
  42. package/src/commands/scan.mjs +102 -0
  43. package/src/commands/secrets.mjs +99 -0
  44. package/src/commands/status.mjs +50 -0
  45. package/src/commands/why.mjs +88 -0
  46. package/src/core/api-client.mjs +66 -0
  47. package/src/core/api-key.mjs +6 -0
  48. package/src/core/circuit-breaker.mjs +42 -0
  49. package/src/core/config.mjs +37 -0
  50. package/src/core/exit-codes.mjs +9 -0
  51. package/src/core/json-file.mjs +13 -0
  52. package/src/core/numbers.mjs +4 -0
  53. package/src/core/package-root.mjs +10 -0
  54. package/src/core/terminal.mjs +16 -0
  55. package/src/core/version.mjs +14 -0
  56. package/src/core/wire-limits.mjs +53 -0
  57. package/src/corpus/screening.mjs +127 -0
  58. package/{ai-usage.mjs → src/detect/ai-usage.mjs} +0 -27
  59. package/src/detect/code-sast.mjs +2 -0
  60. package/{design.mjs → src/detect/design.mjs} +18 -107
  61. package/src/detect/guard-signals.mjs +18 -0
  62. package/{model-refs.mjs → src/detect/model-refs.mjs} +18 -77
  63. package/src/detect/sast/chains.mjs +30 -0
  64. package/src/detect/sast/path-expressions.mjs +76 -0
  65. package/src/detect/sast/rules-chains.mjs +33 -0
  66. package/src/detect/sast/rules-config.mjs +51 -0
  67. package/src/detect/sast/rules-javascript.mjs +109 -0
  68. package/src/detect/sast/rules-python.mjs +292 -0
  69. package/src/detect/sast/scanner.mjs +104 -0
  70. package/src/detect/sast/source-lines.mjs +115 -0
  71. package/src/detect/sast/taint.mjs +71 -0
  72. package/src/detect/signals/artifacts.mjs +113 -0
  73. package/src/detect/signals/autonomy.mjs +55 -0
  74. package/src/detect/signals/config-markers.mjs +28 -0
  75. package/src/detect/signals/credential-harvest.mjs +64 -0
  76. package/src/detect/signals/durable-claims.mjs +73 -0
  77. package/src/detect/signals/egress.mjs +56 -0
  78. package/src/detect/signals/execution-hijack.mjs +128 -0
  79. package/src/detect/signals/gate.mjs +91 -0
  80. package/src/detect/signals/injection.mjs +55 -0
  81. package/src/detect/signals/lines.mjs +42 -0
  82. package/src/detect/signals/masking.mjs +99 -0
  83. package/src/detect/signals/memory.mjs +357 -0
  84. package/src/detect/signals/packages.mjs +45 -0
  85. package/src/detect/signals/propagation.mjs +86 -0
  86. package/src/detect/signals/prose-context.mjs +82 -0
  87. package/src/detect/signals/scan.mjs +91 -0
  88. package/src/detect/signals/secrets.mjs +85 -0
  89. package/src/detect/signals/sensitive.mjs +9 -0
  90. package/src/detect/signals/severity.mjs +10 -0
  91. package/src/detect/signals/shell.mjs +96 -0
  92. package/src/detect/signals/staged-fetch.mjs +66 -0
  93. package/src/detect/signals/text-match.mjs +35 -0
  94. package/src/gate/batch.mjs +157 -0
  95. package/src/gate/environment.mjs +122 -0
  96. package/src/gate/repo-policy.mjs +65 -0
  97. package/src/gate/result.mjs +53 -0
  98. package/src/gate/sarif.mjs +33 -0
  99. package/src/gate/sast.mjs +64 -0
  100. package/src/gate/suppressions.mjs +0 -0
  101. package/src/guard/classify.mjs +50 -0
  102. package/src/guard/emit.mjs +51 -0
  103. package/src/guard/ignore.mjs +24 -0
  104. package/src/guard/ledger.mjs +112 -0
  105. package/src/guard/model-load.mjs +50 -0
  106. package/src/guard/normalize.mjs +77 -0
  107. package/src/guard/options.mjs +10 -0
  108. package/src/guard/prompt-guard.mjs +184 -0
  109. package/src/guard/report.mjs +35 -0
  110. package/src/guard/result-guard.mjs +140 -0
  111. package/src/guard/tool-guard.mjs +166 -0
  112. package/src/inventory/agent-artifacts.mjs +5 -0
  113. package/src/inventory/agent-posture.mjs +249 -0
  114. package/src/inventory/artifacts/classify.mjs +27 -0
  115. package/src/inventory/artifacts/discover.mjs +187 -0
  116. package/src/inventory/artifacts/file-read.mjs +42 -0
  117. package/src/inventory/artifacts/hooks.mjs +14 -0
  118. package/src/inventory/artifacts/limits.mjs +37 -0
  119. package/src/inventory/artifacts/marketplaces.mjs +45 -0
  120. package/src/inventory/artifacts/roots.mjs +20 -0
  121. package/src/inventory/artifacts/walk.mjs +36 -0
  122. package/src/inventory/discovery/ai-dependencies.mjs +161 -0
  123. package/src/inventory/discovery/ai-tools.mjs +23 -0
  124. package/src/inventory/discovery/all.mjs +40 -0
  125. package/src/inventory/discovery/coding-agents.mjs +77 -0
  126. package/src/inventory/discovery/fs-read.mjs +36 -0
  127. package/src/inventory/discovery/local-runtimes.mjs +53 -0
  128. package/src/inventory/discovery/mcp-clients.mjs +67 -0
  129. package/src/inventory/discovery/mcp-servers.mjs +78 -0
  130. package/src/inventory/discovery/model-keys.mjs +97 -0
  131. package/src/inventory/discovery/platform.mjs +16 -0
  132. package/src/inventory/discovery/rules-files.mjs +25 -0
  133. package/src/inventory/discovery/vector-stores.mjs +176 -0
  134. package/src/inventory/discovery/workspace.mjs +124 -0
  135. package/src/inventory/discovery.mjs +10 -0
  136. package/src/mcp/child-process.mjs +50 -0
  137. package/src/mcp/config-wrapping.mjs +75 -0
  138. package/src/mcp/connect-gate.mjs +45 -0
  139. package/src/mcp/hosts.mjs +16 -0
  140. package/src/mcp/jsonrpc.mjs +48 -0
  141. package/src/mcp/lookup.mjs +50 -0
  142. package/src/mcp/screening.mjs +103 -0
  143. package/src/mcp/server-tools.mjs +97 -0
  144. package/src/mcp/server.mjs +102 -0
  145. package/src/mcp/shim.mjs +205 -0
  146. package/src/models/lookup.mjs +79 -0
  147. package/src/models/references.mjs +103 -0
  148. package/src/rules/context.mjs +98 -0
  149. package/src/rules/generate.mjs +103 -0
  150. package/src/rules/sections.mjs +145 -0
  151. package/src/scaffold/agent-project.mjs +185 -0
  152. package/src/scaffold/artifact-templates.mjs +35 -0
  153. package/code-sast.mjs +0 -1063
  154. package/discovery.mjs +0 -977
  155. package/guard-ledger.mjs +0 -239
  156. package/guard-signals.mjs +0 -1268
@@ -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
- // ── SOURCES ────────────────────────────────────────────────────────────────
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
- // Document nouns are plural far more often than not in a design doc ("ingests
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
- // Provenance, not format: content ACCEPTED FROM a party outside the trust
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
- // A money NOUN alone is not a capability — "the dashboard displays the refund
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
- // Code blocks in a design doc are illustrative snippets, not statements of
135
- // intent — and they are exactly where a scanner's vocabulary produces noise.
74
+
136
75
  if (inFence || isSkippableLine(line)) continue;
137
- // A disclaimer names the capability in order to rule it out. Granting the
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; // one citation per distinct capability phrasing
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
- // Rank each capability's citations so the one the attack story quotes is the
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; // a full sentence beats a heading fragment
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.',
@@ -200,7 +126,7 @@ const CONTROLS = {
200
126
  ],
201
127
  'readsSensitive→network': [
202
128
  'Splitting the trust boundary: the component that reads the sensitive data and the component that makes the outbound call do not share one context or one credential.',
203
- 'Outbound payloads are field-allowlisted what may leave is enumerated, rather than what may not.',
129
+ 'Outbound payloads are field-allowlisted - what may leave is enumerated, rather than what may not.',
204
130
  'The sensitive read is scoped to the minimum rows/fields the task needs, per-request, not a standing broad grant.',
205
131
  ],
206
132
  'readsSensitive→exec': [
@@ -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 a prompt is a request, not a control.',
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' ? ' whoever writes that input is choosing what the agent does' : ''}` +
262
- `${s === 'readsSensitive' && k === 'network' ? ' the data and the way out are held by the same component' : ''}.`,
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 ${result.name}`);
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 if the agent will read anything untrusted or take any action, write that down and re-run.',
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]}** ${p.story}`);
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 a capability nobody documented is not a capability you do not have._');
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
- // High-confidence positions whose quoted value IS a model id — so we can also
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
- // ⚠ `[=:]`, not `=`. This required an equals sign, so it saw Python and nothing
26
- // else: JS/TS object literals and JSON all write `model: "gpt-4o"`, which is how
27
- // every Node OpenAI/Anthropic SDK call declares its model. A JS repo full of
28
- // model calls reported "✓ No AI model references found in the code." — a green
29
- // pass over a blind spot, and no vuln lookups ran for the whole estate.
30
- //
31
- // The VALUE STAYS QUOTED. An unquoted value matches any identifier, so
32
- // `const model = keyword` and `model: capabilities` came back as models named
33
- // "keyword" and "capabilities" — an inventory of things that do not exist is
34
- // worse than a short one. YAML's unquoted form is handled by YAML_KW below,
35
- // where line anchoring makes it safe.
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
- // A pinned revision/commit in the same call.
12
+
45
13
  const REVISION = /\b(?:revision|commit|sha)\s*=\s*['"]([\w.-]{4,})['"]/i;
46
- // Bare-id positions can accidentally grab a pipeline TASK / device / dtype — drop those.
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
- // Full huggingface.co / hf.co model URLs.
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
- // `ollama pull llama3:8b` / `ollama run mistral` — local runtime models (no slash).
25
+
58
26
  const OLLAMA = /\bollama\s+(?:pull|run|cp|create)\s+([a-z0-9][\w.:\/-]*)/gi;
59
- // torch.hub.load("pytorch/vision", …) — a GitHub owner/repo that runs hubconf.py.
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
- // First path segment on huggingface.co that is a SITE section, not an org — so
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; // a model id never ends in a code/asset ext
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; // kills "123/456"
96
- if (NONMODEL_ORGS.has(a.toLowerCase())) return false; // site path, not an org
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
- // A bare id (no org) from a high-confidence position — accept unless it's clearly
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
- // `bare` allows an org-less id ("gpt2") when it came from a high-confidence
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
- // A bare hosted-API model name reached us via an HF-shaped matcher (`model=`,
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
- // 4a. High-confidence positions — accept BARE ids (org-less) too.
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
- // Unquoted YAML values, only where a model id is plausible (see YAML_KW).
112
+
169
113
  for (const m of raw.matchAll(YAML_KW)) {
170
- if (!/[0-9./]/.test(m[1])) continue; // no digit, slash or dot → an English word, not a model id
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
+ ];