cleartoship 0.12.2 → 0.13.0

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/README.md CHANGED
@@ -1,19 +1,37 @@
1
1
  # ClearToShip
2
2
 
3
- > The 30-second pre-launch security clearance for AI-built & vibe-coded apps.
3
+ > The 30-second pre-launch security clearance for AI-built apps including the agent
4
+ > surface a runtime scanner cannot reach until after you have already deployed it.
4
5
 
5
6
  AI coding assistants write code fast, but they optimise for *"runs without errors"*, not
6
7
  *"runs without leaks"*. The failure mode is almost never a dangerous line of code — it's an
7
8
  **absent** one: the session check that was never written, the RLS policy that was never
8
- enabled, the package name the model invented.
9
+ enabled, the package name the model invented, the approval step the agent tool never had.
9
10
 
10
11
  `cleartoship` is a static pre-flight check for exactly those gaps. No database connection, no
11
12
  account, no upload — it reads your repo and exits non-zero if you shouldn't deploy.
12
13
 
14
+ ### Where this is not like the other scanners
15
+
16
+ The serious LLM security tools are **runtime**: they probe a deployed endpoint and report how
17
+ it answered. That is a real and different job, and this does not compete with it. What none of
18
+ them does is read your *source*, before it ships, and tell you that the system prompt is
19
+ compiled into the client bundle, that the model call has no token ceiling, that the agent tool
20
+ which deletes rows has nothing asking a person first, or that an answer is being used as the
21
+ access check.
22
+
23
+ Six first-party rules read that surface directly, mapped to the **2026** OWASP LLM Top 10 —
24
+ and the coverage table below names the one category this refuses to fake. Everything else here
25
+ is the ordinary web surface, which is table stakes: you still ship on the same day that a
26
+ missing RLS policy would have leaked the table.
27
+
13
28
  ```bash
14
29
  npx cleartoship
15
30
  ```
16
31
 
32
+ **Free while in beta** — no account, no key, no tier. It runs on your machine and
33
+ reports to your terminal.
34
+
17
35
  ## What it checks
18
36
 
19
37
  **Next.js server surface** — Server Actions, Route Handlers, client boundary
@@ -86,6 +104,9 @@ reaches a manifest.
86
104
  | **CTS080** | high | Caller-supplied text interpolated into the instruction text itself — prompt injection by construction, not by filter (LLM01) |
87
105
  | **CTS081** | medium | A request-reachable model call with no `max_tokens` ceiling: the answer's length, and its bill, chosen by whoever wrote the input (LLM06) |
88
106
  | **CTS082** | medium | A system prompt in a `'use client'` module — compiled into the bundle, readable in devtools (LLM08) |
107
+ | **CTS083** | high | An agent tool the model may call on its own whose body **cannot be taken back** — deletes rows, moves money, sends mail, runs a shell — with nothing asking a person first (LLM03) |
108
+ | **CTS084** | critical/high | The model's own answer traced into a sink that *runs* it: `eval`, `new Function`, a shell, raw SQL, `innerHTML`, `dangerouslySetInnerHTML` (LLM10) |
109
+ | **CTS085** | high | A branch or a check that turns on what the model returned — the guess decides access (LLM07) |
89
110
 
90
111
  **Logging, error-handling & deserialization** — the detectable slices of A08/A09/A10
91
112
 
@@ -144,7 +165,7 @@ it is strongest exactly where AI-generated code fails. Coverage by category:
144
165
 
145
166
  Counts, measured across the vendored ruleset after normalisation: A01 121,
146
167
  A05 112, A04 78, A02 59, A03 32, A07 17, A06 13, A08 11, A09 2. ClearToShip's
147
- own 45 rules add A01 19, A03 9, A04 7, A08 3, A05 2, and one each for A07, A09
168
+ own 48 rules add A01 21, A03 9, A04 7, A08 3, A05 3, and one each for A07, A09
148
169
  and A10 — which is the category no vendored rule reaches.
149
170
 
150
171
  ## OWASP Top 10 for LLM Applications (2026) — coverage
@@ -160,24 +181,31 @@ broadened into **Hidden Context Exposure**.
160
181
  | --- | --- | --- |
161
182
  | **LLM01** Prompt Injection | 12 + **CTS080** | Caller text interpolated into the instruction text itself; fetched pages and query results reaching a prompt unbounded; instructions hidden in a tool description |
162
183
  | **LLM02** Sensitive Information Disclosure | 11 (+ CTS030, CTS045) | Provider keys in client code or a `NEXT_PUBLIC_` variable, `dangerouslyAllowBrowser`, a base URL pointed at somebody else's endpoint |
163
- | **LLM03** Excessive Agency | 9 | MCP servers with permissive tool access, `allowedTools` wildcards, auto-approve bypassing the permission prompt, settings hooks that fetch or pipe |
184
+ | **LLM03** Excessive Agency | 9 + **CTS083** | MCP servers with permissive tool access, `allowedTools` wildcards, auto-approve bypassing the permission prompt, settings hooks that fetch or pipe; and a tool handed to a model whose body takes an action nobody can undo, with no approval step in it |
164
185
  | **LLM04** Supply Chain | 1 | MCP server pinned to `@latest` |
165
186
  | **LLM06** Unbounded Consumption | **CTS081** | A request-reachable model call with no `max_tokens` ceiling — the answer's length, and its cost, decided by whoever wrote the input |
187
+ | **LLM07** Misinformation | **CTS085** | Whether an answer is *true* is not a property of the source. What is in the source is the answer being **believed**: a branch, or a function that reads as a check, turning on what the model returned |
166
188
  | **LLM08** Hidden Context Exposure | 1 + **CTS082** | A system prompt held in a `'use client'` module, so it ships in the bundle; a prompt returned in an error response |
