cleartoship 0.9.0 → 0.10.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 +35 -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 +83 -8
- package/dist/scanners/logic.js +8 -1
- package/dist/scanners/server-actions.js +120 -23
- package/dist/utils/ast.js +4 -1
- 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 +48 -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,10 @@ 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, 5 carry a **match guard** for a
|
|
101
|
+
shape their regex cannot exclude (a `"link": true` lockfile entry has no
|
|
102
|
+
integrity hash by design; `querySelectorAll` is not a SQL call; `eval()` inside
|
|
103
|
+
a sentence about eval is prose), and 3 name-heuristic rules are
|
|
101
104
|
**manifest-only** — they never run over a `package-lock.json`, where they matched
|
|
102
105
|
ordinary transitive packages (`fast-glob`, `core-js`) and named nothing anyone
|
|
103
106
|
could act on. Each list carries a reason per rule in
|
|
@@ -153,6 +156,7 @@ npx cleartoship --sarif -o results.sarif # GitHub code scanning
|
|
|
153
156
|
npx cleartoship --fail-on high # stricter CI gate (default: critical)
|
|
154
157
|
npx cleartoship --ignore CTS004,CTS022 # skip rules
|
|
155
158
|
npx cleartoship --no-community # ClearToShip rules only
|
|
159
|
+
npx cleartoship --no-gitignore # also scan what .gitignore excludes
|
|
156
160
|
npx cleartoship --markdown # markdown report (PR comments / summaries)
|
|
157
161
|
```
|
|
158
162
|
|
|
@@ -181,7 +185,7 @@ To feed findings into GitHub's Security tab:
|
|
|
181
185
|
|
|
182
186
|
```yaml
|
|
183
187
|
- run: npx cleartoship --sarif -o results.sarif --fail-on=none
|
|
184
|
-
- uses: github/codeql-action/upload-sarif@
|
|
188
|
+
- uses: github/codeql-action/upload-sarif@v4
|
|
185
189
|
with: { sarif_file: results.sarif }
|
|
186
190
|
```
|
|
187
191
|
|
|
@@ -211,8 +215,8 @@ jobs:
|
|
|
211
215
|
preflight:
|
|
212
216
|
runs-on: ubuntu-latest
|
|
213
217
|
steps:
|
|
214
|
-
- uses: actions/checkout@
|
|
215
|
-
- uses: murtazaozdemir/cleartoship@v0.
|
|
218
|
+
- uses: actions/checkout@v7
|
|
219
|
+
- uses: murtazaozdemir/cleartoship@v0.10.0
|
|
216
220
|
with:
|
|
217
221
|
fail-on: critical
|
|
218
222
|
comment: true
|
|
@@ -234,7 +238,7 @@ above `fail-on`) for use in later steps. The comment is *sticky* — re-runs edi
|
|
|
234
238
|
the same comment instead of piling up.
|
|
235
239
|
|
|
236
240
|
By default the action runs the scanner version its own ref declares, so
|
|
237
|
-
`@v0.
|
|
241
|
+
`@v0.10.0` runs `cleartoship@0.10.0` and pinning the ref pins the behaviour. If
|
|
238
242
|
that version is not on the registry, it builds from its own checkout instead, so
|
|
239
243
|
`uses: …@ref` works against an unpublished commit.
|
|
240
244
|
|
|
@@ -274,8 +278,33 @@ uploaded, and no database is connected to.
|
|
|
274
278
|
a network blip must never be reported as a hallucinated dependency.
|
|
275
279
|
- **Test fixtures are not breaches.** Credentials under `tests/`, `fixtures/`, `docs/` or in a
|
|
276
280
|
commented-out line are reported at `low`, never as blocking criticals.
|
|
281
|
+
- **Where a file lives changes what a finding costs.** CTS024 already separated a
|
|
282
|
+
CVE that ships from one that only ever runs on your machine; the same reasoning
|
|
283
|
+
now covers the rules that read code. An interpolated query in `scripts/`, a
|
|
284
|
+
swallowed error in a `*.config.ts` — build and maintenance tooling runs with
|
|
285
|
+
credentials its author already holds and never answers a request from the
|
|
286
|
+
internet, so those drop one severity step, with the reason attached to the
|
|
287
|
+
finding rather than applied silently. `bin/` and `migrations/` are deliberately
|
|
288
|
+
excluded: one is a published CLI's entry point, the other is production schema.
|
|
289
|
+
Across five dogfooded repos this moved 21 findings out of `critical` without
|
|
290
|
+
hiding one of them.
|
|
291
|
+
- **Mass assignment means the payload arrives whole.** CTS002 and CTS043 fire when
|
|
292
|
+
the object the caller sent reaches the columns — `update(body)`,
|
|
293
|
+
`data: { ...input }`, including one level down where Prisma and Drizzle put it.
|
|
294
|
+
Reading named fields into an explicit column list is the safe pattern and is
|
|
295
|
+
never reported, whether or not a schema library was involved: of 102 findings
|
|
296
|
+
the old, broader rule produced on one dogfooded app, all 102 were that shape.
|
|
277
297
|
- **Precision over recall on the noisy rules.** Typosquat matching skips exact matches and
|
|
278
298
|
names shorter than five characters, where one-edit neighbours are meaningless.
|
|
299
|
+
- **Your `.gitignore` decides what counts as your project.** Ignored paths are
|
|
300
|
+
not scanned: they never reach a CI checkout, so a finding there is one nobody
|
|
301
|
+
can act on and nobody's pipeline would reproduce. This is usually the
|
|
302
|
+
difference between a usable report and an unreadable one — a repo with a
|
|
303
|
+
gitignored folder of reference apps went from 1,487 findings to 235, and from
|
|
304
|
+
84 seconds to 2. `.env` files are the deliberate exception, since a secret on
|
|
305
|
+
your disk is a secret either way and CTS032 exists to check that the file *is*
|
|
306
|
+
ignored. `--no-gitignore` scans everything, and the report always says how many
|
|
307
|
+
paths were skipped.
|
|
279
308
|
- **Auth is followed into the helper it lives in.** Almost no real app repeats the
|
|
280
309
|
session check inside every action — it resolves in `lib/auth.ts`, or in a
|
|
281
310
|
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.0` runs cleartoship@0.10.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.10.0` runs cleartoship@0.10.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
|
|
@@ -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,5 +1,6 @@
|
|
|
1
1
|
import { read, rel, lineAt, snippetAt, languagesFor } from '../utils/files.js';
|
|
2
2
|
import { Suppressions } from '../utils/suppress.js';
|
|
3
|
+
import { adjustForPath } from '../utils/paths.js';
|
|
3
4
|
import { GUARDVIBE_RULES, GUARDVIBE_ATTRIBUTION } from '../vendor/guardvibe/index.js';
|
|
4
5
|
import { emptyResult } from '../types.js';
|
|
5
6
|
/**
|
|
@@ -97,18 +98,93 @@ 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
|
+
/** Whether `index` falls inside a quoted string on its own line. */
|
|
125
|
+
function insideStringLiteral(source, index) {
|
|
126
|
+
const lineStart = source.lastIndexOf('\n', index - 1) + 1;
|
|
127
|
+
let quote = null;
|
|
128
|
+
for (let i = lineStart; i < index; i++) {
|
|
129
|
+
const ch = source[i];
|
|
130
|
+
if (ch === '\\') {
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (quote) {
|
|
135
|
+
if (ch === quote)
|
|
136
|
+
quote = null;
|
|
137
|
+
}
|
|
138
|
+
else if (ch === '"' || ch === "'" || ch === '`') {
|
|
139
|
+
quote = ch;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return quote !== null;
|
|
143
|
+
}
|
|
100
144
|
/**
|
|
101
145
|
* Per-rule filters for a match shape the upstream regex cannot exclude on its
|
|
102
|
-
* own.
|
|
146
|
+
* own. Given the matched text plus where it sat, so a guard can look around it.
|
|
103
147
|
*/
|
|
104
148
|
const MATCH_GUARDS = {
|
|
105
149
|
// A `"link": true` entry is a workspace or pnpm symlink resolved to a path on
|
|
106
150
|
// disk rather than a tarball, so it has no integrity hash by design. Thirty of
|
|
107
151
|
// them in one pnpm-managed lockfile, every one reported as a false critical.
|
|
108
152
|
VG870: (match) => !/"link"\s*:\s*true/.test(match),
|
|
153
|
+
// The verb list has no word boundary in front of it and includes `all`, `get`
|
|
154
|
+
// and `run`, so `querySelectorAll(\`[name="${CSS.escape(k)}"]\`)` reads as a
|
|
155
|
+
// SQL call. Weak verbs have to be backed by something that looks like SQL;
|
|
156
|
+
// `query`, `execute` and friends stand on their own. A real interpolated
|
|
157
|
+
// `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
|
+
// `(?:child_process|cp)[\s\S]*?(?:exec|spawn…)` lets the bridge run to the end
|
|
163
|
+
// of the file: one match measured 3,608 characters and 109 lines, pairing an
|
|
164
|
+
// `import … from "node:child_process"` with an `exec(` far below it and
|
|
165
|
+
// reporting the import as a critical. A real one is a single statement.
|
|
166
|
+
VG011: (match) => (match.match(/\n/g)?.length ?? 0) <= 1,
|
|
167
|
+
// `sk-[A-Za-z0-9-_]{20,}` has no boundary in front of it, so the slug
|
|
168
|
+
// `best-disk-space-analyzer-mac-2026` contains an "OpenAI key" — the `sk-` in
|
|
169
|
+
// "di*sk-*space...". Real keys start at a token boundary; substrings of a word
|
|
170
|
+
// do not.
|
|
171
|
+
VG003: (_match, source, index) => index === 0 || !/[A-Za-z0-9_-]/.test(source[index - 1]),
|
|
172
|
+
// Both rules key off a *name* — anything called password, secret, apiKey — and
|
|
173
|
+
// accept any string as its value, so UI copy lands as a critical:
|
|
174
|
+
// `password: "That password didn't match. Try again."` was one. A credential
|
|
175
|
+
// is an opaque token, not a sentence and not a placeholder.
|
|
176
|
+
VG001: (match) => looksLikeSecretValue(match),
|
|
177
|
+
VG062: (match) => looksLikeSecretValue(match),
|
|
178
|
+
// "An attacker can request the entire table" is the rule's premise, and a
|
|
179
|
+
// query filtered to the caller's own rows does not let them. An unbounded
|
|
180
|
+
// fetch of your own data is a scalability question, not a security finding.
|
|
181
|
+
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),
|
|
109
187
|
};
|
|
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
188
|
/** Regexes over very large files are where catastrophic backtracking bites. */
|
|
113
189
|
const MAX_BYTES = 400_000;
|
|
114
190
|
/** Upstream severities already use our vocabulary; this just narrows the type. */
|
|
@@ -135,7 +211,6 @@ export const communityScanner = {
|
|
|
135
211
|
if (source === null || source.length > MAX_BYTES)
|
|
136
212
|
continue;
|
|
137
213
|
const relPath = rel(ctx.root, file);
|
|
138
|
-
const fixture = NON_PRODUCTION_PATH.test(relPath);
|
|
139
214
|
const lockfile = LOCKFILE.test(relPath);
|
|
140
215
|
const suppress = new Suppressions(source);
|
|
141
216
|
filesScanned++;
|
|
@@ -157,7 +232,7 @@ export const communityScanner = {
|
|
|
157
232
|
}
|
|
158
233
|
// Skipping a match must not skip the non-global `break` below, or a
|
|
159
234
|
// rule without /g would rescan from zero forever.
|
|
160
|
-
if (guard && !guard(m[0])) {
|
|
235
|
+
if (guard && !guard(m[0], source, m.index)) {
|
|
161
236
|
if (!re.global)
|
|
162
237
|
break;
|
|
163
238
|
continue;
|
|
@@ -166,12 +241,12 @@ export const communityScanner = {
|
|
|
166
241
|
const key = `${relPath}:${line}:${rule.id}`;
|
|
167
242
|
if (!seen.has(key) && !suppress.suppressed(line, rule.id)) {
|
|
168
243
|
seen.add(key);
|
|
244
|
+
const placed = adjustForPath(severityOf(rule.severity), relPath);
|
|
169
245
|
result.findings.push({
|
|
170
246
|
id: rule.id,
|
|
171
|
-
severity:
|
|
247
|
+
severity: placed.severity,
|
|
172
248
|
title: rule.name,
|
|
173
|
-
detail: rule.description +
|
|
174
|
-
(fixture ? ' (Path looks like tests or docs, so the severity is reduced.)' : ''),
|
|
249
|
+
detail: rule.description + placed.note,
|
|
175
250
|
fix: rule.fixCode ? `${rule.fix}\n\n${rule.fixCode}` : rule.fix,
|
|
176
251
|
file: relPath,
|
|
177
252
|
line,
|
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 },
|
|
@@ -613,8 +712,6 @@ export const serverActionsScanner = {
|
|
|
613
712
|
return;
|
|
614
713
|
const name = d.id.name;
|
|
615
714
|
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
715
|
const initPath = path.get(`declaration.declarations.${i}.init`);
|
|
619
716
|
handle(initPath, name, true, method);
|
|
620
717
|
});
|
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/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,48 @@
|
|
|
1
|
+
import { SEVERITY_ORDER } from '../types.js';
|
|
2
|
+
/** Tests, fixtures, examples and docs: the code is there to be read, not run. */
|
|
3
|
+
const NON_PRODUCTION = /(^|\/)(tests?|__tests__|__mocks__|__fixtures__|fixtures?|spec|specs|examples?|docs?|demo|samples?|e2e|cypress|playwright|stories)(\/|$)|\.(test|spec|stories|fixture)\.[a-z]+$/i;
|
|
4
|
+
/**
|
|
5
|
+
* Build and maintenance tooling. It runs on a developer's machine or in CI,
|
|
6
|
+
* with credentials its author already holds, and never answers a request from
|
|
7
|
+
* the internet — so nothing here is reachable by an attacker who is not already
|
|
8
|
+
* inside the repository.
|
|
9
|
+
*
|
|
10
|
+
* `bin/` is deliberately absent: for a published CLI that is the shipped entry
|
|
11
|
+
* point. So is `migrations/`, which is the production schema.
|
|
12
|
+
*/
|
|
13
|
+
const DEV_TOOLING = /(^|\/)(scripts?|tools?|tooling|\.github|\.husky|\.circleci|seeds?|benchmarks?|codegen)(\/|$)|(^|\/)[^/]*\.config\.[cm]?[jt]s$|(^|\/)(Makefile|Dockerfile[^/]*|docker-compose[^/]*\.ya?ml)$/i;
|
|
14
|
+
export function pathClass(relPath) {
|
|
15
|
+
if (NON_PRODUCTION.test(relPath))
|
|
16
|
+
return 'non-production';
|
|
17
|
+
if (DEV_TOOLING.test(relPath))
|
|
18
|
+
return 'dev-tooling';
|
|
19
|
+
return 'production';
|
|
20
|
+
}
|
|
21
|
+
const ORDER = ['info', 'low', 'medium', 'high', 'critical'];
|
|
22
|
+
/** One step down the scale, floored at `low`. */
|
|
23
|
+
export function demote(severity) {
|
|
24
|
+
const index = SEVERITY_ORDER[severity];
|
|
25
|
+
return ORDER[Math.max(1, index - 1)];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The severity a finding should carry once you know where it lives, plus the
|
|
29
|
+
* sentence explaining the adjustment — always stated, never silent.
|
|
30
|
+
*/
|
|
31
|
+
export function adjustForPath(severity, relPath) {
|
|
32
|
+
switch (pathClass(relPath)) {
|
|
33
|
+
case 'non-production':
|
|
34
|
+
return {
|
|
35
|
+
severity: 'low',
|
|
36
|
+
note: ' (Path looks like tests, fixtures or docs, so the severity is reduced.)',
|
|
37
|
+
};
|
|
38
|
+
case 'dev-tooling':
|
|
39
|
+
return {
|
|
40
|
+
severity: demote(severity),
|
|
41
|
+
note: ' (Path is build or maintenance tooling rather than shipped code — it runs on a ' +
|
|
42
|
+
'developer machine or in CI, not in response to a request — so the severity is one ' +
|
|
43
|
+
'step lower than it would be in the app itself.)',
|
|
44
|
+
};
|
|
45
|
+
default:
|
|
46
|
+
return { severity, note: '' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -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.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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cleartoship",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|