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 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,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, and 3 name-heuristic rules are
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@v3
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@v4
215
- - uses: murtazaozdemir/cleartoship@v0.9.0
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.9.0` runs `cleartoship@0.9.0` and pinning the ref pins the behaviour. If
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 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.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.9.0` runs cleartoship@0.9.0 instead of
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@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,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. Applied to the matched text, not the whole file.
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: fixture ? 'low' : severityOf(rule.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,
@@ -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 },
@@ -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
- '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)
@@ -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,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@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.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.9.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": "^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",