167
189
  | **LLM09** Vector & Embedding Weaknesses | 3 | Retrieval results interpolated into a prompt, unauthenticated vector upserts |
168
- | **LLM10** Improper Output Handling | 4 | Model output rendered as raw HTML or markdown images, or used in a dangerous sink |
190
+ | **LLM10** Improper Output Handling | 4 + **CTS084** | Model output rendered as raw HTML or markdown images, or used in a dangerous sink — and, first-party, the answer followed from the call that produced it into `eval`, `new Function`, a shell, raw SQL or the DOM |
169
191
  | **LLM05** Data & Model Poisoning | — | Needs training-pipeline and dataset provenance. Nothing in a web app's source tree answers it, and a rule that pretended otherwise would be box-checking |
170
- | **LLM07** Misinformation | — | A property of what the model says, not of the code that calls it. The adjacent detectable case — model output driving a security decision — would be a real rule, and is not written yet |
171
192
 
172
193
  The mapping is derived from each rule's own text rather than a hand-kept list of
173
194
  ids, so re-vendoring upstream cannot silently drop it, and it is deliberately
174
195
  conservative: a rule that does not clearly belong to a category gets none.
175
196
 
176
- **Eight of ten have first-party or vendored detection. Two do not, and will not
177
- get a rule for the sake of the table** — LLM05 needs artefacts that are not in
178
- the repository, and LLM07 is about the truthfulness of an answer. A tool that
179
- claimed those would be lying about what it checked, which is the failure mode
180
- this project exists to avoid.
197
+ **Nine of ten have first-party or vendored detection. One does not, and will not
198
+ get a rule for the sake of the table** — LLM05 needs training-pipeline and dataset
199
+ provenance, and nothing in a web app's source tree answers it. A tool that claimed
200
+ it would be lying about what it checked, which is the failure mode this project
201
+ exists to avoid.
202
+
203
+ LLM07 left that group in 0.13.0, and it is worth being exact about how. Nothing here
204
+ judges whether an answer is true; that is still not a property of the source. What
205
+ the source does show is the answer being *trusted* — `if (verdict === 'safe')`, or a
206
+ function named like a check handing back whatever the model said. That is the
207
+ detectable half, it was named as missing in this table for two releases, and it is
208
+ now CTS085.
181
209
 
182
210
  ## Usage
183
211
 
@@ -251,7 +279,7 @@ jobs:
251
279
  runs-on: ubuntu-latest
252
280
  steps:
253
281
  - uses: actions/checkout@v7
254
- - uses: murtazaozdemir/cleartoship@v0.12.2
282
+ - uses: murtazaozdemir/cleartoship@v0.13.0
255
283
  with:
256
284
  fail-on: critical
257
285
  comment: true
@@ -273,7 +301,7 @@ above `fail-on`) for use in later steps. The comment is *sticky* — re-runs edi
273
301
  the same comment instead of piling up.
274
302
 
275
303
  By default the action runs the scanner version its own ref declares, so
276
- `@v0.12.2` runs `cleartoship@0.12.2` and pinning the ref pins the behaviour. If
304
+ `@v0.13.0` runs `cleartoship@0.13.0` and pinning the ref pins the behaviour. If
277
305
  that version is not on the registry, it builds from its own checkout instead, so
278
306
  `uses: …@ref` works against an unpublished commit.
279
307
 
@@ -378,6 +406,27 @@ uploaded, and no database is connected to.
378
406
  aliases) and credits a call whose helper authenticates, directly or through
379
407
  another helper. Third-party packages are never followed, so a call into
380
408
  `node_modules` still proves nothing on its own.
409
+ - **A rule about model output has to prove the output came from a model.**
410
+ `dangerouslySetInnerHTML` is everywhere — theme scripts, chart CSS, sanitized
411
+ markdown — and reporting all of it would be a regex, not a finding. CTS084 and
412
+ CTS085 first collect every binding in the file that holds what a model
413
+ returned, following it through the shapes the SDKs actually produce
414
+ (`const { text } = await generateText(...)`, `message.content[0].text`,
415
+ `completion.choices[0].message.content`) and one hop onward, then ask whether
416
+ *that* value reaches the sink. Across five dogfooded repos the rules fired
417
+ zero times; on a probe corpus written to break them, `SLUG.exec(slug)` and
418
+ `seen.delete(key)` on a `Map` stayed silent while a Supabase delete, a Drizzle
419
+ delete and a two-hop Anthropic answer reaching `execSync` all fired.
420
+ - **An approval gate is code, not a description.** CTS083 stands down when
421
+ something in the tool puts a person in front of the effect — a confirmation, a
422
+ permission check, `needsApproval: true`. That test reads identifiers and
423
+ property keys only, never string values: a tool whose `description` says
424
+ "review this before approving" has written prose, and prose must not be able
425
+ to talk the rule out of firing.
426
+ - **Writing a row is not excessive agency.** An agent that inserts and updates is
427
+ the ordinary case, and reporting it would bury CTS083 in its own output. What
428
+ it reports is the subset a wrong answer cannot be undone from: rows deleted,
429
+ money moved, mail sent to somebody, a shell command, a file removed.
381
430
  - **A machine endpoint authenticates differently.** Comparing an `Authorization`
382
431
  header against a server-side secret is the auth check for a cron or
383
432
  webhook route, and a verified provider signature *is* the caller's identity —
package/SECURITY.md CHANGED
@@ -17,6 +17,21 @@ supported one; older versions are not backported.
17
17
 
18
18
  ## What it does to your project
19
19
 
