cleartoship 0.8.0 → 0.9.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.
@@ -0,0 +1,169 @@
1
+ import { read } from '../utils/files.js';
2
+ import { parseSource, calleeName, calleeTail } from '../utils/ast.js';
3
+ /**
4
+ * Recognising an authenticated caller only when the check is written inline
5
+ * inside the action is wrong for the way real apps are built: session
6
+ * verification is factored into `lib/auth.ts` (or a framework helper such as
7
+ * Shopify's `handleSessionToken`) and every action calls that. Reading only the
8
+ * action body then reports each of them as unauthenticated — four false
9
+ * criticals on one dogfooded Shopify app, which is exactly the kind of finding
10
+ * that teaches people to ignore the tool.
11
+ *
12
+ * This module answers the narrower question the scanner actually needs: which
13
+ * *names*, as spelled in this file, stand for "the caller was authenticated"?
14
+ * A name qualifies when the function behind it — defined here, or exported by a
15
+ * first-party module this file imports — itself performs a recognised auth call,
16
+ * directly or through another such helper. Third-party packages are never
17
+ * followed, so a call into `node_modules` still proves nothing on its own.
18
+ */
19
+ /** How many import hops to follow before giving up. */
20
+ const MAX_DEPTH = 3;
21
+ /** Rounds of the same-file fixed point — helpers calling helpers calling helpers. */
22
+ const MAX_ROUNDS = 4;
23
+ const EMPTY = new Set();
24
+ function matchesAny(name, list) {
25
+ for (const candidate of list) {
26
+ if (name === candidate || name.endsWith('.' + candidate))
27
+ return true;
28
+ }
29
+ return false;
30
+ }
31
+ /** Every callee name (dotted and bare) reachable inside one function body. */
32
+ function callNamesIn(node, out, depth = 0) {
33
+ if (!node || typeof node !== 'object' || depth > 400)
34
+ return;
35
+ if (Array.isArray(node)) {
36
+ for (const child of node)
37
+ callNamesIn(child, out, depth + 1);
38
+ return;
39
+ }
40
+ if (typeof node.type !== 'string')
41
+ return;
42
+ if (node.type === 'CallExpression' || node.type === 'OptionalCallExpression') {
43
+ const full = calleeName(node.callee);
44
+ if (full) {
45
+ out.add(full);
46
+ out.add(calleeTail(node.callee));
47
+ }
48
+ }
49
+ for (const key of Object.keys(node)) {
50
+ if (key === 'loc' || key === 'leadingComments' || key === 'trailingComments')
51
+ continue;
52
+ const value = node[key];
53
+ if (value && typeof value === 'object')
54
+ callNamesIn(value, out, depth + 1);
55
+ }
56
+ }
57
+ /** Top-level `function f()` / `const f = () => {}`, exported or not. */
58
+ function collectFunctions(ast) {
59
+ const found = new Map();
60
+ const addFunction = (name, body) => {
61
+ if (!name || !body)
62
+ return;
63
+ const calls = new Set();
64
+ callNamesIn(body, calls);
65
+ found.set(name, calls);
66
+ };
67
+ const fromDeclaration = (decl) => {
68
+ if (!decl)
69
+ return;
70
+ if (decl.type === 'FunctionDeclaration') {
71
+ addFunction(decl.id?.name, decl.body);
72
+ return;
73
+ }
74
+ if (decl.type !== 'VariableDeclaration')
75
+ return;
76
+ for (const d of decl.declarations ?? []) {
77
+ if (d?.id?.type !== 'Identifier')
78
+ continue;
79
+ const init = d.init;
80
+ if (init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression') {
81
+ addFunction(d.id.name, init.body);
82
+ }
83
+ }
84
+ };
85
+ for (const stmt of ast.program.body) {
86
+ if (stmt.type === 'ExportNamedDeclaration')
87
+ fromDeclaration(stmt.declaration);
88
+ else
89
+ fromDeclaration(stmt);
90
+ }
91
+ return found;
92
+ }
93
+ /**
94
+ * Names that mean "authenticated" when used inside `file`. Includes helpers
95
+ * defined in the file and symbols imported from first-party modules.
96
+ */
97
+ export function authNamesFor(file, opts, preparsed, depth = 0, stack = new Set()) {
98
+ const memo = opts.cache.get(file);
99
+ if (memo)
100
+ return memo;
101
+ // A cycle (a imports b imports a) resolves to nothing rather than looping.
102
+ if (stack.has(file) || depth > MAX_DEPTH)
103
+ return EMPTY;
104
+ let ast = preparsed ?? null;
105
+ if (!ast) {
106
+ const source = read(file);
107
+ if (source === null)
108
+ return EMPTY;
109
+ // Cheap bail-out: a file that mentions none of the auth vocabulary cannot
110
+ // define an auth helper, and most files in a repo are that file.
111
+ if (!opts.authCalls.some((c) => source.includes(c.split('.').pop()))) {
112
+ opts.cache.set(file, EMPTY);
113
+ return EMPTY;
114
+ }
115
+ ast = parseSource(source, file);
116
+ if (!ast) {
117
+ opts.cache.set(file, EMPTY);
118
+ return EMPTY;
119
+ }
120
+ }
121
+ stack.add(file);
122
+ const credited = new Set();
123
+ for (const stmt of ast.program.body) {
124
+ if (stmt.type !== 'ImportDeclaration')
125
+ continue;
126
+ const spec = stmt.source?.value;
127
+ const target = typeof spec === 'string' ? opts.index.resolve(spec, file) : null;
128
+ if (!target)
129
+ continue;
130
+ const exported = authNamesFor(target, opts, null, depth + 1, stack);
131
+ if (exported.size === 0)
132
+ continue;
133
+ for (const s of stmt.specifiers ?? []) {
134
+ const local = s.local?.name;
135
+ if (!local)
136
+ continue;
137
+ const imported = s.type === 'ImportSpecifier'
138
+ ? (s.imported.name ?? s.imported.value)
139
+ : s.type === 'ImportDefaultSpecifier'
140
+ ? 'default'
141
+ : null;
142
+ if (imported && exported.has(imported))
143
+ credited.add(local);
144
+ }
145
+ }
146
+ const functions = collectFunctions(ast);
147
+ for (let round = 0; round < MAX_ROUNDS; round++) {
148
+ let changed = false;
149
+ for (const [name, calls] of functions) {
150
+ if (credited.has(name))
151
+ continue;
152
+ for (const call of calls) {
153
+ if (matchesAny(call, opts.authCalls) ||
154
+ matchesAny(call, opts.authWrappers) ||
155
+ credited.has(call)) {
156
+ credited.add(name);
157
+ changed = true;
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ if (!changed)
163
+ break;
164
+ }
165
+ stack.delete(file);
166
+ const result = credited;
167
+ opts.cache.set(file, result);
168
+ return result;
169
+ }
@@ -65,12 +65,48 @@ const WITHHELD = new Map([
65
65
  'a packaging lint (missing "files" field), not a security finding, and wrong for apps ' +
66
66
  'rather than published libraries',
67
67
  ],
68
+ [
69
+ 'VG865',
70
+ 'declares itself a `.npmignore` lint but is registered for the `shell` language, so it ' +
71
+ 'matches almost every line of every shell script — 6 hits on two `scripts/*.sh` files, ' +
72
+ 'all false, and it is a packaging lint rather than a security finding either way',
73
+ ],
68
74
  [
69
75
  'VG1004',
70
76
  'matches every `use server` module with an exported function; it asserts "no rate limiting" ' +
71
77
  'without ever checking for it',
72
78
  ],
73
79
  ]);
80
+ /**
81
+ * Machine-generated dependency lockfiles. Their entries describe the *transitive*
82
+ * graph, which nobody in this repo wrote or can edit directly.
83
+ */
84
+ const LOCKFILE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|composer\.lock|Pipfile\.lock|poetry\.lock)$/i;
85
+ /**
86
+ * Rules whose signal only exists in a hand-authored manifest. The same JSON
87
+ * shapes appear all over a lockfile describing packages a dependency chose, so
88
+ * a hit there is both unactionable and, measured on the dogfooding corpus,
89
+ * wrong: `fast-glob`, `fast-deep-equal`, `common-tags`, `core-js` and
90
+ * `simple-statistics` all trip the deceptive-prefix list, and the "wildcard
91
+ * version" rule fires on transitive `"node": ">=16"` engine constraints.
92
+ * Declared package names are covered properly by CTS020-CTS027, which ask the
93
+ * registry rather than matching a prefix.
94
+ */
95
+ const MANIFEST_ONLY = new Map([
96
+ ['VG872', 'internal-name heuristic; in a lockfile it names a transitive dependency'],
97
+ ['VG873', 'deceptive-prefix heuristic; matches ordinary transitive package names'],
98
+ ['VG020', 'wildcard version; a transitive range is the dependency author\'s choice'],
99
+ ]);
100
+ /**
101
+ * 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.
103
+ */
104
+ const MATCH_GUARDS = {
105
+ // A `"link": true` entry is a workspace or pnpm symlink resolved to a path on
106
+ // disk rather than a tarball, so it has no integrity hash by design. Thirty of
107
+ // them in one pnpm-managed lockfile, every one reported as a false critical.
108
+ VG870: (match) => !/"link"\s*:\s*true/.test(match),
109
+ };
74
110
  /** Paths where a match is a fixture or documentation rather than shipped code. */
75
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;
76
112
  /** Regexes over very large files are where catastrophic backtracking bites. */
@@ -100,11 +136,15 @@ export const communityScanner = {
100
136
  continue;
101
137
  const relPath = rel(ctx.root, file);
102
138
  const fixture = NON_PRODUCTION_PATH.test(relPath);
139
+ const lockfile = LOCKFILE.test(relPath);
103
140
  const suppress = new Suppressions(source);
104
141
  filesScanned++;
105
142
  for (const rule of active) {
106
143
  if (!rule.languages.some((l) => languages.includes(l)))
107
144
  continue;
145
+ if (lockfile && MANIFEST_ONLY.has(rule.id))
146
+ continue;
147
+ const guard = MATCH_GUARDS[rule.id];
108
148
  const re = rule.pattern;
109
149
  re.lastIndex = 0;
110
150
  let m;
@@ -115,6 +155,13 @@ export const communityScanner = {
115
155
  re.lastIndex++;
116
156
  continue;
117
157
  }
158
+ // Skipping a match must not skip the non-global `break` below, or a
159
+ // rule without /g would rescan from zero forever.
160
+ if (guard && !guard(m[0])) {
161
+ if (!re.global)
162
+ break;
163
+ continue;
164
+ }
118
165
  const line = lineAt(source, m.index);
119
166
  const key = `${relPath}:${line}:${rule.id}`;
120
167
  if (!seen.has(key) && !suppress.suppressed(line, rule.id)) {
@@ -148,7 +195,8 @@ export const communityScanner = {
148
195
  result.checks.push({
149
196
  label: `Community ruleset (${active.length} rules over ${filesScanned} files)`,
150
197
  passed: result.findings.every((f) => f.severity !== 'critical'),
151
- note: `${SUPERSEDED.size} superseded by ClearToShip's AST checks, ${WITHHELD.size} withheld as noisy`,
198
+ note: `${SUPERSEDED.size} superseded by ClearToShip's AST checks, ${WITHHELD.size} withheld as noisy, ` +
199
+ `${MANIFEST_ONLY.size} manifest-only (not run over lockfiles)`,
152
200
  });
153
201
  return result;
154
202
  },
@@ -1,6 +1,8 @@
1
1
  import { read, rel, isScript, snippetAt } from '../utils/files.js';
2
2
  import { parseSource, calleeName, calleeTail, hasDirective } from '../utils/ast.js';
3
3
  import { traverse } from '../utils/traverse.js';
4
+ import { buildModuleIndex } from '../utils/modules.js';
5
+ import { authNamesFor } from './auth-helpers.js';
4
6
  import { Suppressions } from '../utils/suppress.js';
5
7
  import { emptyResult } from '../types.js';
6
8
  /**
@@ -14,6 +16,11 @@ const AUTH_CALLS = [
14
16
  'getAuth', 'validateRequest', 'verifySession', 'requireUser', 'requireAuth',
15
17
  'requireSession', 'assertAuthenticated', 'getCurrentUser', 'getLoggedInUser',
16
18
  'getToken', 'verifyToken', 'verifyIdToken', 'protect', 'ensureUser',
19
+ // Framework primitives that verify a signed session token rather than read
20
+ // one: Shopify App Bridge's session-token exchange, Shopify Remix's
21
+ // `authenticate.admin(request)`, and jose's JWT verification.
22
+ 'session.decodeSessionToken', 'decodeSessionToken', 'authenticate.admin',
23
+ 'authenticate.public', 'authenticate.flow', 'jwtVerify', 'verifyJWT', 'verifyJwt',
17
24
  ];
18
25
  /** Higher-order wrappers that apply auth (and often validation) for the action. */
19
26
  const AUTH_WRAPPERS = [
@@ -52,17 +59,93 @@ const SIGNATURE_CHECKS = [
52
59
  * "unverified webhook" check.
53
60
  */
54
61
  const SIGNATURE_HEADERS = /(x-shopify-hmac-sha256|x-hub-signature(-256)?|stripe-signature|svix-signature|svix-id|x-signature|x-webhook-signature|x-slack-signature|x-line-signature|paypal-transmission-sig)/i;
62
+ /**
63
+ * Headers that exist to carry a caller credential. Reading one *and* comparing
64
+ * it against a server-side secret is how a cron or machine-to-machine endpoint
65
+ * authenticates — there is no session to look up.
66
+ */
67
+ const CREDENTIAL_HEADERS = /^(authorization|proxy-authorization|x-api-key|x-apikey|api-key|x-auth-token|x-access-token|x-cron-secret|x-webhook-secret|x-admin-key|x-internal-token)$/i;
68
+ /** `process.env.SOMETHING_SECRET` and friends — the other half of that check. */
69
+ const SECRET_ENV = /^process\.env\.[A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD|PASS)[A-Z0-9_]*$/;
70
+ /**
71
+ * Calls that only build the HTTP response. A handler whose body contains
72
+ * nothing else is a static responder — a health check — with no work to trigger.
73
+ */
74
+ const RESPONSE_CALLS = /^(NextResponse\.(json|redirect|next|rewrite)|Response\.(json|redirect|error)|json|res\.(json|send|status))$/;
75
+ /**
76
+ * Reading the request body. Anchored on the receiver so `NextResponse.json(...)`
77
+ * — which writes the response — is not mistaken for reading the request.
78
+ */
79
+ const REQUEST_INPUT_READ = /(^|\.)(req|request|nextRequest|_req|_request)\??\.(json|text|formData|arrayBuffer|blob)$/i;
55
80
  /** Schema escape hatches that make validation decorative. */
56
81
  const LOOSE_SCHEMA = /\.passthrough\s*\(|z\s*\.\s*(any|unknown)\s*\(|\.catchall\s*\(/g;
57
82
  const SERVICE_ROLE_HINTS = [
58
83
  'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_SERVICE_KEY', 'SERVICE_ROLE_KEY',
59
84
  'SUPABASE_SECRET_KEY',
60
85
  ];
61
- /** Identifiers that plausibly carry the authenticated principal's id. */
86
+ /**
87
+ * Dotted expressions that plausibly carry the authenticated principal's id.
88
+ * The principal is not always a person: in a B2B or platform app it is the
89
+ * tenant — a store, org, workspace or team — and a write scoped to it is
90
+ * scoped just as tightly as one scoped to a user id.
91
+ */
62
92
  const OWNER_HINTS = [
63
93
  'user.id', 'user?.id', 'session.user.id', 'userId', 'user_id', 'auth.uid',
64
94
  'currentUser.id', 'ctx.user', 'claims.sub', 'uid',
95
+ 'store.id', 'shop.id', 'org.id', 'organization.id', 'tenant.id', 'workspace.id',
96
+ 'account.id', 'team.id', 'company.id', 'session.shop', 'session.shopDomain',
65
97
  ];
98
+ /** Bare identifiers carrying that same principal. */
99
+ const OWNER_IDENTIFIERS = new Set([
100
+ 'userId', 'user_id', 'ownerId', 'owner_id', 'shop', 'shopDomain', 'shop_domain',
101
+ 'storeId', 'store_id', 'orgId', 'org_id', 'organizationId', 'organization_id',
102
+ 'tenantId', 'tenant_id', 'workspaceId', 'workspace_id', 'accountId', 'account_id',
103
+ 'teamId', 'team_id', 'companyId', 'company_id',
104
+ ]);
105
+ /** Names bound by a function's own parameter list, destructuring included. */
106
+ function parameterNames(params) {
107
+ const names = new Set();
108
+ const visit = (node, depth = 0) => {
109
+ if (!node || depth > 8)
110
+ return;
111
+ switch (node.type) {
112
+ case 'Identifier':
113
+ names.add(node.name);
114
+ return;
115
+ case 'AssignmentPattern':
116
+ visit(node.left, depth + 1);
117
+ return;
118
+ case 'RestElement':
119
+ visit(node.argument, depth + 1);
120
+ return;
121
+ case 'ObjectPattern':
122
+ for (const prop of node.properties ?? []) {
123
+ visit(prop.type === 'ObjectProperty' ? prop.value : prop, depth + 1);
124
+ }
125
+ return;
126
+ case 'ArrayPattern':
127
+ for (const el of node.elements ?? [])
128
+ visit(el, depth + 1);
129
+ return;
130
+ default:
131
+ return;
132
+ }
133
+ };
134
+ for (const p of params ?? [])
135
+ visit(p);
136
+ return names;
137
+ }
138
+ /** Root identifier of `a.b.c` — the binding the expression reads from. */
139
+ function rootObject(node) {
140
+ let cur = node;
141
+ let guard = 0;
142
+ while (cur && guard++ < 16) {
143
+ if (cur.type === 'Identifier')
144
+ return cur.name;
145
+ cur = cur.object ?? cur.expression ?? cur.argument;
146
+ }
147
+ return null;
148
+ }
66
149
  function matchesAny(name, list) {
67
150
  for (const candidate of list) {
68
151
  if (name === candidate || name.endsWith('.' + candidate))
@@ -70,7 +153,9 @@ function matchesAny(name, list) {
70
153
  }
71
154
  return false;
72
155
  }
73
- function analyseFunction(path, name) {
156
+ function analyseFunction(path, name,
157
+ /** Names that stand for an auth check in this file — see ./auth-helpers.ts. */
158
+ credited) {
74
159
  const node = path.node;
75
160
  const info = {
76
161
  name,
@@ -88,10 +173,27 @@ function analyseFunction(path, name) {
88
173
  spreadLine: null,
89
174
  looseSchemaLine: null,
90
175
  readsAuthHeader: false,
176
+ readsCredentialHeader: false,
177
+ readsSecretEnv: false,
178
+ workCalls: 0,
179
+ // A Server Action's arguments *are* its input; a Route Handler has to go
180
+ // and read one, so that is detected below.
181
+ readsRequestInput: false,
91
182
  };
183
+ // An id the caller passed in is not proof of ownership — it is the IDOR.
184
+ // Only a principal resolved inside the function counts as scoping.
185
+ const params = parameterNames(node.params ?? []);
186
+ // `export async function GET(_req, { params })` — the segment values are
187
+ // caller-supplied input just as much as a body is.
188
+ if (params.has('params'))
189
+ info.readsRequestInput = true;
92
190
  const inspect = (inner) => {
93
191
  const full = calleeName(inner.node.callee);
94
192
  const tail = calleeTail(inner.node.callee);
193
+ if (!RESPONSE_CALLS.test(full))
194
+ info.workCalls++;
195
+ if (REQUEST_INPUT_READ.test(full))
196
+ info.readsRequestInput = true;
95
197
  // `supabase.auth.getSession()` reads the cookie without asking the auth
96
198
  // server whether the token is still valid, so it proves nothing on the
97
199
  // server. It must not satisfy the auth check via the generic `getSession`
@@ -103,6 +205,14 @@ function analyseFunction(path, name) {
103
205
  else if (matchesAny(full, AUTH_CALLS)) {
104
206
  info.hasAuth = true;
105
207
  }
208
+ else if (credited.has(full)) {
209
+ // The check lives in a first-party helper this file imports, or one
210
+ // defined above in the same file. Matched on the whole callee name, not
211
+ // its tail: a helper is called by the name this file binds it to, and
212
+ // crediting `crypto.verify()` because some other module exports a
213
+ // `verify` helper would hide a real finding.
214
+ info.hasAuth = true;
215
+ }
106
216
  if (matchesAny(full, AUTH_WRAPPERS))
107
217
  info.hasAuth = true;
108
218
  if (matchesAny(full, SIGNATURE_CHECKS))
@@ -135,8 +245,12 @@ function analyseFunction(path, name) {
135
245
  // Reading a webhook-signature header counts as participating in
136
246
  // verification (see SIGNATURE_HEADERS).
137
247
  for (const arg of inner.node.arguments ?? []) {
138
- if (arg?.type === 'StringLiteral' && SIGNATURE_HEADERS.test(arg.value)) {
248
+ if (arg?.type !== 'StringLiteral')
249
+ continue;
250
+ if (SIGNATURE_HEADERS.test(arg.value))
139
251
  info.hasSignatureCheck = true;
252
+ if (CREDENTIAL_HEADERS.test(arg.value) && /headers\.get$/.test(full)) {
253
+ info.readsCredentialHeader = true;
140
254
  }
141
255
  }
142
256
  // Raw SQL: db.query(`DELETE FROM ...`) / sql`UPDATE ...`
@@ -175,23 +289,36 @@ function analyseFunction(path, name) {
175
289
  const full = calleeName(inner.node);
176
290
  if (/headers\.get$/.test(full) || full.endsWith('CRON_SECRET'))
177
291
  info.readsAuthHeader = true;
292
+ if (SECRET_ENV.test(full))
293
+ info.readsSecretEnv = true;
294
+ // `request.url` / `req.nextUrl` are read to get at the query string.
295
+ if (/(^|\.)searchParams$/.test(full) || /(^|\.)(req|request)\??\.(url|nextUrl)$/i.test(full)) {
296
+ info.readsRequestInput = true;
297
+ }
178
298
  for (const hint of SERVICE_ROLE_HINTS) {
179
299
  if (full.endsWith(hint)) {
180
300
  info.serviceRoleLine ??= inner.node.loc?.start.line ?? info.line;
181
301
  }
182
302
  }
183
- for (const hint of OWNER_HINTS) {
184
- if (full === hint || full.endsWith('.' + hint))
185
- info.ownerScoped = true;
303
+ const root = rootObject(inner.node);
304
+ if (!root || !params.has(root)) {
305
+ for (const hint of OWNER_HINTS) {
306
+ if (full === hint || full.endsWith('.' + hint))
307
+ info.ownerScoped = true;
308
+ }
186
309
  }
187
310
  },
188
311
  Identifier(inner) {
189
312
  if (SERVICE_ROLE_HINTS.includes(inner.node.name)) {
190
313
  info.serviceRoleLine ??= inner.node.loc?.start.line ?? info.line;
191
314
  }
192
- if (inner.node.name === 'userId' || inner.node.name === 'user_id') {
315
+ if (OWNER_IDENTIFIERS.has(inner.node.name) && !params.has(inner.node.name)) {
193
316
  info.ownerScoped = true;
194
317
  }
318
+ // `const { searchParams } = new URL(request.url)` — destructured, so it
319
+ // never appears as a member expression.
320
+ if (inner.node.name === 'searchParams')
321
+ info.readsRequestInput = true;
195
322
  },
196
323
  });
197
324
  // An action wrapped by an auth HOC inherits the check from its wrapper.
@@ -199,7 +326,8 @@ function analyseFunction(path, name) {
199
326
  let hops = 0;
200
327
  while (parent && hops++ < 4) {
201
328
  if (parent.node?.type === 'CallExpression') {
202
- if (matchesAny(calleeName(parent.node.callee), AUTH_WRAPPERS))
329
+ const wrapper = calleeName(parent.node.callee);
330
+ if (matchesAny(wrapper, AUTH_WRAPPERS) || credited.has(wrapper))
203
331
  info.hasAuth = true;
204
332
  if (VALIDATION_CALLS.has(calleeTail(parent.node.callee)))
205
333
  info.hasValidation = true;
@@ -221,6 +349,14 @@ export const serverActionsScanner = {
221
349
  const result = emptyResult();
222
350
  let actionCount = 0;
223
351
  let routeCount = 0;
352
+ // Shared across the whole scan: resolving `@/lib/auth` costs one parse of
353
+ // that helper, however many actions import it.
354
+ const helperOptions = {
355
+ authCalls: AUTH_CALLS,
356
+ authWrappers: AUTH_WRAPPERS,
357
+ index: buildModuleIndex(ctx.files, ctx.root),
358
+ cache: new Map(),
359
+ };
224
360
  for (const file of ctx.files) {
225
361
  if (!isScript(file))
226
362
  continue;
@@ -239,6 +375,7 @@ export const serverActionsScanner = {
239
375
  }
240
376
  const suppress = new Suppressions(source);
241
377
  const programUseServer = moduleUseServer || hasDirective(ast.program, 'use server');
378
+ const credited = authNamesFor(file, helperOptions, ast);
242
379
  const push = (f) => {
243
380
  if (suppress.suppressed(f.line, f.id))
244
381
  return;
@@ -255,7 +392,7 @@ export const serverActionsScanner = {
255
392
  const isRoute = Boolean(httpMethod);
256
393
  if (!isAction && !isRoute)
257
394
  return;
258
- const info = analyseFunction(path, name);
395
+ const info = analyseFunction(path, name, credited);
259
396
  if (isAction)
260
397
  actionCount++;
261
398
  if (isRoute)
@@ -273,14 +410,27 @@ export const serverActionsScanner = {
273
410
  // reported at low rather than as a blocking critical.
274
411
  const PUBLIC_BY_DESIGN_ROUTE = /(^|\/|-)(login|signin|sign-in|register|signup|sign-up|forgot-password|reset-password|verify-email|resend-verification|magic-link|contact|lead|leads|waitlist|subscribe|unsubscribe|newsletter)(\/|-|\.|$)/i;
275
412
  const intentionallyPublic = isRoute && PUBLIC_BY_DESIGN_ROUTE.test(relPath);
276
- if (writes && !info.hasAuth && info.getSessionLine === null) {
413
+ // A machine-to-machine endpoint has no session to look up: it proves the
414
+ // caller by comparing a credential header against a server-side secret.
415
+ // That is an authorization check, so it must not read as a missing one.
416
+ const secretAuth = info.readsCredentialHeader && info.readsSecretEnv;
417
+ // For a webhook, the provider signature *is* the caller's identity.
418
+ // CTS042 below is the rule for a webhook that verifies nothing.
419
+ const verifiedWebhook = isWebhook && info.hasSignatureCheck;
420
+ const authenticated = info.hasAuth || secretAuth || verifiedWebhook;
421
+ if (writes && !authenticated && info.getSessionLine === null) {
277
422
  push({
278
423
  id: 'CTS001',
279
- severity: intentionallyPublic ? 'low' : 'critical',
424
+ severity: intentionallyPublic ? 'low' : info.hasMutation ? 'critical' : 'high',
280
425
  title: intentionallyPublic
281
426
  ? `${kind} is unauthenticated (appears public by design)`
282
427
  : `Missing ${kind} authorization`,
283
- detail: `${kind} \`${name}\` performs a database mutation without verifying the caller. ` +
428
+ detail: `${kind} \`${name}\` ` +
429
+ (info.hasMutation
430
+ ? 'performs a database mutation without verifying the caller. '
431
+ : `accepts ${httpMethod ?? 'POST'} and never verifies the caller. No database write ` +
432
+ 'is visible in the handler itself, so this is judged on the method alone — a ' +
433
+ 'read-only endpoint here is a lower risk than the severity suggests. ') +
284
434
  exposure +
285
435
  (intentionallyPublic
286
436
  ? ' This route name suggests a sign-in / account-recovery or public-intake endpoint, which is unauthenticated by design — confirm it has rate limiting and does not trust caller-supplied identifiers.'
@@ -296,7 +446,11 @@ export const serverActionsScanner = {
296
446
  meta: { action: name, kind },
297
447
  });
298
448
  }
299
- if (info.params > 0 && writes && !info.hasValidation) {
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) {
300
454
  push({
301
455
  id: 'CTS002',
302
456
  severity: 'high',
@@ -383,7 +537,10 @@ export const serverActionsScanner = {
383
537
  meta: { kind, route: relPath },
384
538
  });
385
539
  }
386
- if (isCron && !info.readsAuthHeader && !info.hasAuth) {
540
+ // A cron route's GET health check that returns a constant has nothing to
541
+ // trigger, so "callable by anyone" is not a finding about it.
542
+ const doesWork = info.workCalls > 0 || HTTP_MUTATION_METHODS.has(httpMethod ?? '');
543
+ if (isCron && doesWork && !info.readsAuthHeader && !info.hasAuth && !secretAuth) {
387
544
  push({
388
545
  id: 'CTS046',
389
546
  severity: 'high',
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Resolves an import specifier to a first-party file that is already in the
3
+ * scan set, so a scanner can follow a call into the helper it lands in.
4
+ *
5
+ * Deliberately not a real module resolver: it never reads `tsconfig.json`
6
+ * `paths`, never touches disk beyond the file list it was given, and answers
7
+ * `null` for anything from `node_modules`. Aliases (`@/lib/auth`, `~/server/db`)
8
+ * are matched by path suffix instead, which finds the file whether the alias is
9
+ * rooted at the project or at `src/` — the two shapes that cover essentially
10
+ * every Next.js app — without pretending to know which.
11
+ */
12
+ export interface ModuleIndex {
13
+ /** Absolute path of the file `spec` names when imported from `fromFile`. */
14
+ resolve(spec: string, fromFile: string): string | null;
15
+ }
16
+ export declare function buildModuleIndex(files: string[], root: string): ModuleIndex;
@@ -0,0 +1,67 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { isScript, rel } from './files.js';
3
+ /** Alias prefixes that stand for "somewhere in this project", not a package. */
4
+ const ALIAS = /^(@|~|#|\$)\//;
5
+ function stripExt(path) {
6
+ const slash = path.lastIndexOf('/');
7
+ const dot = path.lastIndexOf('.');
8
+ return dot > slash ? path.slice(0, dot) : path;
9
+ }
10
+ export function buildModuleIndex(files, root) {
11
+ /** Extensionless absolute path -> file. `dir/index.ts` also registers `dir`. */
12
+ const byAbs = new Map();
13
+ /**
14
+ * Every suffix of a file's extensionless project-relative path -> file, for
15
+ * alias lookups. A suffix shared by two files is recorded as ambiguous
16
+ * (`null`) so an alias never resolves to an arbitrary one of them.
17
+ */
18
+ const bySuffix = new Map();
19
+ const addSuffix = (key, file) => {
20
+ const current = bySuffix.get(key);
21
+ if (current === undefined)
22
+ bySuffix.set(key, file);
23
+ else if (current !== file)
24
+ bySuffix.set(key, null);
25
+ };
26
+ for (const file of files) {
27
+ if (!isScript(file))
28
+ continue;
29
+ const noExt = stripExt(file);
30
+ byAbs.set(noExt, file);
31
+ if (/\/index$/.test(noExt))
32
+ byAbs.set(noExt.slice(0, -'/index'.length), file);
33
+ const relNoExt = stripExt(rel(root, file));
34
+ const segments = relNoExt.split('/');
35
+ for (let i = 0; i < segments.length; i++) {
36
+ addSuffix(segments.slice(i).join('/'), file);
37
+ // `lib/auth/index.ts` is what `@/lib/auth` names.
38
+ if (i < segments.length - 1 && segments[segments.length - 1] === 'index') {
39
+ addSuffix(segments.slice(i, segments.length - 1).join('/'), file);
40
+ }
41
+ }
42
+ }
43
+ const fromAbsolute = (base) =>
44
+ // `./auth` and `./auth.js` (the ESM-correct spelling of `auth.ts`) both name
45
+ // the same file, and `./auth` may also be `auth/index.ts`.
46
+ byAbs.get(base) ?? byAbs.get(stripExt(base)) ?? null;
47
+ return {
48
+ resolve(spec, fromFile) {
49
+ if (!spec || spec.startsWith('node:') || spec.startsWith('data:'))
50
+ return null;
51
+ if (spec.startsWith('./') || spec.startsWith('../')) {
52
+ return fromAbsolute(join(dirname(fromFile), spec));
53
+ }
54
+ const aliased = ALIAS.test(spec)
55
+ ? spec.slice(spec.indexOf('/') + 1)
56
+ : // A bare specifier is a package unless it looks like a project path
57
+ // that was written without a leading alias (`src/lib/auth`).
58
+ /^(src|app|lib|server|utils|components)\//.test(spec)
59
+ ? spec
60
+ : null;
61
+ if (!aliased)
62
+ return null;
63
+ const hit = bySuffix.get(stripExt(aliased));
64
+ return hit ?? null;
65
+ },
66
+ };
67
+ }