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 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 | Action takes caller input and writes it with no runtime schema validation |
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, and 3 name-heuristic rules are
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@v3
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@v4
215
- - uses: murtazaozdemir/cleartoship@v0.9.0
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.9.0` runs `cleartoship@0.9.0` and pinning the ref pins the behaviour. If
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 virtualenvs. Nothing outside the root is read except the cache directory. |
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.9.0` runs cleartoship@0.9.0. Set
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.9.0` runs cleartoship@0.9.0 instead of
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@v3
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 files = [...new Set(roots.flatMap((r) => walk(r)))];
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 { GUARDVIBE_RULES, GUARDVIBE_ATTRIBUTION } from '../vendor/guardvibe/index.js';
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. Applied to the matched text, not the whole file.
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 active = GUARDVIBE_RULES.filter((r) => !SUPERSEDED.has(r.id) && !WITHHELD.has(r.id));
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: fixture ? 'low' : severityOf(rule.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
  },
@@ -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 })` writes whatever keys the caller chose to send.
228
- for (const arg of inner.node.arguments ?? []) {
229
- const props = arg?.type === 'ObjectExpression' ? arg.properties : null;
230
- if (!props)
231
- continue;
232
- for (const prop of props) {
233
- if (prop?.type !== 'SpreadElement')
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
- const spreadOf = calleeName(prop.argument);
236
- if (/^(this|process|env)\b/.test(spreadOf))
306
+ }
307
+ if (prop?.type !== 'ObjectProperty')
308
+ continue;
309
+ if (!WRITE_PAYLOAD_KEYS.has(propertyKey(prop)))
237
310
  continue;
238
- info.spreadLine ??= prop.loc?.start.line ?? info.line;
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
- // A Route Handler that never reads a body, query or route param has no
450
- // caller input to validate reporting one is noise, and it was firing
451
- // on cron endpoints whose whole body is a secret comparison.
452
- const hasInput = !isRoute || info.readsRequestInput;
453
- if (info.params > 0 && writes && hasInput && !info.hasValidation) {
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} input is never validated at runtime`,
458
- detail: `\`${name}\` accepts caller-supplied arguments and writes them to the database ` +
459
- 'without runtime schema validation. TypeScript types are erased at runtime, so a ' +
460
- 'crafted payload can carry extra fields straight into the write (mass assignment).',
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
- if (writes && info.hasAuth && !info.ownerScoped && info.params > 0) {
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
@@ -53,6 +53,7 @@ export interface FrameworkInfo {
53
53
  nextAuth: boolean;
54
54
  stripe: boolean;
55
55
  python: boolean;
56
+ reactNative: boolean;
56
57
  /** Human-readable one-liner for the CLI header. */
57
58
  describe(): string;
58
59
  }
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
- 'importAssertions',
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)
@@ -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
  };
@@ -1,4 +1,11 @@
1
- export declare function walk(root: string): string[];
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;
@@ -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
- export function walk(root) {
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
- const stack = [root];
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
- stack.push(full);
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@v4
24
- - uses: actions/setup-node@v4
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@v3
32
+ - uses: github/codeql-action/upload-sarif@v4
33
33
  with:
34
34
  sarif_file: cleartoship.sarif
35
35
 
@@ -21,8 +21,8 @@ jobs:
21
21
  preflight:
22
22
  runs-on: ubuntu-latest
23
23
  steps:
24
- - uses: actions/checkout@v4
25
- - uses: murtazaozdemir/cleartoship@v0.9.0
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.9.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": "^22.5.0",
59
- "typescript": "^5.6.0"
58
+ "@types/node": "^26.4.0",
59
+ "typescript": "^7.0.2"
60
60
  },
61
61
  "repository": {
62
62
  "type": "git",