20
+ **You do not need access to the source repository to check any of this.** The
21
+ published package contains the code that actually runs:
22
+
23
+ ```bash
24
+ npm pack cleartoship && tar xzf cleartoship-*.tgz # gives you package/dist
25
+ ```
26
+
27
+ The greps in the table below are written against `src/`. Run the same greps
28
+ against `package/dist` and they answer the same question — every hit that comes
29
+ back is a comment, a regex pattern string or the text of a rule that is *matched
30
+ against your code*, never a call site. That is worth checking rather than
31
+ believing: a scanner that looks for `eval` necessarily contains the word `eval`,
32
+ and the difference between naming a dangerous call and making one is the whole
33
+ claim.
34
+
20
35
  | Guarantee | Why it holds |
21
36
  | --- | --- |
22
37
  | **Never writes to the scanned project** | The only writes in the codebase are the report file you ask for with `--output <path>` and the registry cache. Verify: `grep -rn "writeFileSync\|mkdirSync\|rmSync\|unlink" src/ --exclude-dir=vendor` — five lines, of which two are imports and three are those call sites. Neither path is derived from the scan root. |
package/action.yml CHANGED
@@ -29,7 +29,7 @@ inputs:
29
29
  version:
30
30
  description: >-
31
31
  Version of the cleartoship npm package to run. Defaults to the version this
32
- action's own ref declares, so `uses: …@v0.12.2` runs cleartoship@0.12.2. Set
32
+ action's own ref declares, so `uses: …@v0.13.0` runs cleartoship@0.13.0. Set
33
33
  `latest` to always track the newest release, or `local` to build from the checkout.
34
34
  required: false
35
35
  default: ''
@@ -76,7 +76,7 @@ runs:
76
76
  run: |
77
77
  # With no version pinned, run the exact version this action's checkout
78
78
  # declares. That keeps the action ref and the scanner in lockstep:
79
- # `uses: <owner>/cleartoship@v0.12.2` runs cleartoship@0.12.2 instead of
79
+ # `uses: <owner>/cleartoship@v0.13.0` runs cleartoship@0.13.0 instead of
80
80
  # whatever npm happens to tag `latest` at the time.
81
81
  ver="$INPUT_VERSION"
82
82
  if [ -z "$ver" ]; then
