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 +62 -13
- package/SECURITY.md +15 -0
- package/action.yml +2 -2
- package/dist/banner.js +1 -1
- package/dist/scanners/community.js +5 -0
- package/dist/scanners/logic.js +529 -2
- package/examples/security.yml +1 -1
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
# ClearToShip
|
|
2
2
|
|
|
3
|
-
> The 30-second pre-launch security clearance for AI-built
|
|
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
|
|
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
|
-
**
|
|
177
|
-
get a rule for the sake of the table** — LLM05 needs
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
package/dist/scanners/logic.js
CHANGED
|
@@ -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
|
-
*
|
|
16
|
-
*
|
|
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;
|
package/examples/security.yml
CHANGED
|
@@ -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.
|
|
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.
|
|
4
|
-
"description": "The 30-second pre-launch security clearance for AI-built
|
|
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",
|