cleartoship 0.10.0 → 0.10.2
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 +24 -3
- package/action.yml +2 -2
- package/dist/scanners/community.js +169 -14
- package/dist/scanners/server-actions.js +8 -1
- package/dist/types.d.ts +1 -0
- package/dist/utils/detect.js +10 -0
- package/dist/utils/paths.js +12 -3
- package/dist/vendor/guardvibe/index.d.ts +7 -0
- package/dist/vendor/guardvibe/index.js +7 -0
- package/examples/security.yml +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,7 +97,9 @@ Dockerfiles, Terraform, GitHub Actions pinning, prompt injection and MCP tool
|
|
|
97
97
|
runtimes, React Native, Go and shell.
|
|
98
98
|
|
|
99
99
|
27 upstream rules are **superseded** where ClearToShip's own AST check is more
|
|
100
|
-
precise, 6 are **withheld** as measurably noisy,
|
|
100
|
+
precise, 6 are **withheld** as measurably noisy, 10 React Native rules are
|
|
101
|
+
**skipped as inapplicable** on a project that is not React Native, 11 carry a
|
|
102
|
+
**match guard** for a
|
|
101
103
|
shape their regex cannot exclude (a `"link": true` lockfile entry has no
|
|
102
104
|
integrity hash by design; `querySelectorAll` is not a SQL call; `eval()` inside
|
|
103
105
|
a sentence about eval is prose), and 3 name-heuristic rules are
|
|
@@ -216,7 +218,7 @@ jobs:
|
|
|
216
218
|
runs-on: ubuntu-latest
|
|
217
219
|
steps:
|
|
218
220
|
- uses: actions/checkout@v7
|
|
219
|
-
- uses: murtazaozdemir/cleartoship@v0.10.
|
|
221
|
+
- uses: murtazaozdemir/cleartoship@v0.10.2
|
|
220
222
|
with:
|
|
221
223
|
fail-on: critical
|
|
222
224
|
comment: true
|
|
@@ -238,7 +240,7 @@ above `fail-on`) for use in later steps. The comment is *sticky* — re-runs edi
|
|
|
238
240
|
the same comment instead of piling up.
|
|
239
241
|
|
|
240
242
|
By default the action runs the scanner version its own ref declares, so
|
|
241
|
-
`@v0.10.
|
|
243
|
+
`@v0.10.2` runs `cleartoship@0.10.2` and pinning the ref pins the behaviour. If
|
|
242
244
|
that version is not on the registry, it builds from its own checkout instead, so
|
|
243
245
|
`uses: …@ref` works against an unpublished commit.
|
|
244
246
|
|
|
@@ -288,6 +290,25 @@ uploaded, and no database is connected to.
|
|
|
288
290
|
excluded: one is a published CLI's entry point, the other is production schema.
|
|
289
291
|
Across five dogfooded repos this moved 21 findings out of `critical` without
|
|
290
292
|
hiding one of them.
|
|
293
|
+
- **A CVE that only runs on a build machine is not a shipping vulnerability.**
|
|
294
|
+
CTS024 already split those when OSV answers; the vendored CVE rules — what
|
|
295
|
+
runs under `--offline` — now make the same split, dropping a match under
|
|
296
|
+
`devDependencies` (or a lockfile entry marked `"dev": true`) to `low` with the
|
|
297
|
+
reason attached. The same advisory against a dependency your users run keeps
|
|
298
|
+
its full severity.
|
|
299
|
+
- **A bound parameter is not an injection.** `db.prepare(\`UPDATE ${table} SET
|
|
300
|
+
csv = ? WHERE id = ?\`).bind(...)` interpolates an identifier while its values
|
|
301
|
+
go through placeholders — the correct pattern, and the one a SQL-injection
|
|
302
|
+
regex reads as the bug. Both SQL rules now read the whole statement and the
|
|
303
|
+
call chained to it, and stand down when the values are bound. They still fire
|
|
304
|
+
when any interpolation reads from the request, so a query that binds one value
|
|
305
|
+
and concatenates another is reported.
|
|
306
|
+
- **A rule that cannot apply here is not run.** The vendored ruleset covers
|
|
307
|
+
ground this project may not stand on: certificate pinning and WebView
|
|
308
|
+
hardening are React Native concerns, and a browser will not let a page pin a
|
|
309
|
+
certificate at all, so those rules are skipped unless the project actually is
|
|
310
|
+
React Native. Likewise the "no request-body size limit" rule, whose own text
|
|
311
|
+
says Next.js already imposes one. The report names what it skipped and why.
|
|
291
312
|
- **Mass assignment means the payload arrives whole.** CTS002 and CTS043 fire when
|
|
292
313
|
the object the caller sent reaches the columns — `update(body)`,
|
|
293
314
|
`data: { ...input }`, including one level down where Prisma and Drizzle put it.
|
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.10.
|
|
32
|
+
action's own ref declares, so `uses: …@v0.10.2` runs cleartoship@0.10.2. 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.10.
|
|
79
|
+
# `uses: <owner>/cleartoship@v0.10.2` runs cleartoship@0.10.2 instead of
|
|
80
80
|
# whatever npm happens to tag `latest` at the time.
|
|
81
81
|
ver="$INPUT_VERSION"
|
|
82
82
|
if [ -z "$ver" ]; then
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { read, rel, lineAt, snippetAt, languagesFor } from '../utils/files.js';
|
|
2
2
|
import { Suppressions } from '../utils/suppress.js';
|
|
3
3
|
import { adjustForPath } from '../utils/paths.js';
|
|
4
|
-
import { GUARDVIBE_RULES, GUARDVIBE_ATTRIBUTION } from '../vendor/guardvibe/index.js';
|
|
4
|
+
import { GUARDVIBE_RULES, GUARDVIBE_ATTRIBUTION, GUARDVIBE_REACT_NATIVE_RULE_IDS, GUARDVIBE_CVE_RULE_IDS, } from '../vendor/guardvibe/index.js';
|
|
5
5
|
import { emptyResult } from '../types.js';
|
|
6
6
|
/**
|
|
7
7
|
* Upstream rules that restate a check ClearToShip already performs against the
|
|
@@ -121,6 +121,56 @@ function looksLikeSecretValue(match) {
|
|
|
121
121
|
return false;
|
|
122
122
|
return true;
|
|
123
123
|
}
|
|
124
|
+
/** Bind placeholders: `?`, `$1`, `:name`, `@name`, anywhere a value may go. */
|
|
125
|
+
const BIND_PLACEHOLDER = /[\s(,=]\?(?=[\s,)`;]|$)|\$\d+\b|[\s(,=]:[A-Za-z_]\w*|[\s(,=]@[A-Za-z_]\w*/;
|
|
126
|
+
/** `${...}` expressions that read straight from the request. */
|
|
127
|
+
const INTERPOLATES_REQUEST = /\$\{[^}]*\b(req|request|body|params|query|searchParams|argv|input|formData|payload)\b/i;
|
|
128
|
+
/**
|
|
129
|
+
* The whole statement a match sits in. Both SQL rules stop matching at the first
|
|
130
|
+
* `${`, so the bind placeholders that decide the question are usually *past* the
|
|
131
|
+
* end of the match — `prepare(\`UPDATE ${table} SET csv_data = ? WHERE id = ?\`)`
|
|
132
|
+
* matches only as far as `UPDATE ${`.
|
|
133
|
+
*/
|
|
134
|
+
function statementAround(source, index) {
|
|
135
|
+
const open = source.indexOf('`', index);
|
|
136
|
+
if (open !== -1 && open - index < 120) {
|
|
137
|
+
for (let i = open + 1; i < source.length && i < open + 800; i++) {
|
|
138
|
+
if (source[i] === '\\') {
|
|
139
|
+
i++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// A little past the closing backtick, so the chained `.bind(...)` that
|
|
143
|
+
// decides whether the values are parameterized is inside the window.
|
|
144
|
+
if (source[i] === '`')
|
|
145
|
+
return source.slice(index, i + 121);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return source.slice(index, index + 400);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Whether the statement passes its values as bound parameters. Interpolating a
|
|
152
|
+
* table name into an otherwise parameterized query is ordinary; interpolating
|
|
153
|
+
* `${req.query.id}` is the bug, and a query doing both still fails this test.
|
|
154
|
+
*/
|
|
155
|
+
function isParameterized(statement) {
|
|
156
|
+
if (INTERPOLATES_REQUEST.test(statement))
|
|
157
|
+
return false;
|
|
158
|
+
// Handing the statement to `.bind(...)` is the parameterizing itself, and it
|
|
159
|
+
// covers the idioms a placeholder scan cannot see: `IN (${ids.map(() =>
|
|
160
|
+
// '?').join(',')})`, or a constant SQL fragment interpolated beside binds.
|
|
161
|
+
if (/\.\s*bind\s*\(\s*[^)\s]/.test(statement))
|
|
162
|
+
return true;
|
|
163
|
+
// Otherwise look for the placeholders themselves — with interpolations
|
|
164
|
+
// dropped first, so a JavaScript ternary is not read as a `?` parameter.
|
|
165
|
+
return BIND_PLACEHOLDER.test(statement.replace(/\$\{[^}]*\}/g, ' '));
|
|
166
|
+
}
|
|
167
|
+
/** The verb has to be a SQL verb, and the statement must not be parameterized. */
|
|
168
|
+
function sqlGuard(match, source, index) {
|
|
169
|
+
const verb = /^[A-Za-z_]+/.exec(match)?.[0] ?? '';
|
|
170
|
+
if (!SQL_VERBS.test(verb) && !SQL_KEYWORDS.test(match))
|
|
171
|
+
return false;
|
|
172
|
+
return !isParameterized(statementAround(source, index));
|
|
173
|
+
}
|
|
124
174
|
/** Whether `index` falls inside a quoted string on its own line. */
|
|
125
175
|
function insideStringLiteral(source, index) {
|
|
126
176
|
const lineStart = source.lastIndexOf('\n', index - 1) + 1;
|
|
@@ -155,10 +205,6 @@ const MATCH_GUARDS = {
|
|
|
155
205
|
// SQL call. Weak verbs have to be backed by something that looks like SQL;
|
|
156
206
|
// `query`, `execute` and friends stand on their own. A real interpolated
|
|
157
207
|
// `exec(\`INSERT INTO ...\`)` still matches — checked against one.
|
|
158
|
-
VG010: (match) => {
|
|
159
|
-
const verb = /^[A-Za-z_]+/.exec(match)?.[0] ?? '';
|
|
160
|
-
return SQL_VERBS.test(verb) || SQL_KEYWORDS.test(match);
|
|
161
|
-
},
|
|
162
208
|
// `(?:child_process|cp)[\s\S]*?(?:exec|spawn…)` lets the bridge run to the end
|
|
163
209
|
// of the file: one match measured 3,608 characters and 109 lines, pairing an
|
|
164
210
|
// `import … from "node:child_process"` with an `exec(` far below it and
|
|
@@ -175,15 +221,54 @@ const MATCH_GUARDS = {
|
|
|
175
221
|
// is an opaque token, not a sentence and not a placeholder.
|
|
176
222
|
VG001: (match) => looksLikeSecretValue(match),
|
|
177
223
|
VG062: (match) => looksLikeSecretValue(match),
|
|
224
|
+
// A statement whose values go through bind placeholders is parameterized:
|
|
225
|
+
// `db.prepare(\`UPDATE ${table} SET csv_data = ? WHERE id = ?\`).bind(...)`
|
|
226
|
+
// interpolates an identifier, not user input, and both rules read that as
|
|
227
|
+
// injection. Held to two conditions, so a query that binds one value and
|
|
228
|
+
// concatenates another still fires: there must be a placeholder, and no
|
|
229
|
+
// interpolated expression may read from the request.
|
|
230
|
+
VG010: (match, source, index) => sqlGuard(match, source, index),
|
|
231
|
+
VG123: (_match, source, index) => !isParameterized(statementAround(source, index)),
|
|
232
|
+
// The "base64 payload" test is a run of 20+ characters from the base64
|
|
233
|
+
// alphabet, which any long camelCase identifier satisfies:
|
|
234
|
+
// `description: \`${pct(clusteredAroundMedian, …)}\`` matched on the
|
|
235
|
+
// identifier. Interpolated expressions are code, not the description text,
|
|
236
|
+
// and real encoded content is not purely alphabetic.
|
|
237
|
+
VG881: (match) => {
|
|
238
|
+
const text = match.replace(/\$\{[^}]*\}/g, ' ');
|
|
239
|
+
if (/(?:\\x[0-9a-f]{2}){4,}|(?:\\u[0-9a-f]{4}){4,}|(?:&#\d{2,4};){4,}/i.test(text))
|
|
240
|
+
return true;
|
|
241
|
+
const run = /[A-Za-z0-9+/]{20,}={0,2}/.exec(text)?.[0];
|
|
242
|
+
// A slash is not evidence: "new/used/refurbished" is twenty characters of
|
|
243
|
+
// the base64 alphabet and a sentence. Real encoded content carries digits
|
|
244
|
+
// or padding.
|
|
245
|
+
return run !== undefined && /[0-9+=]/.test(run);
|
|
246
|
+
},
|
|
247
|
+
// `eval("require")` is the documented escape hatch for keeping a bundler from
|
|
248
|
+
// statically resolving a require — a constant the author typed, with no input
|
|
249
|
+
// reaching it. Dynamic code execution is about the dynamic part.
|
|
250
|
+
VG014: (match, source, index) => {
|
|
251
|
+
if (insideStringLiteral(source, index))
|
|
252
|
+
return false;
|
|
253
|
+
const after = source.slice(index, index + 60);
|
|
254
|
+
return !/^(?:eval|new\s+Function)\s*\(\s*(['"])[A-Za-z_$][\w$]*\1\s*\)/.test(after);
|
|
255
|
+
},
|
|
256
|
+
// SSRF is a *server* being made to fetch a URL it should not. A module marked
|
|
257
|
+
// `'use client'` runs in the browser, where the request leaves the user's own
|
|
258
|
+
// machine and crosses no trust boundary of yours.
|
|
259
|
+
VG120: (_match, source) => !/^\s*(['"])use client\1/m.test(source.slice(0, 400)),
|
|
260
|
+
// The name list is prefix-matched with `\w*` after it, so `hashPage === 'x'`
|
|
261
|
+
// and `tokenCount === 3` read as secret comparisons. A timing attack needs the
|
|
262
|
+
// *secret itself* on one side, so the identifier has to be one of those words,
|
|
263
|
+
// not merely start with one.
|
|
264
|
+
VG106: (match) => {
|
|
265
|
+
const identifier = /^[A-Za-z_$][\w$]*/.exec(match)?.[0] ?? '';
|
|
266
|
+
return /(secret|token|apikey|api_key|signature|hmac|hash|digest|webhook)$/i.test(identifier);
|
|
267
|
+
},
|
|
178
268
|
// "An attacker can request the entire table" is the rule's premise, and a
|
|
179
269
|
// query filtered to the caller's own rows does not let them. An unbounded
|
|
180
270
|
// fetch of your own data is a scalability question, not a security finding.
|
|
181
271
|
VG955: (match) => !/\bwhere\b[\s\S]{0,200}?\b(userId|user_id|ownerId|owner_id|orgId|org_id|organizationId|tenantId|tenant_id|workspaceId|workspace_id|accountId|account_id|teamId|team_id|shop|shopDomain|storeId|store_id)\b/i.test(match),
|
|
182
|
-
// `description: 'eval() executes arbitrary code…'` is prose about eval, not a
|
|
183
|
-
// call to it — and security tooling, which is a good deal of what gets
|
|
184
|
-
// scanned, is full of that prose. Code held in a string is not code running
|
|
185
|
-
// here; the eval that would run it is its own match, outside the quotes.
|
|
186
|
-
VG014: (_match, source, index) => !insideStringLiteral(source, index),
|
|
187
272
|
};
|
|
188
273
|
/** Regexes over very large files are where catastrophic backtracking bites. */
|
|
189
274
|
const MAX_BYTES = 400_000;
|
|
@@ -193,6 +278,59 @@ function severityOf(value) {
|
|
|
193
278
|
? value
|
|
194
279
|
: 'medium';
|
|
195
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Rules that only apply on a platform this project is not. Skipping them is not
|
|
283
|
+
* a judgement about the rule — it is that the advice cannot be followed here.
|
|
284
|
+
*/
|
|
285
|
+
function inapplicable(ctx) {
|
|
286
|
+
const ids = new Set();
|
|
287
|
+
const why = [];
|
|
288
|
+
if (!ctx.framework.reactNative) {
|
|
289
|
+
for (const id of GUARDVIBE_REACT_NATIVE_RULE_IDS)
|
|
290
|
+
ids.add(id);
|
|
291
|
+
why.push(`${GUARDVIBE_REACT_NATIVE_RULE_IDS.size} React Native rules (not a mobile project)`);
|
|
292
|
+
}
|
|
293
|
+
// VG132 asks for an explicit request-body size limit and says itself that
|
|
294
|
+
// Next.js and Vercel already impose one. On a Next.js project it is advice
|
|
295
|
+
// about a limit the framework has already applied.
|
|
296
|
+
if (ctx.framework.nextjs !== null) {
|
|
297
|
+
ids.add('VG132');
|
|
298
|
+
why.push('VG132 body-size limit (Next.js sets one by default)');
|
|
299
|
+
}
|
|
300
|
+
return { ids, why };
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Whether a dependency-manifest match sits under `devDependencies`, or in a
|
|
304
|
+
* lockfile entry marked `"dev": true`. Both mean the package is a build-time
|
|
305
|
+
* tool that no user ever runs — the split CTS024 already makes for CVEs found
|
|
306
|
+
* through OSV, applied to the vendored CVE rules that run when offline.
|
|
307
|
+
*/
|
|
308
|
+
function inDevDependencies(source, index) {
|
|
309
|
+
const before = source.slice(Math.max(0, index - 4000), index);
|
|
310
|
+
if (/"dev"\s*:\s*true[\s\S]{0,600}$/.test(before))
|
|
311
|
+
return true;
|
|
312
|
+
const nearest = /"(dev|peer|optional)?[dD]ependencies"\s*:\s*\{(?![\s\S]*"[a-z]*[dD]ependencies"\s*:\s*\{)/.exec(before);
|
|
313
|
+
return nearest?.[1] === 'dev';
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Rules whose upstream severity is right for one shape they match and wrong for
|
|
317
|
+
* another. Returning null leaves the rule's own severity alone.
|
|
318
|
+
*/
|
|
319
|
+
const SEVERITY_ADJUSTERS = {
|
|
320
|
+
// The rule matches two different things. Explicitly accepting `alg: none` is
|
|
321
|
+
// the critical it is named for. Merely calling `jwt.verify(token, secret)`
|
|
322
|
+
// without pinning `algorithms` is not: jsonwebtoken has rejected `none` on a
|
|
323
|
+
// keyed verify since v9, so what is left is defence against algorithm
|
|
324
|
+
// confusion — worth doing, not worth blocking a deploy over.
|
|
325
|
+
VG105: (match) => /algorithms\s*:\s*\[\s*['"]none['"]/i.test(match)
|
|
326
|
+
? null
|
|
327
|
+
: {
|
|
328
|
+
severity: 'medium',
|
|
329
|
+
note: ' (Reported at medium: no `algorithms` option is pinned, but nothing here accepts ' +
|
|
330
|
+
'`alg: none` — a keyed `jwt.verify` rejects it. Pinning the algorithm is defence ' +
|
|
331
|
+
'against algorithm confusion, which matters most when the key could be a public key.)',
|
|
332
|
+
},
|
|
333
|
+
};
|
|
196
334
|
export const communityScanner = {
|
|
197
335
|
name: `Community ruleset (${GUARDVIBE_RULES.length - SUPERSEDED.size - WITHHELD.size} rules)`,
|
|
198
336
|
applies() {
|
|
@@ -200,7 +338,8 @@ export const communityScanner = {
|
|
|
200
338
|
},
|
|
201
339
|
async run(ctx) {
|
|
202
340
|
const result = emptyResult();
|
|
203
|
-
const
|
|
341
|
+
const platform = inapplicable(ctx);
|
|
342
|
+
const active = GUARDVIBE_RULES.filter((r) => !SUPERSEDED.has(r.id) && !WITHHELD.has(r.id) && !platform.ids.has(r.id));
|
|
204
343
|
const seen = new Set();
|
|
205
344
|
let filesScanned = 0;
|
|
206
345
|
for (const file of ctx.files) {
|
|
@@ -241,12 +380,27 @@ export const communityScanner = {
|
|
|
241
380
|
const key = `${relPath}:${line}:${rule.id}`;
|
|
242
381
|
if (!seen.has(key) && !suppress.suppressed(line, rule.id)) {
|
|
243
382
|
seen.add(key);
|
|
244
|
-
|
|
383
|
+
let adjusted = SEVERITY_ADJUSTERS[rule.id]?.(m[0], source, m.index) ?? null;
|
|
384
|
+
// A CVE in something that only ever runs on a build machine is not
|
|
385
|
+
// a shipping vulnerability. OSV-sourced findings are already split
|
|
386
|
+
// this way (CTS024); this is the same split for the vendored CVE
|
|
387
|
+
// rules, which are what runs with --offline.
|
|
388
|
+
if (!adjusted &&
|
|
389
|
+
GUARDVIBE_CVE_RULE_IDS.has(rule.id) &&
|
|
390
|
+
(lockfile || /(^|\/)package\.json$/.test(relPath)) &&
|
|
391
|
+
inDevDependencies(source, m.index)) {
|
|
392
|
+
adjusted = {
|
|
393
|
+
severity: 'low',
|
|
394
|
+
note: ' (Reported at low: this version is declared under devDependencies, so it is a ' +
|
|
395
|
+
'build-time tool rather than something your users run.)',
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const placed = adjustForPath(adjusted?.severity ?? severityOf(rule.severity), relPath);
|
|
245
399
|
result.findings.push({
|
|
246
400
|
id: rule.id,
|
|
247
401
|
severity: placed.severity,
|
|
248
402
|
title: rule.name,
|
|
249
|
-
detail: rule.description + placed.note,
|
|
403
|
+
detail: rule.description + (adjusted?.note ?? '') + placed.note,
|
|
250
404
|
fix: rule.fixCode ? `${rule.fix}\n\n${rule.fixCode}` : rule.fix,
|
|
251
405
|
file: relPath,
|
|
252
406
|
line,
|
|
@@ -271,7 +425,8 @@ export const communityScanner = {
|
|
|
271
425
|
label: `Community ruleset (${active.length} rules over ${filesScanned} files)`,
|
|
272
426
|
passed: result.findings.every((f) => f.severity !== 'critical'),
|
|
273
427
|
note: `${SUPERSEDED.size} superseded by ClearToShip's AST checks, ${WITHHELD.size} withheld as noisy, ` +
|
|
274
|
-
`${MANIFEST_ONLY.size} manifest-only (not run over lockfiles)
|
|
428
|
+
`${MANIFEST_ONLY.size} manifest-only (not run over lockfiles)` +
|
|
429
|
+
(platform.why.length ? `; skipped as inapplicable: ${platform.why.join(', ')}` : ''),
|
|
275
430
|
});
|
|
276
431
|
return result;
|
|
277
432
|
},
|
|
@@ -655,7 +655,14 @@ export const serverActionsScanner = {
|
|
|
655
655
|
meta: { kind, route: relPath },
|
|
656
656
|
});
|
|
657
657
|
}
|
|
658
|
-
|
|
658
|
+
// A Route Handler always has a `request` parameter, so "takes an
|
|
659
|
+
// argument" says nothing about it — `POST /api/auth/logout`, which
|
|
660
|
+
// resolves the session and destroys it, was reported as an IDOR. What
|
|
661
|
+
// the rule needs is a real write keyed on something the caller sent.
|
|
662
|
+
const idorShaped = isRoute
|
|
663
|
+
? info.hasMutation && info.readsRequestInput
|
|
664
|
+
: info.params > 0;
|
|
665
|
+
if (writes && info.hasAuth && !info.ownerScoped && idorShaped) {
|
|
659
666
|
push({
|
|
660
667
|
id: 'CTS004',
|
|
661
668
|
severity: 'medium',
|
package/dist/types.d.ts
CHANGED
package/dist/utils/detect.js
CHANGED
|
@@ -38,6 +38,13 @@ export function detectFramework(root, files) {
|
|
|
38
38
|
hasDep(pkg, '@supabase/ssr') ||
|
|
39
39
|
hasDep(pkg, '@supabase/auth-helpers-nextjs') ||
|
|
40
40
|
exists(join(root, 'supabase'));
|
|
41
|
+
// Expo counts: it is React Native underneath, and the mobile rules apply.
|
|
42
|
+
const reactNative = hasDep(pkg, 'react-native') ||
|
|
43
|
+
hasDep(pkg, 'expo') ||
|
|
44
|
+
exists(join(root, 'metro.config.js')) ||
|
|
45
|
+
exists(join(root, 'metro.config.ts')) ||
|
|
46
|
+
exists(join(root, 'app.json')) &&
|
|
47
|
+
/\b(expo|react-native)\b/.test(read(join(root, 'app.json')) ?? '');
|
|
41
48
|
const info = {
|
|
42
49
|
nextjs,
|
|
43
50
|
supabase,
|
|
@@ -48,6 +55,7 @@ export function detectFramework(root, files) {
|
|
|
48
55
|
stripe: hasDep(pkg, 'stripe'),
|
|
49
56
|
python: exists(join(root, 'requirements.txt')) ||
|
|
50
57
|
exists(join(root, 'pyproject.toml')),
|
|
58
|
+
reactNative,
|
|
51
59
|
describe() {
|
|
52
60
|
const parts = [];
|
|
53
61
|
if (this.nextjs === 'app-router')
|
|
@@ -70,6 +78,8 @@ export function detectFramework(root, files) {
|
|
|
70
78
|
parts.push('Stripe');
|
|
71
79
|
if (this.python)
|
|
72
80
|
parts.push('Python');
|
|
81
|
+
if (this.reactNative)
|
|
82
|
+
parts.push('React Native');
|
|
73
83
|
return parts.length ? parts.join(' + ') : 'generic JavaScript/TypeScript';
|
|
74
84
|
},
|
|
75
85
|
};
|
package/dist/utils/paths.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { SEVERITY_ORDER } from '../types.js';
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Tests, fixtures, examples and docs: the code is there to be read, not run.
|
|
4
|
+
*
|
|
5
|
+
* The test family accepts an affix — `transfer-tests/`, `integration_test/` —
|
|
6
|
+
* because that is how those directories get named. The rest are matched whole
|
|
7
|
+
* on purpose: `demo` is a fixture directory, but `demo-billing/` may well be a
|
|
8
|
+
* shipped feature, and quietly downgrading it would be the worse mistake.
|
|
9
|
+
* Every segment must be followed by a slash — these are directories. A file
|
|
10
|
+
* named `spec-parser.ts` is production code that happens to parse specs.
|
|
11
|
+
*/
|
|
12
|
+
const NON_PRODUCTION = /(^|\/)(?:(?:[\w.]+[-_])?(?:tests?|specs?|e2e|fixtures?|mocks?)(?:[-_][\w.]+)?|__tests__|__mocks__|__fixtures__|examples?|docs?|demo|samples?|cypress|playwright|stories)\/|\.(test|spec|stories|fixture)\.[a-z]+$/i;
|
|
4
13
|
/**
|
|
5
14
|
* Build and maintenance tooling. It runs on a developer's machine or in CI,
|
|
6
15
|
* with credentials its author already holds, and never answers a request from
|
|
@@ -10,7 +19,7 @@ const NON_PRODUCTION = /(^|\/)(tests?|__tests__|__mocks__|__fixtures__|fixtures?
|
|
|
10
19
|
* `bin/` is deliberately absent: for a published CLI that is the shipped entry
|
|
11
20
|
* point. So is `migrations/`, which is the production schema.
|
|
12
21
|
*/
|
|
13
|
-
const DEV_TOOLING = /(^|\/)(scripts?|tools?|tooling|\.github|\.husky|\.circleci|seeds?|benchmarks?|codegen)(
|
|
22
|
+
const DEV_TOOLING = /(^|\/)(scripts?|tools?|tooling|\.github|\.husky|\.circleci|seeds?|benchmarks?|codegen)\/|(^|\/)[^/]*\.config\.[cm]?[jt]s$|(^|\/)(Makefile|Dockerfile[^/]*|docker-compose[^/]*\.ya?ml)$/i;
|
|
14
23
|
export function pathClass(relPath) {
|
|
15
24
|
if (NON_PRODUCTION.test(relPath))
|
|
16
25
|
return 'non-production';
|
|
@@ -8,4 +8,11 @@ export declare const GUARDVIBE_RULES: SecurityRule[];
|
|
|
8
8
|
* running with --offline.
|
|
9
9
|
*/
|
|
10
10
|
export declare const GUARDVIBE_CVE_RULE_IDS: ReadonlySet<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Ids of the React Native ruleset. Certificate pinning, WebView hardening and
|
|
13
|
+
* AsyncStorage hygiene are mobile concerns; on a web or server project they are
|
|
14
|
+
* not weaker advice, they are inapplicable — a browser will not let a page pin a
|
|
15
|
+
* certificate at all. Skipped unless the project actually is React Native.
|
|
16
|
+
*/
|
|
17
|
+
export declare const GUARDVIBE_REACT_NATIVE_RULE_IDS: ReadonlySet<string>;
|
|
11
18
|
export declare const GUARDVIBE_ATTRIBUTION = "GuardVibe (github.com/goklab/guardvibe), Copyright 2026 GokLab, Apache-2.0";
|
|
@@ -57,4 +57,11 @@ export const GUARDVIBE_RULES = [
|
|
|
57
57
|
* running with --offline.
|
|
58
58
|
*/
|
|
59
59
|
export const GUARDVIBE_CVE_RULE_IDS = new Set(cveVersionRules.map((r) => r.id));
|
|
60
|
+
/**
|
|
61
|
+
* Ids of the React Native ruleset. Certificate pinning, WebView hardening and
|
|
62
|
+
* AsyncStorage hygiene are mobile concerns; on a web or server project they are
|
|
63
|
+
* not weaker advice, they are inapplicable — a browser will not let a page pin a
|
|
64
|
+
* certificate at all. Skipped unless the project actually is React Native.
|
|
65
|
+
*/
|
|
66
|
+
export const GUARDVIBE_REACT_NATIVE_RULE_IDS = new Set(reactNativeRules.map((r) => r.id));
|
|
60
67
|
export const GUARDVIBE_ATTRIBUTION = 'GuardVibe (github.com/goklab/guardvibe), Copyright 2026 GokLab, Apache-2.0';
|
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.10.
|
|
25
|
+
- uses: murtazaozdemir/cleartoship@v0.10.2
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cleartoship",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"security",
|