package/dist/banner.js CHANGED
@@ -8,7 +8,7 @@ const ART = String.raw `
8
8
  export function banner(version) {
9
9
  return (pc.cyan(ART) +
10
10
  '\n ' +
11
- pc.dim('Pre-flight security check for AI-built & vibe-coded apps') +
11
+ pc.dim('Pre-flight security check for AI-built apps, agents included') +
12
12
  pc.dim(` • v${version}`) +
13
13
  '\n');
14
14
  }
@@ -259,6 +259,11 @@ const MATCH_GUARDS = {
259
259
  // `expiresIn` stops at the first `)`, which here belongs to `getJwtSecret()`.
260
260
  // Read the whole call before saying the option is absent.
261
261
  VG061: (_match, source, index) => !/\b(expiresIn|exp\s*:|setExpirationTime)\b/.test(source.slice(index, index + 300)),
262
+ // `console.log("[App] Got idToken, running audit...")` is a progress message,
263
+ // not a logged credential: "idToken," satisfies the rule's `token` + delimiter
264
+ // because both sit inside the string. What matters is whether the *value* is
265
+ // being logged, and CTS070 checks that against the AST.
266
+ VG080: (match, source, index, spans) => !isInside(spans, index + match.length - 1, 'string'),
262
267
  // The "sink" half of the upload rule matched the word *upload* inside a
263
268
  // sentence — "Please upload a CSV file exported from Shopify". A filename
264
269
  // reaching a string is not a filename reaching a filesystem.
@@ -12,8 +12,11 @@ import { OWASP_LLM } from '../utils/owasp.js';
12
12
  * A10 (Exceptional Conditions)— fail-open / swallowed errors on a security path
13
13
  * A08 (Integrity Failures) — insecure deserialization of untrusted data
14
14
  * LLM01 (Prompt Injection) — request data concatenated into prompt text
15
- * LLM07 (System Prompt Leak) a system prompt shipped to the browser
16
- * LLM10 (Unbounded Consumption) — a model call with no ceiling on what it spends
15
+ * LLM03 (Excessive Agency) an agent tool that acts irreversibly, ungated
16
+ * LLM06 (Unbounded Consumption) — a model call with no ceiling on what it spends
17
+ * LLM07 (Misinformation) — a security decision made from the model's answer
18
+ * LLM08 (Hidden Context) — a system prompt shipped to the browser
19
+ * LLM10 (Improper Output Handling) — model output executed or rendered as HTML
17
20
  *
18
21
  * Each rule is deliberately narrow: the goal is a true positive a developer will
19
22
  * act on, not coverage for its own sake.
@@ -87,6 +90,289 @@ function isPermissiveReturn(node) {
87
90
  }
88
91
  return false;
89
92
  }
93
+ /**
94
+ * The shape of a tool a model is allowed to invoke on its own: a description, an
95
+ * argument schema, and a body that does the work. Vercel's `tool({...})`, an
96
+ * OpenAI function definition and MCP's `registerTool` all reduce to it.
97
+ */
98
+ const TOOL_BODY_KEY = /^(execute|handler|run|fn|func|callback)$/;
99
+ const TOOL_SHAPE_KEY = /^(description|parameters|inputSchema|input_schema|schema|args)$/;
100
+ const TOOL_REGISTER = /^(registerTool|addTool|setRequestHandler|tool)$/;
101
+ /**
102
+ * Actions an agent cannot take back. Ordinary inserts and updates are left out
103
+ * deliberately — an agent that writes a row is the normal case, and reporting
104
+ * every one of those would bury the rule. What is reported is the subset that a
105
+ * wrong answer cannot be undone from: data deleted, money moved, a message sent
106
+ * to somebody, a shell command, a file removed.
107
+ */
108
+ const IRREVERSIBLE = [
109
+ [
110
+ /^(db|prisma|supabase|sql|knex|drizzle|conn|pool|client|collection|table|model|repo|repository)[\w.]*\.(delete|deleteMany|deleteOne|destroy|drop|truncate)$/i,
111
+ 'deletes rows',
112
+ ],
113
+ [/(^|\.)\$(execute|query)Raw(Unsafe)?$/, 'executes raw SQL'],
114
+ [/(^|\.)(rmSync|rmdir|rmdirSync|unlink|unlinkSync|rimraf)$|(^|\.)fs\.rm$|^rm$/, 'removes files'],
115
+ // Bare `exec` is the child_process import; `re.exec(s)` is a regex match and
116
+ // must not read as a shell. So a member call has to name the module.
117
+ [
118
+ /^(exec|execSync|execFile|execFileSync|spawn|spawnSync)$|^(child_process|cp|shell)\.(exec|execSync|execFile|spawn|spawnSync)$/,
119
+ 'runs a shell command',
120
+ ],
121
+ [/(refunds|charges|paymentIntents|transfers|payouts|invoices|subscriptions)\.(create|cancel|update|del)$/i, 'moves money'],
122
+ [
123
+ /(resend|sendgrid|nodemailer|transporter|mailer|postmark|ses|emails)[\w.]*\.(send|sendMail|sendEmail)$/i,
124
+ 'sends email',
125
+ ],
126
+ ];
127
+ /**
128
+ * Anything that puts a person between the model's decision and the effect —
129
+ * a confirmation step, a permission check, a dry run. Its presence is taken as
130
+ * the gate; judging whether the gate is *correct* is not something a static
131
+ * read can do, and claiming otherwise would be the kind of coverage theatre
132
+ * this project avoids.
133
+ *
134
+ * Matched against identifiers and property keys only, never string values. A
135
+ * tool whose `description` says "review this before approving" has written
136
+ * prose, not a gate, and must not be able to talk the rule out of firing.
137
+ */
138
+ const APPROVAL_GATE = /(confirm|approv|consent|agree|acknowledg|human|operator|reviewer|dry[_-]?run|checkpermission|haspermission|require(admin|user|owner|human)|assertowner|authoriz|authoris|verifyowner|pending)/i;
139
+ /** Sinks where a model's answer stops being text and starts being behaviour. */
140
+ const OUTPUT_SINKS = [
141
+ [/^eval$/, 'evaluated as code', 'critical'],
142
+ [
143
+ /^(exec|execSync|execFile|execFileSync|spawn|spawnSync)$|^(child_process|cp|shell)\.(exec|execSync|execFile|spawn|spawnSync)$/,
144
+ 'run as a shell command',
145
+ 'critical',
146
+ ],
147
+ [/(^|\.)\$(execute|query)RawUnsafe$/, 'concatenated into raw SQL', 'critical'],
148
+ ];
149
+ /** Answers a model is asked to give when it is being used as a guard. */
150
+ const DECISION_WORD = /^(yes|no|true|false|allow|allowed|deny|denied|safe|unsafe|approved|rejected|authorized|authorised|admin|ok|okay|pass|fail|valid|invalid|clean|abusive|spam)$/i;
151
+ /** Properties that hold the text a model returned, across the SDKs. */
152
+ const MODEL_TEXT_PROP = /^(text|content|output_text|outputText|message|choices|candidates|completion)$/;
153
+ /**
154
+ * Did this expression come out of a model call — directly, or through a binding
155
+ * that did? Walks the spine of the expression rather than the whole subtree, so
156
+ * `answer.trim()` counts and `{ answer }.other` does not.
157
+ */
158
+ function fromModel(node, bindings) {
159
+ let cur = node;
160
+ let guard = 0;
161
+ while (cur && guard++ < 24) {
162
+ switch (cur.type) {
163
+ case 'AwaitExpression':
164
+ cur = cur.argument;
165
+ continue;
166
+ case 'TSNonNullExpression':
167
+ case 'TSAsExpression':
168
+ case 'ParenthesizedExpression':
169
+ cur = cur.expression;
170
+ continue;
171
+ case 'MemberExpression':
172
+ case 'OptionalMemberExpression':
173
+ cur = cur.object;
174
+ continue;
175
+ case 'CallExpression':
176
+ case 'OptionalCallExpression':
177
+ if (matchesModelCall(calleeName(cur.callee)))
178
+ return true;
179
+ cur = cur.callee;
180
+ continue;
181
+ case 'Identifier':
182
+ return bindings.has(cur.name);
183
+ default:
184
+ return false;
185
+ }
186
+ }
187
+ return false;
188
+ }
189
+ /**
190
+ * Every binding in the file that holds what a model returned. Two passes, so
191
+ * `const res = await generateText(...)` on one line and `const answer = res.text`
192
+ * on the next both count — which is how every one of these SDKs reads in
193
+ * practice.
194
+ */
195
+ function collectModelBindings(ast) {
196
+ const bindings = new Set();
197
+ const record = (id) => {
198
+ if (id?.type === 'Identifier')
199
+ bindings.add(id.name);
200
+ else if (id?.type === 'ObjectPattern') {
201
+ // `const { text } = await generateText(...)` — the whole destructured
202
+ // result came from the model, so every name it binds did too.
203
+ for (const p of id.properties ?? []) {
204
+ const value = p?.value ?? p?.argument;
205
+ if (value?.type === 'Identifier')
206
+ bindings.add(value.name);
207
+ }
208
+ }
209
+ };
210
+ for (let pass = 0; pass < 2; pass++) {
211
+ traverse(ast, {
212
+ VariableDeclarator(path) {
213
+ if (path.node.init && fromModel(path.node.init, bindings))
214
+ record(path.node.id);
215
+ },
216
+ AssignmentExpression(path) {
217
+ const { left, right } = path.node;
218
+ if (left?.type === 'Identifier' && fromModel(right, bindings))
219
+ bindings.add(left.name);
220
+ },
221
+ });
222
+ }
223
+ return bindings;
224
+ }
225
+ /** The first model-derived identifier anywhere inside a subtree, if there is one. */
226
+ function referencesModel(node, bindings) {
227
+ let found = null;
228
+ const seen = new Set();
229
+ const walk = (n, depth) => {
230
+ if (found || !n || typeof n !== 'object' || depth > 14 || seen.has(n))
231
+ return;
232
+ seen.add(n);
233
+ if (Array.isArray(n)) {
234
+ for (const child of n)
235
+ walk(child, depth + 1);
236
+ return;
237
+ }
238
+ if (typeof n.type !== 'string')
239
+ return;
240
+ if (n.type === 'Identifier' && bindings.has(n.name)) {
241
+ found = n.name;
242
+ return;
243
+ }
244
+ for (const key of Object.keys(n)) {
245
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'range' || key === 'extra')
246
+ continue;
247
+ if (key === 'leadingComments' || key === 'trailingComments' || key === 'comments')
248
+ continue;
249
+ walk(n[key], depth + 1);
250
+ }
251
+ };
252
+ walk(node, 0);
253
+ return found;
254
+ }
255
+ /** True when any string literal in the subtree reads as a verdict. */
256
+ function hasDecisionLiteral(node) {
257
+ let found = false;
258
+ const walk = (n, depth) => {
259
+ if (found || !n || typeof n !== 'object' || depth > 10)
260
+ return;
261
+ if (Array.isArray(n)) {
262
+ for (const child of n)
263
+ walk(child, depth + 1);
264
+ return;
265
+ }
266
+ if (n.type === 'StringLiteral' && DECISION_WORD.test(String(n.value).trim())) {
267
+ found = true;
268
+ return;
269
+ }
270
+ for (const key of Object.keys(n)) {
271
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'range' || key === 'extra')
272
+ continue;
273
+ walk(n[key], depth + 1);
274
+ }
275
+ };
276
+ walk(node, 0);
277
+ return found;
278
+ }
279
+ /** Name of the nearest enclosing function, for judging what a block guards. */
280
+ function enclosingFunctionName(path) {
281
+ let name = '';
282
+ let p = path.parentPath;
283
+ let hops = 0;
284
+ while (p && hops++ < 10) {
285
+ const n = p.node;
286
+ if (n?.type === 'FunctionDeclaration' || n?.type === 'FunctionExpression') {
287
+ name = n.id?.name ?? name;
288
+ if (name)
289
+ break;
290
+ }
291
+ if (n?.type === 'VariableDeclarator' && n.id?.type === 'Identifier') {
292
+ name = n.id.name;
293
+ break;
294
+ }
295
+ if (n?.type === 'ObjectProperty' && n.key) {
296
+ name = (n.key.name ?? n.key.value ?? '');
297
+ if (name)
298
+ break;
299
+ }
300
+ p = p.parentPath;
301
+ }
302
+ return name;
303
+ }
304
+ /** Does this function body take an action that cannot be undone? */
305
+ function irreversibleAction(body) {
306
+ let hit = null;
307
+ const seen = new Set();
308
+ const walk = (n, depth) => {
309
+ if (hit || !n || typeof n !== 'object' || depth > 18 || seen.has(n))
310
+ return;
311
+ seen.add(n);
312
+ if (Array.isArray(n)) {
313
+ for (const child of n)
314
+ walk(child, depth + 1);
315
+ return;
316
+ }
317
+ if (typeof n.type !== 'string')
318
+ return;
319
+ if (n.type === 'CallExpression' || n.type === 'OptionalCallExpression') {
320
+ const full = calleeName(n.callee);
321
+ for (const [pattern, kind] of IRREVERSIBLE) {
322
+ if (pattern.test(full)) {
323
+ hit = { name: full, kind, line: n.loc?.start.line ?? 0 };
324
+ return;
325
+ }
326
+ }
327
+ }
328
+ for (const key of Object.keys(n)) {
329
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'range' || key === 'extra')
330
+ continue;
331
+ if (key === 'leadingComments' || key === 'trailingComments' || key === 'comments')
332
+ continue;
333
+ walk(n[key], depth + 1);
334
+ }
335
+ };
336
+ walk(body, 0);
337
+ return hit;
338
+ }
339
+ /** Is there anything here that puts a person in front of the effect? */
340
+ function hasApprovalGate(scope) {
341
+ let found = false;
342
+ const seen = new Set();
343
+ const walk = (n, depth) => {
344
+ if (found || !n || typeof n !== 'object' || depth > 20 || seen.has(n))
345
+ return;
346
+ seen.add(n);
347
+ if (Array.isArray(n)) {
348
+ for (const child of n)
349
+ walk(child, depth + 1);
350
+ return;
351
+ }
352
+ if (typeof n.type !== 'string')
353
+ return;
354
+ if (n.type === 'Identifier' && APPROVAL_GATE.test(n.name)) {
355
+ found = true;
356
+ return;
357
+ }
358
+ if ((n.type === 'ObjectProperty' || n.type === 'ObjectMethod') && n.key) {
359
+ const key = n.key.name ?? n.key.value;
360
+ if (typeof key === 'string' && APPROVAL_GATE.test(key)) {
361
+ found = true;
362
+ return;
363
+ }
364
+ }
365
+ for (const key of Object.keys(n)) {
366
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'range' || key === 'extra')
367
+ continue;
368
+ if (key === 'leadingComments' || key === 'trailingComments' || key === 'comments')
369
+ continue;
370
+ walk(n[key], depth + 1);
371
+ }
372
+ };
373
+ walk(scope, 0);
374
+ return found;
375
+ }
90
376
  export const logicScanner = {
91
377
  name: 'Logging, exception-handling & deserialization',
92
378
  applies(ctx) {
@@ -144,6 +430,65 @@ export const logicScanner = {
144
430
  if (!ast)
145
431
  continue;
146
432
  analysed++;
433
+ // Every binding in this file that holds what a model returned. Collected
434
+ // once, up front, so the three rules below can ask the only question that
435
+ // makes them precise: did this value come out of a model at all?
436
+ const modelBindings = collectModelBindings(ast);
437
+ const reportedAgency = new Set();
438
+ const reportedDecision = new Set();
439
+ const reportExcessiveAgency = (scope, body, name, fallback) => {
440
+ const action = irreversibleAction(body);
441
+ if (!action)
442
+ return;
443
+ if (hasApprovalGate(scope))
444
+ return;
445
+ const line = action.line || fallback;
446
+ if (reportedAgency.has(line))
447
+ return;
448
+ reportedAgency.add(line);
449
+ push({
450
+ id: 'CTS083',
451
+ severity: 'high',
452
+ title: 'Agent tool takes an action that cannot be taken back',
453
+ detail: `\`${name}\` is handed to a model as a tool it may call on its own, and the body ` +
454
+ `${action.kind} (\`${action.name}\`). Nothing in it asks a person first, so the decision ` +
455
+ 'to run it belongs to the model — made from whatever text happened to reach the context ' +
456
+ 'window. A support ticket, a fetched page or a retrieved document that says "delete the ' +
457
+ 'old records" is then not a description of an action, it is the action.',
458
+ fix: 'Put a person in front of the half that cannot be undone: have the tool return a proposed ' +
459
+ 'action for confirmation rather than performing it, or gate the call behind an explicit ' +
460
+ 'approval step. Where that is impractical, narrow the tool instead — scope it to the ' +
461
+ "caller's own rows, cap how much one call can affect, and make the effect recoverable " +
462
+ '(soft-delete, refundable, queued) so a wrong answer is survivable.',
463
+ line,
464
+ cwe: 'CWE-250: Execution with Unnecessary Privileges',
465
+ owasp: 'A01:2025 - Broken Access Control',
466
+ meta: { llm: OWASP_LLM.LLM03, tool: name, action: action.name, effect: action.kind },
467
+ });
468
+ };
469
+ const reportModelDecision = (binding, line, lead) => {
470
+ if (reportedDecision.has(line))
471
+ return;
472
+ reportedDecision.add(line);
473
+ push({
474
+ id: 'CTS085',
475
+ severity: 'high',
476
+ title: "A security decision is made from the model's answer",
477
+ detail: `${lead}, which holds what the model returned. A model's answer ` +
478
+ 'is a probabilistic guess about text, not a fact about your system: it can be wrong on ' +
479
+ 'ordinary input, and it can be *made* wrong by anyone whose text reached the prompt. ' +
480
+ 'Using it as the gate means the guess decides access — and the caller who wrote the ' +
481
+ 'input is the one steering the guess.',
482
+ fix: 'Decide in code. Let the model classify, summarise or suggest, then enforce the rule ' +
483
+ 'against something you control — a row you own, a permission lookup, a signed claim. If ' +
484
+ 'a model result must influence the outcome, make it fail closed and treat it as one ' +
485
+ 'signal beside a real check, never as the check itself.',
486
+ line,
487
+ cwe: 'CWE-807: Reliance on Untrusted Inputs in a Security Decision',
488
+ owasp: 'A01:2025 - Broken Access Control',
489
+ meta: { llm: OWASP_LLM.LLM07, source: binding },
490
+ });
491
+ };
147
492
  // A module marked 'use client' is compiled into the browser bundle, so
