cleartoship 0.9.0 → 0.10.1
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 +50 -6
- package/SECURITY.md +1 -1
- package/action.yml +3 -3
- package/dist/cli.js +3 -0
- package/dist/report.js +1 -0
- package/dist/scan.d.ts +4 -0
- package/dist/scan.js +13 -1
- package/dist/scanners/community.js +175 -11
- package/dist/scanners/logic.js +8 -1
- package/dist/scanners/server-actions.js +128 -24
- package/dist/types.d.ts +1 -0
- package/dist/utils/ast.js +4 -1
- package/dist/utils/detect.js +10 -0
- package/dist/utils/files.d.ts +8 -1
- package/dist/utils/files.js +27 -5
- package/dist/utils/gitignore.d.ts +21 -0
- package/dist/utils/gitignore.js +140 -0
- package/dist/utils/paths.d.ts +21 -0
- package/dist/utils/paths.js +57 -0
- package/dist/vendor/guardvibe/index.d.ts +7 -0
- package/dist/vendor/guardvibe/index.js +7 -0
- package/examples/security-cli.yml +3 -3
- package/examples/security.yml +2 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ npx cleartoship
|
|
|
21
21
|
| Rule | Severity | What it catches |
|
|
22
22
|
| --- | --- | --- |
|
|
23
23
|
| **CTS001** | critical | Server Action / Route Handler mutates the database with no session check |
|
|
24
|
-
| **CTS002** | high |
|
|
24
|
+
| **CTS002** | high | Caller's payload written to the database as an object — every key they sent becomes a column |
|
|
25
25
|
| **CTS003** | critical | `SUPABASE_SERVICE_ROLE_KEY` client built inside a user-reachable action |
|
|
26
26
|
| **CTS004** | medium | Authenticated mutation keyed only on a caller-supplied id (IDOR) |
|
|
27
27
|
| **CTS040** | high | Client component reads a server-side `process.env` variable |
|
|
@@ -97,7 +97,12 @@ 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, 9 carry a
|
|
102
|
+
**match guard** for a
|
|
103
|
+
shape their regex cannot exclude (a `"link": true` lockfile entry has no
|
|
104
|
+
integrity hash by design; `querySelectorAll` is not a SQL call; `eval()` inside
|
|
105
|
+
a sentence about eval is prose), and 3 name-heuristic rules are
|
|
101
106
|
**manifest-only** — they never run over a `package-lock.json`, where they matched
|
|
102
107
|
ordinary transitive packages (`fast-glob`, `core-js`) and named nothing anyone
|
|
103
108
|
could act on. Each list carries a reason per rule in
|
|
@@ -153,6 +158,7 @@ npx cleartoship --sarif -o results.sarif # GitHub code scanning
|
|
|
153
158
|
npx cleartoship --fail-on high # stricter CI gate (default: critical)
|
|
154
159
|
npx cleartoship --ignore CTS004,CTS022 # skip rules
|
|
155
160
|
npx cleartoship --no-community # ClearToShip rules only
|
|
161
|
+
npx cleartoship --no-gitignore # also scan what .gitignore excludes
|
|
156
162
|
npx cleartoship --markdown # markdown report (PR comments / summaries)
|
|
157
163
|
```
|
|
158
164
|
|
|
@@ -181,7 +187,7 @@ To feed findings into GitHub's Security tab:
|
|
|
181
187
|
|
|
182
188
|
```yaml
|
|
183
189
|
- run: npx cleartoship --sarif -o results.sarif --fail-on=none
|
|
184
|
-
- uses: github/codeql-action/upload-sarif@
|
|
190
|
+
- uses: github/codeql-action/upload-sarif@v4
|
|
185
191
|
with: { sarif_file: results.sarif }
|
|
186
192
|
```
|
|
187
193
|
|
|
@@ -211,8 +217,8 @@ jobs:
|
|
|
211
217
|
preflight:
|
|
212
218
|
runs-on: ubuntu-latest
|
|
213
219
|
steps:
|
|
214
|
-
- uses: actions/checkout@
|
|
215
|
-
- uses: murtazaozdemir/cleartoship@v0.
|
|
220
|
+
- uses: actions/checkout@v7
|
|
221
|
+
- uses: murtazaozdemir/cleartoship@v0.10.1
|
|
216
222
|
with:
|
|
217
223
|
fail-on: critical
|
|
218
224
|
comment: true
|
|
@@ -234,7 +240,7 @@ above `fail-on`) for use in later steps. The comment is *sticky* — re-runs edi
|
|
|
234
240
|
the same comment instead of piling up.
|
|
235
241
|
|
|
236
242
|
By default the action runs the scanner version its own ref declares, so
|
|
237
|
-
`@v0.
|
|
243
|
+
`@v0.10.1` runs `cleartoship@0.10.1` and pinning the ref pins the behaviour. If
|
|
238
244
|
that version is not on the registry, it builds from its own checkout instead, so
|
|
239
245
|
`uses: …@ref` works against an unpublished commit.
|
|
240
246
|
|
|
@@ -274,8 +280,46 @@ uploaded, and no database is connected to.
|
|
|
274
280
|
a network blip must never be reported as a hallucinated dependency.
|
|
275
281
|
- **Test fixtures are not breaches.** Credentials under `tests/`, `fixtures/`, `docs/` or in a
|
|
276
282
|
commented-out line are reported at `low`, never as blocking criticals.
|
|
283
|
+
- **Where a file lives changes what a finding costs.** CTS024 already separated a
|
|
284
|
+
CVE that ships from one that only ever runs on your machine; the same reasoning
|
|
285
|
+
now covers the rules that read code. An interpolated query in `scripts/`, a
|
|
286
|
+
swallowed error in a `*.config.ts` — build and maintenance tooling runs with
|
|
287
|
+
credentials its author already holds and never answers a request from the
|
|
288
|
+
internet, so those drop one severity step, with the reason attached to the
|
|
289
|
+
finding rather than applied silently. `bin/` and `migrations/` are deliberately
|
|
290
|
+
excluded: one is a published CLI's entry point, the other is production schema.
|
|
291
|
+
Across five dogfooded repos this moved 21 findings out of `critical` without
|
|
292
|
+
hiding one of them.
|
|
293
|
+
- **A bound parameter is not an injection.** `db.prepare(\`UPDATE ${table} SET
|
|
294
|
+
csv = ? WHERE id = ?\`).bind(...)` interpolates an identifier while its values
|
|
295
|
+
go through placeholders — the correct pattern, and the one a SQL-injection
|
|
296
|
+
regex reads as the bug. Both SQL rules now read the whole statement and the
|
|
297
|
+
call chained to it, and stand down when the values are bound. They still fire
|
|
298
|
+
when any interpolation reads from the request, so a query that binds one value
|
|
299
|
+
and concatenates another is reported.
|
|
300
|
+
- **A rule that cannot apply here is not run.** The vendored ruleset covers
|
|
301
|
+
ground this project may not stand on: certificate pinning and WebView
|
|
302
|
+
hardening are React Native concerns, and a browser will not let a page pin a
|
|
303
|
+
certificate at all, so those rules are skipped unless the project actually is
|
|
304
|
+
React Native. Likewise the "no request-body size limit" rule, whose own text
|
|
305
|
+
says Next.js already imposes one. The report names what it skipped and why.
|
|
306
|
+
- **Mass assignment means the payload arrives whole.** CTS002 and CTS043 fire when
|
|
307
|
+
the object the caller sent reaches the columns — `update(body)`,
|
|
308
|
+
`data: { ...input }`, including one level down where Prisma and Drizzle put it.
|
|
309
|
+
Reading named fields into an explicit column list is the safe pattern and is
|
|
310
|
+
never reported, whether or not a schema library was involved: of 102 findings
|
|
311
|
+
the old, broader rule produced on one dogfooded app, all 102 were that shape.
|
|
277
312
|
- **Precision over recall on the noisy rules.** Typosquat matching skips exact matches and
|
|
278
313
|
names shorter than five characters, where one-edit neighbours are meaningless.
|
|
314
|
+
- **Your `.gitignore` decides what counts as your project.** Ignored paths are
|
|
315
|
+
not scanned: they never reach a CI checkout, so a finding there is one nobody
|
|
316
|
+
can act on and nobody's pipeline would reproduce. This is usually the
|
|
317
|
+
difference between a usable report and an unreadable one — a repo with a
|
|
318
|
+
gitignored folder of reference apps went from 1,487 findings to 235, and from
|
|
319
|
+
84 seconds to 2. `.env` files are the deliberate exception, since a secret on
|
|
320
|
+
your disk is a secret either way and CTS032 exists to check that the file *is*
|
|
321
|
+
ignored. `--no-gitignore` scans everything, and the report always says how many
|
|
322
|
+
paths were skipped.
|
|
279
323
|
- **Auth is followed into the helper it lives in.** Almost no real app repeats the
|
|
280
324
|
session check inside every action — it resolves in `lib/auth.ts`, or in a
|
|
281
325
|
framework helper such as Shopify's `handleSessionToken`, and each action calls
|
package/SECURITY.md
CHANGED
|
@@ -22,7 +22,7 @@ supported one; older versions are not backported.
|
|
|
22
22
|
| **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. |
|
|
23
23
|
| **Never executes your code** | The scanner has no `child_process`, `exec`, `spawn`, `eval`, `new Function`, or dynamic `import()` of scanned files. Your code is read as text, parsed by Babel into an AST, and matched against regexes. Verify: `grep -rn "child_process\|execSync\|spawn\|eval(\|new Function" src/ --exclude-dir=vendor` — one hit, and it is a *pattern string* in the rule that detects those calls in **your** install hooks. (Drop `--exclude-dir=vendor` and the extra hits are rule text in the vendored ruleset, matched against your code, never run.) |
|
|
24
24
|
| **Never connects to your database** | The Row Level Security scanner replays your `.sql` migration files to model the resulting schema. There is no database driver in the dependency tree and no connection string is ever read. |
|
|
25
|
-
| **Reads only what it scans** | Files are gathered by walking the scan root, skipping `node_modules`, build output and
|
|
25
|
+
| **Reads only what it scans** | Files are gathered by walking the scan root, skipping `node_modules`, build output, virtualenvs and anything your own `.gitignore` excludes. Ignore rules are read from the scan root downwards only — never from a parent directory — so nothing outside the root is read except the cache directory. |
|
|
26
26
|
|
|
27
27
|
## What leaves your machine
|
|
28
28
|
|
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.10.1` runs cleartoship@0.10.1. 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.10.1` runs cleartoship@0.10.1 instead of
|
|
80
80
|
# whatever npm happens to tag `latest` at the time.
|
|
81
81
|
ver="$INPUT_VERSION"
|
|
82
82
|
if [ -z "$ver" ]; then
|
|
@@ -180,7 +180,7 @@ runs:
|
|
|
180
180
|
|
|
181
181
|
- name: Upload SARIF
|
|
182
182
|
if: ${{ inputs.sarif == 'true' && always() }}
|
|
183
|
-
uses: github/codeql-action/upload-sarif@
|
|
183
|
+
uses: github/codeql-action/upload-sarif@v4
|
|
184
184
|
with:
|
|
185
185
|
sarif_file: ${{ inputs.working-directory }}/results.sarif
|
|
186
186
|
|
package/dist/cli.js
CHANGED
|
@@ -39,6 +39,7 @@ program
|
|
|
39
39
|
.option('-o, --output <file>', 'write the chosen output to a file instead of stdout')
|
|
40
40
|
.option('--offline', 'skip registry lookups (no network)')
|
|
41
41
|
.option('--no-community', 'run only ClearToShip rules, skipping the vendored community ruleset')
|
|
42
|
+
.option('--no-gitignore', 'also scan files your .gitignore excludes')
|
|
42
43
|
.option('--ignore <ids>', 'comma-separated rule ids to skip, e.g. CTS004,CTS022')
|
|
43
44
|
.option('--only <ids>', 'comma-separated rule ids to report exclusively')
|
|
44
45
|
.option('--no-banner', 'suppress the ASCII header')
|
|
@@ -55,6 +56,8 @@ program
|
|
|
55
56
|
root: opts.cwd,
|
|
56
57
|
paths,
|
|
57
58
|
offline: opts.offline,
|
|
59
|
+
// commander maps --no-gitignore to `gitignore: false`.
|
|
60
|
+
noGitignore: opts.gitignore === false,
|
|
58
61
|
noCommunity: opts.community === false,
|
|
59
62
|
ignore: list(opts.ignore),
|
|
60
63
|
only: list(opts.only),
|
package/dist/report.js
CHANGED
|
@@ -117,6 +117,7 @@ export function renderJson(scan) {
|
|
|
117
117
|
root: scan.root,
|
|
118
118
|
framework: scan.framework,
|
|
119
119
|
fileCount: scan.fileCount,
|
|
120
|
+
gitIgnoredCount: scan.gitIgnoredCount,
|
|
120
121
|
durationMs: scan.durationMs,
|
|
121
122
|
verdict: scan.counts.critical > 0 ? 'hold' : scan.counts.high > 0 ? 'conditional' : 'clear',
|
|
122
123
|
counts: scan.counts,
|
package/dist/scan.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface ScanOptions {
|
|
|
8
8
|
minSeverity?: Severity;
|
|
9
9
|
/** Skip the vendored community ruleset, leaving only ClearToShip's own checks. */
|
|
10
10
|
noCommunity?: boolean;
|
|
11
|
+
/** Scan files the repository ignores too. Off by default — see `walk`. */
|
|
12
|
+
noGitignore?: boolean;
|
|
11
13
|
verbose?: boolean;
|
|
12
14
|
onProgress?: (step: number, total: number, name: string) => void;
|
|
13
15
|
}
|
|
@@ -15,6 +17,8 @@ export interface FullScan {
|
|
|
15
17
|
root: string;
|
|
16
18
|
framework: string;
|
|
17
19
|
fileCount: number;
|
|
20
|
+
/** Paths left unscanned because the repository's own ignore rules exclude them. */
|
|
21
|
+
gitIgnoredCount: number;
|
|
18
22
|
findings: Finding[];
|
|
19
23
|
checks: CheckSummary[];
|
|
20
24
|
warnings: string[];
|
package/dist/scan.js
CHANGED
|
@@ -10,7 +10,9 @@ export async function scan(options) {
|
|
|
10
10
|
const started = Date.now();
|
|
11
11
|
const root = resolve(options.root);
|
|
12
12
|
const roots = options.paths?.length ? options.paths.map((p) => resolve(root, p)) : [root];
|
|
13
|
-
const
|
|
13
|
+
const walked = roots.map((r) => walk(r, { respectGitignore: !options.noGitignore }));
|
|
14
|
+
const files = [...new Set(walked.flatMap((w) => w.files))];
|
|
15
|
+
const gitIgnoredCount = walked.reduce((n, w) => n + w.gitIgnored, 0);
|
|
14
16
|
const framework = detectFramework(root, files);
|
|
15
17
|
const ctx = {
|
|
16
18
|
root,
|
|
@@ -65,6 +67,15 @@ export async function scan(options) {
|
|
|
65
67
|
return byFile;
|
|
66
68
|
return (a.line ?? 0) - (b.line ?? 0);
|
|
67
69
|
});
|
|
70
|
+
if (gitIgnoredCount > 0) {
|
|
71
|
+
checks.push({
|
|
72
|
+
label: `Ignored paths skipped (${gitIgnoredCount} ${gitIgnoredCount === 1 ? 'entry' : 'entries'})`,
|
|
73
|
+
passed: true,
|
|
74
|
+
note: 'excluded by your own .gitignore, and a skipped directory takes its whole ' +
|
|
75
|
+
'subtree with it. They are not part of the project and never reach a CI ' +
|
|
76
|
+
'checkout. Use --no-gitignore to scan them anyway.',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
68
79
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
69
80
|
for (const f of filtered)
|
|
70
81
|
counts[f.severity]++;
|
|
@@ -72,6 +83,7 @@ export async function scan(options) {
|
|
|
72
83
|
root,
|
|
73
84
|
framework: framework.describe(),
|
|
74
85
|
fileCount: files.length,
|
|
86
|
+
gitIgnoredCount,
|
|
75
87
|
findings: filtered,
|
|
76
88
|
checks,
|
|
77
89
|
warnings,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { read, rel, lineAt, snippetAt, languagesFor } from '../utils/files.js';
|
|
2
2
|
import { Suppressions } from '../utils/suppress.js';
|
|
3
|
-
import {
|
|
3
|
+
import { adjustForPath } from '../utils/paths.js';
|
|
4
|
+
import { GUARDVIBE_RULES, GUARDVIBE_ATTRIBUTION, GUARDVIBE_REACT_NATIVE_RULE_IDS, } from '../vendor/guardvibe/index.js';
|
|
4
5
|
import { emptyResult } from '../types.js';
|
|
5
6
|
/**
|
|
6
7
|
* Upstream rules that restate a check ClearToShip already performs against the
|
|
@@ -97,18 +98,159 @@ const MANIFEST_ONLY = new Map([
|
|
|
97
98
|
['VG873', 'deceptive-prefix heuristic; matches ordinary transitive package names'],
|
|
98
99
|
['VG020', 'wildcard version; a transitive range is the dependency author\'s choice'],
|
|
99
100
|
]);
|
|
101
|
+
/** SQL verbs strong enough that the call is about a database by itself. */
|
|
102
|
+
const SQL_VERBS = /^(query|execute|raw|sql|prepare|QueryRow|QueryContext)$/i;
|
|
103
|
+
/** Words that make a string a query rather than a sentence. */
|
|
104
|
+
const SQL_KEYWORDS = /\b(select|insert\s+into|update|delete\s+from|from|where|values|set)\b/i;
|
|
105
|
+
/** Placeholder values that exist to be replaced, not to authenticate. */
|
|
106
|
+
const PLACEHOLDER = /^(your|my|the|a|change|changeme|replace|example|placeholder|dummy|sample|test|todo|none|null|undefined|x{3,}|\.{3}|<.*>|\$\{.*\}|process\.env)/i;
|
|
107
|
+
/**
|
|
108
|
+
* Whether the quoted value in a `name = "value"` match reads as a credential.
|
|
109
|
+
* Prose is not: three or more words, or a trailing full stop, is a sentence.
|
|
110
|
+
*/
|
|
111
|
+
function looksLikeSecretValue(match) {
|
|
112
|
+
const value = /['"]([^'"\n]*)['"]\s*$/.exec(match)?.[1];
|
|
113
|
+
if (value === undefined)
|
|
114
|
+
return true;
|
|
115
|
+
const trimmed = value.trim();
|
|
116
|
+
if (trimmed === '' || PLACEHOLDER.test(trimmed))
|
|
117
|
+
return false;
|
|
118
|
+
if (trimmed.split(/\s+/).length >= 3)
|
|
119
|
+
return false;
|
|
120
|
+
if (/[.!?]$/.test(trimmed) && trimmed.includes(' '))
|
|
121
|
+
return false;
|
|
122
|
+
return true;
|
|
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
|
+
}
|
|
174
|
+
/** Whether `index` falls inside a quoted string on its own line. */
|
|
175
|
+
function insideStringLiteral(source, index) {
|
|
176
|
+
const lineStart = source.lastIndexOf('\n', index - 1) + 1;
|
|
177
|
+
let quote = null;
|
|
178
|
+
for (let i = lineStart; i < index; i++) {
|
|
179
|
+
const ch = source[i];
|
|
180
|
+
if (ch === '\\') {
|
|
181
|
+
i++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (quote) {
|
|
185
|
+
if (ch === quote)
|
|
186
|
+
quote = null;
|
|
187
|
+
}
|
|
188
|
+
else if (ch === '"' || ch === "'" || ch === '`') {
|
|
189
|
+
quote = ch;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return quote !== null;
|
|
193
|
+
}
|
|
100
194
|
/**
|
|
101
195
|
* Per-rule filters for a match shape the upstream regex cannot exclude on its
|
|
102
|
-
* own.
|
|
196
|
+
* own. Given the matched text plus where it sat, so a guard can look around it.
|
|
103
197
|
*/
|
|
104
198
|
const MATCH_GUARDS = {
|
|
105
199
|
// A `"link": true` entry is a workspace or pnpm symlink resolved to a path on
|
|
106
200
|
// disk rather than a tarball, so it has no integrity hash by design. Thirty of
|
|
107
201
|
// them in one pnpm-managed lockfile, every one reported as a false critical.
|
|
108
202
|
VG870: (match) => !/"link"\s*:\s*true/.test(match),
|
|
203
|
+
// The verb list has no word boundary in front of it and includes `all`, `get`
|
|
204
|
+
// and `run`, so `querySelectorAll(\`[name="${CSS.escape(k)}"]\`)` reads as a
|
|
205
|
+
// SQL call. Weak verbs have to be backed by something that looks like SQL;
|
|
206
|
+
// `query`, `execute` and friends stand on their own. A real interpolated
|
|
207
|
+
// `exec(\`INSERT INTO ...\`)` still matches — checked against one.
|
|
208
|
+
// `(?:child_process|cp)[\s\S]*?(?:exec|spawn…)` lets the bridge run to the end
|
|
209
|
+
// of the file: one match measured 3,608 characters and 109 lines, pairing an
|
|
210
|
+
// `import … from "node:child_process"` with an `exec(` far below it and
|
|
211
|
+
// reporting the import as a critical. A real one is a single statement.
|
|
212
|
+
VG011: (match) => (match.match(/\n/g)?.length ?? 0) <= 1,
|
|
213
|
+
// `sk-[A-Za-z0-9-_]{20,}` has no boundary in front of it, so the slug
|
|
214
|
+
// `best-disk-space-analyzer-mac-2026` contains an "OpenAI key" — the `sk-` in
|
|
215
|
+
// "di*sk-*space...". Real keys start at a token boundary; substrings of a word
|
|
216
|
+
// do not.
|
|
217
|
+
VG003: (_match, source, index) => index === 0 || !/[A-Za-z0-9_-]/.test(source[index - 1]),
|
|
218
|
+
// Both rules key off a *name* — anything called password, secret, apiKey — and
|
|
219
|
+
// accept any string as its value, so UI copy lands as a critical:
|
|
220
|
+
// `password: "That password didn't match. Try again."` was one. A credential
|
|
221
|
+
// is an opaque token, not a sentence and not a placeholder.
|
|
222
|
+
VG001: (match) => looksLikeSecretValue(match),
|
|
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
|
+
// SSRF is a *server* being made to fetch a URL it should not. A module marked
|
|
233
|
+
// `'use client'` runs in the browser, where the request leaves the user's own
|
|
234
|
+
// machine and crosses no trust boundary of yours.
|
|
235
|
+
VG120: (_match, source) => !/^\s*(['"])use client\1/m.test(source.slice(0, 400)),
|
|
236
|
+
// The name list is prefix-matched with `\w*` after it, so `hashPage === 'x'`
|
|
237
|
+
// and `tokenCount === 3` read as secret comparisons. A timing attack needs the
|
|
238
|
+
// *secret itself* on one side, so the identifier has to be one of those words,
|
|
239
|
+
// not merely start with one.
|
|
240
|
+
VG106: (match) => {
|
|
241
|
+
const identifier = /^[A-Za-z_$][\w$]*/.exec(match)?.[0] ?? '';
|
|
242
|
+
return /(secret|token|apikey|api_key|signature|hmac|hash|digest|webhook)$/i.test(identifier);
|
|
243
|
+
},
|
|
244
|
+
// "An attacker can request the entire table" is the rule's premise, and a
|
|
245
|
+
// query filtered to the caller's own rows does not let them. An unbounded
|
|
246
|
+
// fetch of your own data is a scalability question, not a security finding.
|
|
247
|
+
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),
|
|
248
|
+
// `description: 'eval() executes arbitrary code…'` is prose about eval, not a
|
|
249
|
+
// call to it — and security tooling, which is a good deal of what gets
|
|
250
|
+
// scanned, is full of that prose. Code held in a string is not code running
|
|
251
|
+
// here; the eval that would run it is its own match, outside the quotes.
|
|
252
|
+
VG014: (_match, source, index) => !insideStringLiteral(source, index),
|
|
109
253
|
};
|
|
110
|
-
/** Paths where a match is a fixture or documentation rather than shipped code. */
|
|
111
|
-
const NON_PRODUCTION_PATH = /(^|\/)(tests?|__tests__|__mocks__|__fixtures__|fixtures?|spec|specs|examples?|docs?|demo|samples?|e2e|cypress|playwright|stories)(\/|$)|\.(test|spec|stories|fixture)\.[a-z]+$/i;
|
|
112
254
|
/** Regexes over very large files are where catastrophic backtracking bites. */
|
|
113
255
|
const MAX_BYTES = 400_000;
|
|
114
256
|
/** Upstream severities already use our vocabulary; this just narrows the type. */
|
|
@@ -117,6 +259,27 @@ function severityOf(value) {
|
|
|
117
259
|
? value
|
|
118
260
|
: 'medium';
|
|
119
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Rules that only apply on a platform this project is not. Skipping them is not
|
|
264
|
+
* a judgement about the rule — it is that the advice cannot be followed here.
|
|
265
|
+
*/
|
|
266
|
+
function inapplicable(ctx) {
|
|
267
|
+
const ids = new Set();
|
|
268
|
+
const why = [];
|
|
269
|
+
if (!ctx.framework.reactNative) {
|
|
270
|
+
for (const id of GUARDVIBE_REACT_NATIVE_RULE_IDS)
|
|
271
|
+
ids.add(id);
|
|
272
|
+
why.push(`${GUARDVIBE_REACT_NATIVE_RULE_IDS.size} React Native rules (not a mobile project)`);
|
|
273
|
+
}
|
|
274
|
+
// VG132 asks for an explicit request-body size limit and says itself that
|
|
275
|
+
// Next.js and Vercel already impose one. On a Next.js project it is advice
|
|
276
|
+
// about a limit the framework has already applied.
|
|
277
|
+
if (ctx.framework.nextjs !== null) {
|
|
278
|
+
ids.add('VG132');
|
|
279
|
+
why.push('VG132 body-size limit (Next.js sets one by default)');
|
|
280
|
+
}
|
|
281
|
+
return { ids, why };
|
|
282
|
+
}
|
|
120
283
|
export const communityScanner = {
|
|
121
284
|
name: `Community ruleset (${GUARDVIBE_RULES.length - SUPERSEDED.size - WITHHELD.size} rules)`,
|
|
122
285
|
applies() {
|
|
@@ -124,7 +287,8 @@ export const communityScanner = {
|
|
|
124
287
|
},
|
|
125
288
|
async run(ctx) {
|
|
126
289
|
const result = emptyResult();
|
|
127
|
-
const
|
|
290
|
+
const platform = inapplicable(ctx);
|
|
291
|
+
const active = GUARDVIBE_RULES.filter((r) => !SUPERSEDED.has(r.id) && !WITHHELD.has(r.id) && !platform.ids.has(r.id));
|
|
128
292
|
const seen = new Set();
|
|
129
293
|
let filesScanned = 0;
|
|
130
294
|
for (const file of ctx.files) {
|
|
@@ -135,7 +299,6 @@ export const communityScanner = {
|
|
|
135
299
|
if (source === null || source.length > MAX_BYTES)
|
|
136
300
|
continue;
|
|
137
301
|
const relPath = rel(ctx.root, file);
|
|
138
|
-
const fixture = NON_PRODUCTION_PATH.test(relPath);
|
|
139
302
|
const lockfile = LOCKFILE.test(relPath);
|
|
140
303
|
const suppress = new Suppressions(source);
|
|
141
304
|
filesScanned++;
|
|
@@ -157,7 +320,7 @@ export const communityScanner = {
|
|
|
157
320
|
}
|
|
158
321
|
// Skipping a match must not skip the non-global `break` below, or a
|
|
159
322
|
// rule without /g would rescan from zero forever.
|
|
160
|
-
if (guard && !guard(m[0])) {
|
|
323
|
+
if (guard && !guard(m[0], source, m.index)) {
|
|
161
324
|
if (!re.global)
|
|
162
325
|
break;
|
|
163
326
|
continue;
|
|
@@ -166,12 +329,12 @@ export const communityScanner = {
|
|
|
166
329
|
const key = `${relPath}:${line}:${rule.id}`;
|
|
167
330
|
if (!seen.has(key) && !suppress.suppressed(line, rule.id)) {
|
|
168
331
|
seen.add(key);
|
|
332
|
+
const placed = adjustForPath(severityOf(rule.severity), relPath);
|
|
169
333
|
result.findings.push({
|
|
170
334
|
id: rule.id,
|
|
171
|
-
severity:
|
|
335
|
+
severity: placed.severity,
|
|
172
336
|
title: rule.name,
|
|
173
|
-
detail: rule.description +
|
|
174
|
-
(fixture ? ' (Path looks like tests or docs, so the severity is reduced.)' : ''),
|
|
337
|
+
detail: rule.description + placed.note,
|
|
175
338
|
fix: rule.fixCode ? `${rule.fix}\n\n${rule.fixCode}` : rule.fix,
|
|
176
339
|
file: relPath,
|
|
177
340
|
line,
|
|
@@ -196,7 +359,8 @@ export const communityScanner = {
|
|
|
196
359
|
label: `Community ruleset (${active.length} rules over ${filesScanned} files)`,
|
|
197
360
|
passed: result.findings.every((f) => f.severity !== 'critical'),
|
|
198
361
|
note: `${SUPERSEDED.size} superseded by ClearToShip's AST checks, ${WITHHELD.size} withheld as noisy, ` +
|
|
199
|
-
`${MANIFEST_ONLY.size} manifest-only (not run over lockfiles)
|
|
362
|
+
`${MANIFEST_ONLY.size} manifest-only (not run over lockfiles)` +
|
|
363
|
+
(platform.why.length ? `; skipped as inapplicable: ${platform.why.join(', ')}` : ''),
|
|
200
364
|
});
|
|
201
365
|
return result;
|
|
202
366
|
},
|
package/dist/scanners/logic.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { read, rel, isScript, snippetAt } from '../utils/files.js';
|
|
2
|
+
import { adjustForPath } from '../utils/paths.js';
|
|
2
3
|
import { parseSource, calleeName, calleeTail } from '../utils/ast.js';
|
|
3
4
|
import { traverse } from '../utils/traverse.js';
|
|
4
5
|
import { Suppressions } from '../utils/suppress.js';
|
|
@@ -67,11 +68,17 @@ export const logicScanner = {
|
|
|
67
68
|
if (source === null)
|
|
68
69
|
continue;
|
|
69
70
|
const relPath = rel(ctx.root, file);
|
|
71
|
+
// A swallowed error or a logged token in a maintenance script is a
|
|
72
|
+
// smaller problem than the same line inside a request handler.
|
|
73
|
+
const place = (f) => {
|
|
74
|
+
const placed = adjustForPath(f.severity, relPath);
|
|
75
|
+
return { ...f, severity: placed.severity, detail: f.detail + placed.note };
|
|
76
|
+
};
|
|
70
77
|
const suppress = new Suppressions(source);
|
|
71
78
|
const push = (f) => {
|
|
72
79
|
if (suppress.suppressed(f.line, f.id))
|
|
73
80
|
return;
|
|
74
|
-
result.findings.push({ ...f, file: relPath, snippet: snippetAt(source, f.line) });
|
|
81
|
+
result.findings.push({ ...place(f), file: relPath, snippet: snippetAt(source, f.line) });
|
|
75
82
|
};
|
|
76
83
|
// Python is regex-only (no JS AST). Cover its highest-signal cases.
|
|
77
84
|
if (python) {
|
|
@@ -179,10 +179,64 @@ credited) {
|
|
|
179
179
|
// A Server Action's arguments *are* its input; a Route Handler has to go
|
|
180
180
|
// and read one, so that is detected below.
|
|
181
181
|
readsRequestInput: false,
|
|
182
|
+
wholePayloadLine: null,
|
|
182
183
|
};
|
|
183
184
|
// An id the caller passed in is not proof of ownership — it is the IDOR.
|
|
184
185
|
// Only a principal resolved inside the function counts as scoping.
|
|
185
186
|
const params = parameterNames(node.params ?? []);
|
|
187
|
+
/**
|
|
188
|
+
* Names holding the caller's payload as one object: a Server Action's own
|
|
189
|
+
* parameter, or a variable assigned from a request-body read. Writing one of
|
|
190
|
+
* these whole is mass assignment; pulling named fields out of it and writing
|
|
191
|
+
* those is the safe pattern this rule used to report anyway.
|
|
192
|
+
*/
|
|
193
|
+
const payloads = new Set();
|
|
194
|
+
for (const p of node.params ?? []) {
|
|
195
|
+
// Only whole-object parameters. `({ name, role })` is already field by field.
|
|
196
|
+
if (p?.type === 'Identifier' && !/^(req|request|_req|_request|nextRequest)$/i.test(p.name)) {
|
|
197
|
+
payloads.add(p.name);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
path.traverse({
|
|
201
|
+
VariableDeclarator(inner) {
|
|
202
|
+
const id = inner.node.id;
|
|
203
|
+
if (id?.type !== 'Identifier')
|
|
204
|
+
return; // destructuring is field by field
|
|
205
|
+
let init = inner.node.init;
|
|
206
|
+
while (init && (init.type === 'AwaitExpression' || init.type === 'TSNonNullExpression')) {
|
|
207
|
+
init = init.argument ?? init.expression;
|
|
208
|
+
}
|
|
209
|
+
if (!init)
|
|
210
|
+
return;
|
|
211
|
+
if (init.type === 'CallExpression' || init.type === 'OptionalCallExpression') {
|
|
212
|
+
const callee = calleeName(init.callee);
|
|
213
|
+
// `await request.json()`, and `JSON.parse(raw)` over one of these.
|
|
214
|
+
if (REQUEST_INPUT_READ.test(callee)) {
|
|
215
|
+
payloads.add(id.name);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// `JSON.parse(raw)` and `Schema.parse(body)` both hand back the
|
|
219
|
+
// caller's object with its key set intact — a passthrough schema
|
|
220
|
+
// validates the fields it declares and keeps the rest (CTS044).
|
|
221
|
+
// A local helper that reads named fields into a literal is NOT this:
|
|
222
|
+
// its key set is fixed, which is exactly why it is safe to spread.
|
|
223
|
+
if (/(^|\.)JSON\.parse$/.test(callee) ||
|
|
224
|
+
PAYLOAD_PRESERVING_CALLS.has(calleeTail(init.callee))) {
|
|
225
|
+
for (const arg of init.arguments ?? []) {
|
|
226
|
+
const root = arg?.type === 'Identifier' ? arg.name : rootObject(arg);
|
|
227
|
+
if (root && payloads.has(root)) {
|
|
228
|
+
payloads.add(id.name);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// A plain alias: `const input = raw`.
|
|
236
|
+
if (init.type === 'Identifier' && payloads.has(init.name))
|
|
237
|
+
payloads.add(id.name);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
186
240
|
// `export async function GET(_req, { params })` — the segment values are
|
|
187
241
|
// caller-supplied input just as much as a body is.
|
|
188
242
|
if (params.has('params'))
|
|
@@ -224,20 +278,41 @@ credited) {
|
|
|
224
278
|
if (info.mutationLine === null) {
|
|
225
279
|
info.mutationLine = inner.node.loc?.start.line ?? info.line;
|
|
226
280
|
}
|
|
227
|
-
// `.update({ ...body })
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
281
|
+
// What actually reaches the columns: `.update({ ...body })`,
|
|
282
|
+
// `.insert(body)`, `prisma.x.update({ data: { ...body } })` — the last of
|
|
283
|
+
// which hides one level down, where a top-level scan never saw it.
|
|
284
|
+
const inspectWritten = (arg, depth) => {
|
|
285
|
+
if (!arg || depth > 2)
|
|
286
|
+
return;
|
|
287
|
+
if (arg.type === 'Identifier') {
|
|
288
|
+
if (payloads.has(arg.name)) {
|
|
289
|
+
info.wholePayloadLine ??= arg.loc?.start.line ?? info.line;
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (arg.type !== 'ObjectExpression')
|
|
294
|
+
return;
|
|
295
|
+
for (const prop of arg.properties ?? []) {
|
|
296
|
+
if (prop?.type === 'SpreadElement') {
|
|
297
|
+
// Only the caller's own object counts. Spreading a locally built
|
|
298
|
+
// literal — `...(x && { k: v })`, or an object a helper assembled
|
|
299
|
+
// from named fields — writes a key set this code chose, which is
|
|
300
|
+
// the safe pattern rather than the bug.
|
|
301
|
+
const root = rootObject(prop.argument);
|
|
302
|
+
if (!root || !payloads.has(root))
|
|
303
|
+
continue;
|
|
304
|
+
info.spreadLine ??= prop.loc?.start.line ?? info.line;
|
|
234
305
|
continue;
|
|
235
|
-
|
|
236
|
-
if (
|
|
306
|
+
}
|
|
307
|
+
if (prop?.type !== 'ObjectProperty')
|
|
308
|
+
continue;
|
|
309
|
+
if (!WRITE_PAYLOAD_KEYS.has(propertyKey(prop)))
|
|
237
310
|
continue;
|
|
238
|
-
|
|
311
|
+
inspectWritten(prop.value, depth + 1);
|
|
239
312
|
}
|
|
240
|
-
}
|
|
313
|
+
};
|
|
314
|
+
for (const arg of inner.node.arguments ?? [])
|
|
315
|
+
inspectWritten(arg, 0);
|
|
241
316
|
}
|
|
242
317
|
if (tail === 'from' || tail === 'select' || tail === 'findMany' || tail === 'findUnique' || tail === 'findFirst') {
|
|
243
318
|
info.hasRead = true;
|
|
@@ -336,6 +411,25 @@ credited) {
|
|
|
336
411
|
}
|
|
337
412
|
return info;
|
|
338
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Keys that hold the columns being written, one level inside the call argument.
|
|
416
|
+
* `prisma.user.update({ where, data: { ... } })`, `db.insert(t).values({ ... })`.
|
|
417
|
+
*/
|
|
418
|
+
const WRITE_PAYLOAD_KEYS = new Set(['data', 'values', 'set', 'create', 'update', 'insert', 'doc']);
|
|
419
|
+
/**
|
|
420
|
+
* Calls that return the caller's object with its keys intact. A schema `.parse`
|
|
421
|
+
* belongs here because a passthrough schema — the CTS044 case — validates what
|
|
422
|
+
* it declares and hands back everything else untouched.
|
|
423
|
+
*/
|
|
424
|
+
const PAYLOAD_PRESERVING_CALLS = new Set([
|
|
425
|
+
'parse', 'parseAsync', 'safeParse', 'safeParseAsync', 'validate', 'validateSync', 'cast',
|
|
426
|
+
]);
|
|
427
|
+
function propertyKey(prop) {
|
|
428
|
+
const key = prop?.key;
|
|
429
|
+
if (!key)
|
|
430
|
+
return '';
|
|
431
|
+
return key.type === 'Identifier' ? key.name : key.type === 'StringLiteral' ? key.value : '';
|
|
432
|
+
}
|
|
339
433
|
const HTTP_MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
340
434
|
function isRouteHandlerFile(relPath) {
|
|
341
435
|
return /(^|\/)(app|src\/app)\/.*\/route\.(t|j)sx?$/.test(relPath);
|
|
@@ -446,23 +540,28 @@ export const serverActionsScanner = {
|
|
|
446
540
|
meta: { action: name, kind },
|
|
447
541
|
});
|
|
448
542
|
}
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
543
|
+
// Narrowed deliberately. The old condition — parameters, a write, no
|
|
544
|
+
// `.parse()` — reported the ordinary safe pattern: pull named fields out
|
|
545
|
+
// of the payload, write an explicit column list. 92 of 92 hits on one
|
|
546
|
+
// dogfooded app were that shape. What actually carries the danger is the
|
|
547
|
+
// payload reaching the columns *as an object*, which is the only case
|
|
548
|
+
// where a field the caller invented can land in the row. A spread is
|
|
549
|
+
// that same danger and CTS043 already names it, so it is left to CTS043.
|
|
550
|
+
const massAssignable = info.wholePayloadLine !== null && info.spreadLine === null;
|
|
551
|
+
if (info.params > 0 && writes && massAssignable && !info.hasValidation) {
|
|
454
552
|
push({
|
|
455
553
|
id: 'CTS002',
|
|
456
554
|
severity: 'high',
|
|
457
|
-
title: `${kind}
|
|
458
|
-
detail: `\`${name}\`
|
|
459
|
-
'
|
|
460
|
-
'
|
|
555
|
+
title: `${kind} writes caller-supplied data without validating it`,
|
|
556
|
+
detail: `\`${name}\` passes an object it received straight into a database write, with no ` +
|
|
557
|
+
'runtime schema validation. TypeScript types are erased at runtime, so every key the ' +
|
|
558
|
+
'caller chose to send is written — adding `"is_admin": true` to the payload is enough ' +
|
|
559
|
+
'to set that column (mass assignment).',
|
|
461
560
|
fix: 'Parse the input before use:\n' +
|
|
462
561
|
" const parsed = MySchema.safeParse(raw)\n" +
|
|
463
562
|
" if (!parsed.success) throw new Error('Invalid input')\n" +
|
|
464
563
|
'and pass `parsed.data` — never the raw argument — to the query.',
|
|
465
|
-
line: info.line,
|
|
564
|
+
line: info.wholePayloadLine ?? info.line,
|
|
466
565
|
cwe: 'CWE-20: Improper Input Validation',
|
|
467
566
|
owasp: 'A05:2025 - Injection',
|
|
468
567
|
meta: { action: name, kind },
|
|
@@ -556,7 +655,14 @@ export const serverActionsScanner = {
|
|
|
556
655
|
meta: { kind, route: relPath },
|
|
557
656
|
});
|
|
558
657
|
}
|
|
559
|
-
|
|
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) {
|
|
560
666
|
push({
|
|
561
667
|
id: 'CTS004',
|
|
562
668
|
severity: 'medium',
|
|
@@ -613,8 +719,6 @@ export const serverActionsScanner = {
|
|
|
613
719
|
return;
|
|
614
720
|
const name = d.id.name;
|
|
615
721
|
const method = routeFile && /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/.test(name) ? name : undefined;
|
|
616
|
-
// cleartoship-ignore VG010 — Babel's AST accessor, not a SQL call;
|
|
617
|
-
// the rule's verb list includes the generic `get`.
|
|
618
722
|
const initPath = path.get(`declaration.declarations.${i}.init`);
|
|
619
723
|
handle(initPath, name, true, method);
|
|
620
724
|
});
|
package/dist/types.d.ts
CHANGED
package/dist/utils/ast.js
CHANGED
|
@@ -15,7 +15,10 @@ export function parseSource(code, filename) {
|
|
|
15
15
|
'classPrivateMethods',
|
|
16
16
|
'dynamicImport',
|
|
17
17
|
'topLevelAwait',
|
|
18
|
-
|
|
18
|
+
// `importAssertions` until Babel 8 removed it, which makes every parse
|
|
19
|
+
// throw and silently costs every AST rule its findings. The replacement
|
|
20
|
+
// has existed since 7.22, so this is correct on both.
|
|
21
|
+
'importAttributes',
|
|
19
22
|
'explicitResourceManagement',
|
|
20
23
|
];
|
|
21
24
|
if (isTs || isTsx)
|
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/files.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface WalkResult {
|
|
2
|
+
files: string[];
|
|
3
|
+
/** How many paths were left out because the repository ignores them. */
|
|
4
|
+
gitIgnored: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function walk(root: string, options?: {
|
|
7
|
+
respectGitignore?: boolean;
|
|
8
|
+
}): WalkResult;
|
|
2
9
|
export declare function read(file: string): string | null;
|
|
3
10
|
export declare function rel(root: string, file: string): string;
|
|
4
11
|
export declare function exists(p: string): boolean;
|
package/dist/utils/files.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { Gitignore, extendedAt, repositoryExcludes } from './gitignore.js';
|
|
3
4
|
const SKIP_DIRS = new Set([
|
|
4
5
|
'node_modules', '.git', '.next', '.turbo', '.vercel', '.wrangler',
|
|
5
6
|
'dist', 'build', 'out', 'coverage', '.venv', 'venv', '__pycache__',
|
|
@@ -48,11 +49,22 @@ function looksGenerated(fullPath, size) {
|
|
|
48
49
|
return false;
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Files a `.gitignore` excludes are still worth reading, because the tool
|
|
54
|
+
* reasons about them by name: a secret in an ignored `.env` is on your disk
|
|
55
|
+
* either way, and CTS032 exists to check that the file is ignored at all.
|
|
56
|
+
*/
|
|
57
|
+
const SCAN_EVEN_IF_IGNORED = /(^|\/)\.env(\.|$)/;
|
|
58
|
+
export function walk(root, options = {}) {
|
|
59
|
+
const respect = options.respectGitignore !== false;
|
|
52
60
|
const found = [];
|
|
53
|
-
|
|
61
|
+
let gitIgnored = 0;
|
|
62
|
+
const rootRules = respect
|
|
63
|
+
? extendedAt(repositoryExcludes(root, read), root, read)
|
|
64
|
+
: Gitignore.empty();
|
|
65
|
+
const stack = [{ dir: root, rules: rootRules }];
|
|
54
66
|
while (stack.length) {
|
|
55
|
-
const dir = stack.pop();
|
|
67
|
+
const { dir, rules } = stack.pop();
|
|
56
68
|
let entries;
|
|
57
69
|
try {
|
|
58
70
|
entries = readdirSync(dir);
|
|
@@ -72,11 +84,21 @@ export function walk(root) {
|
|
|
72
84
|
if (st.isDirectory()) {
|
|
73
85
|
if (SKIP_DIRS.has(entry))
|
|
74
86
|
continue;
|
|
75
|
-
|
|
87
|
+
// git never descends into an ignored directory, and neither do we —
|
|
88
|
+
// which is also where most of the saving comes from.
|
|
89
|
+
if (respect && rules.ignores(full, true)) {
|
|
90
|
+
gitIgnored++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
stack.push({ dir: full, rules: respect ? extendedAt(rules, full, read) : rules });
|
|
76
94
|
continue;
|
|
77
95
|
}
|
|
78
96
|
if (!st.isFile() || st.size > MAX_FILE_BYTES)
|
|
79
97
|
continue;
|
|
98
|
+
if (respect && !SCAN_EVEN_IF_IGNORED.test(full) && rules.ignores(full, false)) {
|
|
99
|
+
gitIgnored++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
80
102
|
const dot = entry.lastIndexOf('.');
|
|
81
103
|
const ext = dot === -1 ? '' : entry.slice(dot);
|
|
82
104
|
// .env, .env.local, requirements.txt and friends have no useful extension.
|
|
@@ -91,7 +113,7 @@ export function walk(root) {
|
|
|
91
113
|
}
|
|
92
114
|
}
|
|
93
115
|
}
|
|
94
|
-
return found.sort();
|
|
116
|
+
return { files: found.sort(), gitIgnored };
|
|
95
117
|
}
|
|
96
118
|
export function read(file) {
|
|
97
119
|
try {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare class Gitignore {
|
|
2
|
+
private readonly layers;
|
|
3
|
+
private constructor();
|
|
4
|
+
static empty(): Gitignore;
|
|
5
|
+
/** A copy of this matcher with one more `.gitignore`'s worth of rules. */
|
|
6
|
+
extend(base: string, content: string): Gitignore;
|
|
7
|
+
get isEmpty(): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Whether git would ignore `absPath`. Deepest layer first, and inside a layer
|
|
10
|
+
* the last matching pattern decides — both are git's own rules.
|
|
11
|
+
*/
|
|
12
|
+
ignores(absPath: string, isDir: boolean): boolean;
|
|
13
|
+
}
|
|
14
|
+
/** The ignore rules that apply at `dir`, given those inherited from above. */
|
|
15
|
+
export declare function extendedAt(parent: Gitignore, dir: string, read: (path: string) => string | null): Gitignore;
|
|
16
|
+
/**
|
|
17
|
+
* Repository-local excludes. Same syntax, same precedence as a root
|
|
18
|
+
* `.gitignore`, but kept out of version control — so a machine-specific
|
|
19
|
+
* scratch directory is invisible here without appearing in anyone's diff.
|
|
20
|
+
*/
|
|
21
|
+
export declare function repositoryExcludes(root: string, read: (path: string) => string | null): Gitignore;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
function escapeLiteral(ch) {
|
|
3
|
+
return /[.*+?^${}()|[\]\\]/.test(ch) ? '\\' + ch : ch;
|
|
4
|
+
}
|
|
5
|
+
/** Translates one gitignore pattern body into a regex source. */
|
|
6
|
+
function translate(pattern) {
|
|
7
|
+
let out = '';
|
|
8
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
9
|
+
const ch = pattern[i];
|
|
10
|
+
if (ch === '\\' && i + 1 < pattern.length) {
|
|
11
|
+
out += escapeLiteral(pattern[++i]);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (ch === '*') {
|
|
15
|
+
const doubled = pattern[i + 1] === '*';
|
|
16
|
+
if (doubled) {
|
|
17
|
+
const atStart = i === 0 || pattern[i - 1] === '/';
|
|
18
|
+
const slashAfter = pattern[i + 2] === '/';
|
|
19
|
+
if (atStart && slashAfter) {
|
|
20
|
+
// `**/` matches zero or more leading directories.
|
|
21
|
+
out += '(?:[^/]+/)*';
|
|
22
|
+
i += 2;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
// A trailing or embedded `**` crosses directory boundaries.
|
|
26
|
+
out += '.*';
|
|
27
|
+
i += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
out += '[^/]*';
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (ch === '?') {
|
|
34
|
+
out += '[^/]';
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (ch === '[') {
|
|
38
|
+
const close = pattern.indexOf(']', i + 1);
|
|
39
|
+
if (close === -1) {
|
|
40
|
+
out += '\\[';
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
let body = pattern.slice(i + 1, close);
|
|
44
|
+
if (body.startsWith('!'))
|
|
45
|
+
body = '^' + body.slice(1);
|
|
46
|
+
out += '[' + body + ']';
|
|
47
|
+
i = close;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
out += escapeLiteral(ch);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function compile(line) {
|
|
55
|
+
// Trailing whitespace is not part of the pattern unless it was escaped.
|
|
56
|
+
let pattern = line.replace(/(?<!\\)\s+$/, '');
|
|
57
|
+
if (pattern === '' || pattern.startsWith('#'))
|
|
58
|
+
return null;
|
|
59
|
+
let negated = false;
|
|
60
|
+
if (pattern.startsWith('!')) {
|
|
61
|
+
negated = true;
|
|
62
|
+
pattern = pattern.slice(1);
|
|
63
|
+
}
|
|
64
|
+
else if (pattern.startsWith('\\!') || pattern.startsWith('\\#')) {
|
|
65
|
+
pattern = pattern.slice(1);
|
|
66
|
+
}
|
|
67
|
+
let dirOnly = false;
|
|
68
|
+
if (pattern.endsWith('/')) {
|
|
69
|
+
dirOnly = true;
|
|
70
|
+
pattern = pattern.slice(0, -1);
|
|
71
|
+
}
|
|
72
|
+
if (pattern === '')
|
|
73
|
+
return null;
|
|
74
|
+
// A slash anywhere but the end anchors the pattern to this file's directory;
|
|
75
|
+
// otherwise it matches a basename at any depth.
|
|
76
|
+
const anchored = pattern.includes('/');
|
|
77
|
+
if (pattern.startsWith('/'))
|
|
78
|
+
pattern = pattern.slice(1);
|
|
79
|
+
const body = translate(pattern);
|
|
80
|
+
const source = anchored ? `^${body}(?:/.*)?$` : `(?:^|/)${body}(?:/.*)?$`;
|
|
81
|
+
return { re: new RegExp(source), negated, dirOnly };
|
|
82
|
+
}
|
|
83
|
+
export class Gitignore {
|
|
84
|
+
layers;
|
|
85
|
+
constructor(layers) {
|
|
86
|
+
this.layers = layers;
|
|
87
|
+
}
|
|
88
|
+
static empty() {
|
|
89
|
+
return new Gitignore([]);
|
|
90
|
+
}
|
|
91
|
+
/** A copy of this matcher with one more `.gitignore`'s worth of rules. */
|
|
92
|
+
extend(base, content) {
|
|
93
|
+
const rules = [];
|
|
94
|
+
for (const line of content.split(/\r?\n/)) {
|
|
95
|
+
const rule = compile(line);
|
|
96
|
+
if (rule)
|
|
97
|
+
rules.push(rule);
|
|
98
|
+
}
|
|
99
|
+
if (rules.length === 0)
|
|
100
|
+
return this;
|
|
101
|
+
return new Gitignore([...this.layers, { base: base.replace(/\/+$/, ''), rules }]);
|
|
102
|
+
}
|
|
103
|
+
get isEmpty() {
|
|
104
|
+
return this.layers.length === 0;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Whether git would ignore `absPath`. Deepest layer first, and inside a layer
|
|
108
|
+
* the last matching pattern decides — both are git's own rules.
|
|
109
|
+
*/
|
|
110
|
+
ignores(absPath, isDir) {
|
|
111
|
+
for (let i = this.layers.length - 1; i >= 0; i--) {
|
|
112
|
+
const layer = this.layers[i];
|
|
113
|
+
if (!absPath.startsWith(layer.base + '/'))
|
|
114
|
+
continue;
|
|
115
|
+
const relative = absPath.slice(layer.base.length + 1);
|
|
116
|
+
for (let j = layer.rules.length - 1; j >= 0; j--) {
|
|
117
|
+
const rule = layer.rules[j];
|
|
118
|
+
if (rule.dirOnly && !isDir)
|
|
119
|
+
continue;
|
|
120
|
+
if (rule.re.test(relative))
|
|
121
|
+
return !rule.negated;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** The ignore rules that apply at `dir`, given those inherited from above. */
|
|
128
|
+
export function extendedAt(parent, dir, read) {
|
|
129
|
+
const content = read(join(dir, '.gitignore'));
|
|
130
|
+
return content === null ? parent : parent.extend(dir, content);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Repository-local excludes. Same syntax, same precedence as a root
|
|
134
|
+
* `.gitignore`, but kept out of version control — so a machine-specific
|
|
135
|
+
* scratch directory is invisible here without appearing in anyone's diff.
|
|
136
|
+
*/
|
|
137
|
+
export function repositoryExcludes(root, read) {
|
|
138
|
+
const content = read(join(root, '.git', 'info', 'exclude'));
|
|
139
|
+
return content === null ? Gitignore.empty() : Gitignore.empty().extend(root, content);
|
|
140
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Severity } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Where a file sits decides how much a finding in it matters. A dependency CVE
|
|
4
|
+
* already gets this treatment — CTS024 separates what ships from what only ever
|
|
5
|
+
* runs on a developer's machine — and the same reasoning applies to the rules
|
|
6
|
+
* that read code: an interpolated query in a migration script you run by hand is
|
|
7
|
+
* a different risk from one behind a public HTTP endpoint, and neither is the
|
|
8
|
+
* same as one in a test fixture.
|
|
9
|
+
*/
|
|
10
|
+
export type PathClass = 'production' | 'dev-tooling' | 'non-production';
|
|
11
|
+
export declare function pathClass(relPath: string): PathClass;
|
|
12
|
+
/** One step down the scale, floored at `low`. */
|
|
13
|
+
export declare function demote(severity: Severity): Severity;
|
|
14
|
+
/**
|
|
15
|
+
* The severity a finding should carry once you know where it lives, plus the
|
|
16
|
+
* sentence explaining the adjustment — always stated, never silent.
|
|
17
|
+
*/
|
|
18
|
+
export declare function adjustForPath(severity: Severity, relPath: string): {
|
|
19
|
+
severity: Severity;
|
|
20
|
+
note: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { SEVERITY_ORDER } from '../types.js';
|
|
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;
|
|
13
|
+
/**
|
|
14
|
+
* Build and maintenance tooling. It runs on a developer's machine or in CI,
|
|
15
|
+
* with credentials its author already holds, and never answers a request from
|
|
16
|
+
* the internet — so nothing here is reachable by an attacker who is not already
|
|
17
|
+
* inside the repository.
|
|
18
|
+
*
|
|
19
|
+
* `bin/` is deliberately absent: for a published CLI that is the shipped entry
|
|
20
|
+
* point. So is `migrations/`, which is the production schema.
|
|
21
|
+
*/
|
|
22
|
+
const DEV_TOOLING = /(^|\/)(scripts?|tools?|tooling|\.github|\.husky|\.circleci|seeds?|benchmarks?|codegen)\/|(^|\/)[^/]*\.config\.[cm]?[jt]s$|(^|\/)(Makefile|Dockerfile[^/]*|docker-compose[^/]*\.ya?ml)$/i;
|
|
23
|
+
export function pathClass(relPath) {
|
|
24
|
+
if (NON_PRODUCTION.test(relPath))
|
|
25
|
+
return 'non-production';
|
|
26
|
+
if (DEV_TOOLING.test(relPath))
|
|
27
|
+
return 'dev-tooling';
|
|
28
|
+
return 'production';
|
|
29
|
+
}
|
|
30
|
+
const ORDER = ['info', 'low', 'medium', 'high', 'critical'];
|
|
31
|
+
/** One step down the scale, floored at `low`. */
|
|
32
|
+
export function demote(severity) {
|
|
33
|
+
const index = SEVERITY_ORDER[severity];
|
|
34
|
+
return ORDER[Math.max(1, index - 1)];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The severity a finding should carry once you know where it lives, plus the
|
|
38
|
+
* sentence explaining the adjustment — always stated, never silent.
|
|
39
|
+
*/
|
|
40
|
+
export function adjustForPath(severity, relPath) {
|
|
41
|
+
switch (pathClass(relPath)) {
|
|
42
|
+
case 'non-production':
|
|
43
|
+
return {
|
|
44
|
+
severity: 'low',
|
|
45
|
+
note: ' (Path looks like tests, fixtures or docs, so the severity is reduced.)',
|
|
46
|
+
};
|
|
47
|
+
case 'dev-tooling':
|
|
48
|
+
return {
|
|
49
|
+
severity: demote(severity),
|
|
50
|
+
note: ' (Path is build or maintenance tooling rather than shipped code — it runs on a ' +
|
|
51
|
+
'developer machine or in CI, not in response to a request — so the severity is one ' +
|
|
52
|
+
'step lower than it would be in the app itself.)',
|
|
53
|
+
};
|
|
54
|
+
default:
|
|
55
|
+
return { severity, note: '' };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -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';
|
|
@@ -20,8 +20,8 @@ jobs:
|
|
|
20
20
|
preflight:
|
|
21
21
|
runs-on: ubuntu-latest
|
|
22
22
|
steps:
|
|
23
|
-
- uses: actions/checkout@
|
|
24
|
-
- uses: actions/setup-node@
|
|
23
|
+
- uses: actions/checkout@v7
|
|
24
|
+
- uses: actions/setup-node@v7
|
|
25
25
|
with:
|
|
26
26
|
node-version: 20
|
|
27
27
|
|
|
@@ -29,7 +29,7 @@ jobs:
|
|
|
29
29
|
# the gate is the second run, so a finding is visible in the UI even when
|
|
30
30
|
# it blocks the build.
|
|
31
31
|
- run: npx cleartoship --sarif -o cleartoship.sarif --fail-on=none
|
|
32
|
-
- uses: github/codeql-action/upload-sarif@
|
|
32
|
+
- uses: github/codeql-action/upload-sarif@v4
|
|
33
33
|
with:
|
|
34
34
|
sarif_file: cleartoship.sarif
|
|
35
35
|
|
package/examples/security.yml
CHANGED
|
@@ -21,8 +21,8 @@ jobs:
|
|
|
21
21
|
preflight:
|
|
22
22
|
runs-on: ubuntu-latest
|
|
23
23
|
steps:
|
|
24
|
-
- uses: actions/checkout@
|
|
25
|
-
- uses: murtazaozdemir/cleartoship@v0.
|
|
24
|
+
- uses: actions/checkout@v7
|
|
25
|
+
- uses: murtazaozdemir/cleartoship@v0.10.1
|
|
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.
|
|
3
|
+
"version": "0.10.1",
|
|
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",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/babel__traverse": "^7.20.6",
|
|
58
|
-
"@types/node": "^
|
|
59
|
-
"typescript": "^
|
|
58
|
+
"@types/node": "^26.4.0",
|
|
59
|
+
"typescript": "^7.0.2"
|
|
60
60
|
},
|
|
61
61
|
"repository": {
|
|
62
62
|
"type": "git",
|