148
493
  // anything it holds is readable by anyone who opens devtools.
149
494
  const clientModule = /^\s*(['"])use client\1/m.test(source.slice(0, 400));
@@ -269,6 +614,48 @@ export const logicScanner = {
269
614
  }
270
615
  }
271
616
  }
617
+ // --- LLM03: a registered tool whose body cannot be taken back ---
618
+ // MCP and the SDK registries pass the handler as its own argument,
619
+ // so the tool-shaped object literal below never sees it.
620
+ if (TOOL_REGISTER.test(tail)) {
621
+ const handler = (node.arguments ?? []).find((a) => a?.type === 'ArrowFunctionExpression' || a?.type === 'FunctionExpression');
622
+ if (handler) {
623
+ const named = (node.arguments ?? []).find((a) => a?.type === 'StringLiteral');
624
+ reportExcessiveAgency(node, handler.body, named?.value ?? tail, node.loc?.start.line ?? 0);
625
+ }
626
+ }
627
+ // --- LLM10: a model's answer reaching a sink that runs it ---
628
+ for (const [pattern, effect, severity] of OUTPUT_SINKS) {
629
+ if (!pattern.test(full))
630
+ continue;
631
+ for (const arg of node.arguments ?? []) {
632
+ const binding = referencesModel(arg, modelBindings);
633
+ if (!binding)
634
+ continue;
635
+ push({
636
+ id: 'CTS084',
637
+ severity,
638
+ title: 'Model output is executed rather than displayed',
639
+ detail: `\`${binding}\` holds what the model returned, and here it is ${effect} by ` +
640
+ `\`${full}(...)\`. Everything upstream of the prompt — the user's message, a ` +
641
+ 'retrieved document, a fetched page, a tool result — now reaches this call, because ' +
642
+ 'a model will repeat text it was given. The model does not have to be jailbroken ' +
643
+ 'for this to run the wrong thing; it only has to be helpful.',
644
+ fix: 'Never route a model\'s answer into an interpreter. Have the model choose from a ' +
645
+ 'fixed set of operations you implement, or return structured JSON you validate ' +
646
+ 'against a schema and then act on in code. If the answer really is code, it belongs ' +
647
+ 'in a sandbox with no credentials and no network, not in this process.',
648
+ line: node.loc?.start.line ?? 0,
649
+ cwe: effect.includes('SQL')
650
+ ? 'CWE-89: Improper Neutralization of Special Elements used in an SQL Command'
651
+ : 'CWE-94: Improper Control of Generation of Code',
652
+ owasp: 'A05:2025 - Injection',
653
+ meta: { llm: OWASP_LLM.LLM10, source: binding, sink: full },
654
+ });
655
+ break;
656
+ }
657
+ break;
658
+ }
272
659
  // --- A09: secrets / PII written to a log ---
273
660
  const base = full.slice(0, full.lastIndexOf('.'));
274
661
  const isLog = LOG_METHODS.has(tail) && (LOG_OBJECTS.test(base) || base === '' || /log$/i.test(base));
@@ -325,6 +712,146 @@ export const logicScanner = {
325
712
  }
326
713
  }
327
714
  },
715
+ // --- LLM03: a tool-shaped object literal given an irreversible body ---
716
+ // `tool({ description, parameters, execute })` — Vercel's AI SDK, an
717
+ // OpenAI function definition and a hand-rolled registry all read alike:
718
+ // something that describes itself to a model, and something that runs.
719
+ ObjectExpression(path) {
720
+ const props = (path.node.properties ?? []).filter((p) => p?.type === 'ObjectProperty');
721
+ const keyOf = (p) => (p.key?.name ?? p.key?.value ?? '');
722
+ const bodyProp = props.find((p) => TOOL_BODY_KEY.test(keyOf(p)) &&
723
+ (p.value?.type === 'ArrowFunctionExpression' || p.value?.type === 'FunctionExpression'));
724
+ if (!bodyProp)
725
+ return;
726
+ // A description or an argument schema is what makes it a *tool* rather
727
+ // than any object that happens to hold a function.
728
+ if (!props.some((p) => TOOL_SHAPE_KEY.test(keyOf(p))))
729
+ return;
730
+ const named = props.find((p) => keyOf(p) === 'name');
731
+ const name = (named?.value?.type === 'StringLiteral' ? named.value.value : null) ??
732
+ (path.parent?.type === 'ObjectProperty'
733
+ ? (path.parent.key?.name ?? path.parent.key?.value)
734
+ : null) ??
735
+ (path.parentPath?.parentPath?.node?.type === 'VariableDeclarator'
736
+ ? path.parentPath.parentPath.node.id?.name
737
+ : null) ??
738
+ 'this tool';
739
+ reportExcessiveAgency(path.node, bodyProp.value.body, String(name), path.node.loc?.start.line ?? 0);
740
+ },
741
+ // --- LLM10: model output assigned straight into the DOM ---
742
+ AssignmentExpression(path) {
743
+ if (modelBindings.size === 0)
744
+ return;
745
+ const { left, right } = path.node;
746
+ if (left?.type !== 'MemberExpression')
747
+ return;
748
+ const prop = left.property?.name ?? left.property?.value;
749
+ if (prop !== 'innerHTML' && prop !== 'outerHTML')
750
+ return;
751
+ const binding = referencesModel(right, modelBindings);
752
+ if (!binding)
753
+ return;
754
+ push({
755
+ id: 'CTS084',
756
+ severity: 'high',
757
+ title: 'Model output is written to the page as HTML',
758
+ detail: `\`${binding}\` holds what the model returned and is assigned to \`${prop}\`, which ` +
759
+ 'parses it as markup. Anything that reached the prompt can come back as a tag: a ' +
760
+ 'retrieved document, a filename, another user\'s message. The model is not the ' +
761
+ 'attacker here — it is the delivery mechanism.',
762
+ fix: 'Render the answer as text (`textContent`, or JSX interpolation, which escapes). If it ' +
763
+ 'genuinely has to be rich, sanitize it with a real HTML sanitizer against an allow-list ' +
764
+ 'of tags and attributes before it goes near the DOM.',
765
+ line: path.node.loc?.start.line ?? 0,
766
+ cwe: 'CWE-79: Improper Neutralization of Input During Web Page Generation',
767
+ owasp: 'A05:2025 - Injection',
768
+ meta: { llm: OWASP_LLM.LLM10, source: binding, sink: prop },
769
+ });
770
+ },
771
+ // --- LLM10: the React spelling of the same sink ---
772
+ JSXAttribute(path) {
773
+ if (modelBindings.size === 0)
774
+ return;
775
+ if (path.node.name?.name !== 'dangerouslySetInnerHTML')
776
+ return;
777
+ const binding = referencesModel(path.node.value, modelBindings);
778
+ if (!binding)
779
+ return;
780
+ push({
781
+ id: 'CTS084',
782
+ severity: 'high',
783
+ title: 'Model output is rendered as raw HTML',
784
+ detail: `\`dangerouslySetInnerHTML\` is given \`${binding}\`, which holds what the model ` +
785
+ 'returned. React escapes every other value you interpolate; this is the one prop that ' +
786
+ 'opts out, and the string it is opting out for is one an attacker upstream of the ' +
787
+ 'prompt can influence.',
788
+ fix: 'Render the answer as a normal child (`{answer}`) so React escapes it. If it has to be ' +
789
+ 'formatted, parse it to a restricted set of nodes — a markdown renderer with HTML ' +
790
+ 'disabled, or a sanitizer with an allow-list — rather than trusting the string.',
791
+ line: path.node.loc?.start.line ?? 0,
792
+ cwe: 'CWE-79: Improper Neutralization of Input During Web Page Generation',
793
+ owasp: 'A05:2025 - Injection',
794
+ meta: { llm: OWASP_LLM.LLM10, source: binding, sink: 'dangerouslySetInnerHTML' },
795
+ });
796
+ },
797
+ // --- LLM10: `new Function(answer)` is `eval` with extra steps ---
798
+ NewExpression(path) {
799
+ if (modelBindings.size === 0)
800
+ return;
801
+ if (calleeTail(path.node.callee) !== 'Function')
802
+ return;
803
+ const binding = (path.node.arguments ?? [])
804
+ .map((a) => referencesModel(a, modelBindings))
805
+ .find(Boolean);
806
+ if (!binding)
807
+ return;
808
+ push({
809
+ id: 'CTS084',
810
+ severity: 'critical',
811
+ title: 'Model output is compiled into a function',
812
+ detail: `\`new Function(${binding})\` compiles what the model returned and runs it in this ` +
813
+ 'process, with this process\'s credentials. It is `eval` under another name, and the ' +
814
+ 'string being compiled is downstream of every piece of text that reached the prompt.',
815
+ fix: 'Have the model pick from operations you implement, or return JSON you validate against ' +
816
+ 'a schema and act on in code. Generated code belongs in a sandbox with no credentials ' +
817
+ 'and no network, never in the request path.',
818
+ line: path.node.loc?.start.line ?? 0,
819
+ cwe: 'CWE-94: Improper Control of Generation of Code',
820
+ owasp: 'A05:2025 - Injection',
821
+ meta: { llm: OWASP_LLM.LLM10, source: binding, sink: 'new Function' },
822
+ });
823
+ },
824
+ // --- LLM07: the model's answer used as the guard ---
825
+ IfStatement(path) {
826
+ if (modelBindings.size === 0)
827
+ return;
828
+ const binding = referencesModel(path.node.test, modelBindings);
829
+ if (!binding)
830
+ return;
831
+ const fnName = enclosingFunctionName(path);
832
+ // Either the branch sits in something whose name says it guards, or it
833
+ // compares the answer against a verdict word — "yes", "safe",
834
+ // "allowed". A model result merely being *read* in a condition is not
835
+ // this rule; being *believed* is.
836
+ if (!SECURITY_FN.test(fnName) && !SENSITIVE_NAME.test(fnName) && !hasDecisionLiteral(path.node.test)) {
837
+ return;
838
+ }
839
+ reportModelDecision(binding, path.node.loc?.start.line ?? 0, `The branch here turns on \`${binding}\``);
840
+ },
841
+ // --- LLM07: a check that returns what the model said ---
842
+ ReturnStatement(path) {
843
+ if (modelBindings.size === 0)
844
+ return;
845
+ if (!path.node.argument)
846
+ return;
847
+ const binding = referencesModel(path.node.argument, modelBindings);
848
+ if (!binding)
849
+ return;
850
+ const fnName = enclosingFunctionName(path);
851
+ if (!SECURITY_FN.test(fnName))
852
+ return;
853
+ reportModelDecision(binding, path.node.loc?.start.line ?? 0, `\`${fnName}\` reads as a security check, and returns \`${binding}\``);
854
+ },
328
855
  // --- A10: fail-open / swallowed error on a security path ---
329
856
  CatchClause(path) {
330
857
  const node = path.node;
@@ -22,7 +22,7 @@ jobs:
22
22
  runs-on: ubuntu-latest
23
23
  steps:
24
24
  - uses: actions/checkout@v7
25
- - uses: murtazaozdemir/cleartoship@v0.12.2
25
+ - uses: murtazaozdemir/cleartoship@v0.13.0
26
26
  with:
27
27
  fail-on: critical # block the PR only on criticals
28
28
  comment: true # post a summary comment on the PR
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cleartoship",
3
- "version": "0.12.2",
4
- "description": "The 30-second pre-launch security clearance for AI-built & vibe-coded apps. Catches missing Server Action auth, Supabase RLS holes, hallucinated npm packages and leaked keys.",
3
+ "version": "0.13.0",
4
+ "description": "The 30-second pre-launch security clearance for AI-built apps. Statically checks the agent surface no runtime scanner sees before deploy — ungated tools, model output in a shell, prompts in the client bundle — plus missing Server Action auth, Supabase RLS holes, hallucinated npm packages and leaked keys. Free while in beta.",
5
5
  "keywords": [
6
6
  "security",
7
7
  "sast",
@@ -12,7 +12,12 @@
12
12
  "slopsquatting",
13
13
  "hallucinated-packages",
14
14
  "vibe-coding",
15
- "ai-code-review"
15
+ "ai-code-review",
16
+ "llm-security",
17
+ "owasp-llm-top-10",
18
+ "ai-agents",
19
+ "prompt-injection",
20
+ "mcp"
16
21
  ],
17
22
  "license": "MIT",
18
23
  "type": "module",