cleartoship 0.7.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,226 @@
1
+ import { read, rel, isScript, snippetAt } from '../utils/files.js';
2
+ import { parseSource, calleeName, calleeTail } from '../utils/ast.js';
3
+ import { traverse } from '../utils/traverse.js';
4
+ import { Suppressions } from '../utils/suppress.js';
5
+ import { emptyResult } from '../types.js';
6
+ /**
7
+ * Cross-cutting logic checks for OWASP categories a pattern scanner can only
8
+ * touch in specific, high-signal slices:
9
+ * A09 (Logging Failures) — secrets/PII written to logs
10
+ * A10 (Exceptional Conditions)— fail-open / swallowed errors on a security path
11
+ * A08 (Integrity Failures) — insecure deserialization of untrusted data
12
+ *
13
+ * Each rule is deliberately narrow: the goal is a true positive a developer will
14
+ * act on, not coverage for its own sake.
15
+ */
16
+ const LOG_METHODS = new Set(['log', 'info', 'warn', 'error', 'debug', 'trace', 'fatal', 'verbose']);
17
+ const LOG_OBJECTS = /^(console|logger|log|pino|winston|fastify\.log|req\.log|ctx\.log|this\.logger)$/i;
18
+ /** A property/identifier name that names a real secret or piece of PII. */
19
+ const SENSITIVE_NAME = /(?:^|[._])(password|passwd|pwd|secret|secrets|token|tokens|apikey|api_key|authorization|auth_?token|accesstoken|access_token|refreshtoken|refresh_token|sessiontoken|session_token|privatekey|private_key|clientsecret|client_secret|creditcard|card_?number|cardnumber|cvv|cvc|ssn|social_?security|passport|jwt)(?:$|[._])/i;
20
+ /** Reading a whole request/response body into a log dumps everything in it. */
21
+ const BODY_EXPR = /(?:^|\.)(body|rawBody|payload)$/;
22
+ /** Deserializers that can instantiate objects or run code from their input. */
23
+ const UNSAFE_DESERIALIZE = [
24
+ 'unserialize', // node-serialize / serialize-to-js — RCE on crafted input
25
+ 'deserialize',
26
+ 'funcster.deepDeserialize', // cleartoship-ignore VG070 — rule-name data, not a call
27
+ 'pickle.loads', // python
28
+ 'pickle.load',
29
+ 'cPickle.loads',
30
+ 'yaml.unsafe_load', // python
31
+ ];
32
+ /** Function names that signal the body is making a security decision. */
33
+ const SECURITY_FN = /(verify|validate|authenticate|authorize|auth|check(?:auth|access|permission)?|hasaccess|haspermission|isallowed|isauthorized|canaccess|ensure|guard|require(?:auth|user|admin)?)/i;
34
+ /** Values a security check must never return from a swallowed error (fail-open). */
35
+ function isPermissiveReturn(node) {
36
+ if (!node)
37
+ return false;
38
+ if (node.type === 'BooleanLiteral' && node.value === true)
39
+ return true;
40
+ if (node.type === 'Identifier' && /^(user|session|account|token|claims)$/i.test(node.name))
41
+ return true;
42
+ if (node.type === 'ObjectExpression') {
43
+ return node.properties.some((p) => p?.key &&
44
+ /^(authorized|authenticated|valid|allowed|ok|success)$/i.test(p.key.name ?? p.key.value) &&
45
+ p.value?.value === true);
46
+ }
47
+ if (node.type === 'CallExpression') {
48
+ // next() with no error argument lets the request continue.
49
+ return calleeTail(node.callee) === 'next' && (node.arguments ?? []).length === 0;
50
+ }
51
+ return false;
52
+ }
53
+ export const logicScanner = {
54
+ name: 'Logging, exception-handling & deserialization',
55
+ applies(ctx) {
56
+ return ctx.files.some((f) => isScript(f) || f.endsWith('.py'));
57
+ },
58
+ async run(ctx) {
59
+ const result = emptyResult();
60
+ let analysed = 0;
61
+ for (const file of ctx.files) {
62
+ const script = isScript(file);
63
+ const python = file.endsWith('.py');
64
+ if (!script && !python)
65
+ continue;
66
+ const source = read(file);
67
+ if (source === null)
68
+ continue;
69
+ const relPath = rel(ctx.root, file);
70
+ const suppress = new Suppressions(source);
71
+ const push = (f) => {
72
+ if (suppress.suppressed(f.line, f.id))
73
+ return;
74
+ result.findings.push({ ...f, file: relPath, snippet: snippetAt(source, f.line) });
75
+ };
76
+ // Python is regex-only (no JS AST). Cover its highest-signal cases.
77
+ if (python) {
78
+ analysed++;
79
+ source.split('\n').forEach((lineText, i) => {
80
+ const line = i + 1;
81
+ if (/\b(pickle|cPickle)\.loads?\s*\(/.test(lineText) || /\byaml\.load\s*\((?![^)]*Loader\s*=\s*yaml\.SafeLoader)/.test(lineText)) {
82
+ push({
83
+ id: 'CTS072',
84
+ severity: 'high',
85
+ title: 'Insecure deserialization of untrusted data',
86
+ detail: 'This call deserializes input with a loader that can construct arbitrary objects — ' +
87
+ '`pickle` and unqualified `yaml.load` both execute code embedded in a crafted payload.',
88
+ fix: 'Use `yaml.safe_load` (or `Loader=yaml.SafeLoader`); never `pickle.loads` on data that crossed a trust boundary. Prefer JSON for untrusted input.',
89
+ line,
90
+ cwe: 'CWE-502: Deserialization of Untrusted Data',
91
+ owasp: 'A08:2025 - Software & Data Integrity Failures',
92
+ });
93
+ }
94
+ if (/\b(logging|logger|log|print)\b[^\n]*\b(password|secret|token|api_?key|authorization)\b/i.test(lineText) && !/["'][^"']*(password|secret|token)[^"']*["']\s*[,)]/i.test(lineText)) {
95
+ // heuristic: a sensitive identifier appears as a logged value, not just in a message string
96
+ }
97
+ });
98
+ continue;
99
+ }
100
+ const ast = parseSource(source, file);
101
+ if (!ast)
102
+ continue;
103
+ analysed++;
104
+ traverse(ast, {
105
+ CallExpression(path) {
106
+ const node = path.node;
107
+ const tail = calleeTail(node.callee);
108
+ const full = calleeName(node.callee);
109
+ // --- A09: secrets / PII written to a log ---
110
+ const base = full.slice(0, full.lastIndexOf('.'));
111
+ const isLog = LOG_METHODS.has(tail) && (LOG_OBJECTS.test(base) || base === '' || /log$/i.test(base));
112
+ if (isLog) {
113
+ for (const arg of node.arguments ?? []) {
114
+ // Only value expressions count — a string message that merely
115
+ // contains the word "password" is not logging a password.
116
+ if (arg.type === 'StringLiteral' || arg.type === 'TemplateLiteral')
117
+ continue;
118
+ const name = arg.type === 'MemberExpression' || arg.type === 'Identifier' ? calleeName(arg) : '';
119
+ const sensitive = name && SENSITIVE_NAME.test(name);
120
+ const wholeBody = name && BODY_EXPR.test(name);
121
+ if (sensitive || wholeBody) {
122
+ push({
123
+ id: 'CTS070',
124
+ severity: wholeBody && !sensitive ? 'medium' : 'high',
125
+ title: wholeBody && !sensitive ? 'Request body written to logs' : 'Secret or PII written to logs',
126
+ detail: (wholeBody && !sensitive
127
+ ? `\`${name}\` is logged in full, so whatever a caller put in the request body — ` +
128
+ 'passwords, tokens, personal data — lands in your log store and anywhere it is shipped.'
129
+ : `\`${name}\` is written to a log. Logs are retained, replicated to aggregators and ` +
130
+ 'often world-readable to a whole team; a credential or piece of PII there is a ' +
131
+ 'leak that outlives the request.'),
132
+ fix: 'Do not log the raw value. Log a non-reversible reference (a user id, a hash prefix) ' +
133
+ 'or redact the field before logging.',
134
+ line: node.loc?.start.line ?? 0,
135
+ cwe: 'CWE-532: Insertion of Sensitive Information into Log File',
136
+ owasp: 'A09:2025 - Security Logging & Alerting Failures',
137
+ meta: { logged: name },
138
+ });
139
+ break;
140
+ }
141
+ }
142
+ }
143
+ // --- A08: insecure deserialization ---
144
+ if (UNSAFE_DESERIALIZE.some((d) => full === d || full.endsWith('.' + d) || tail === d)) {
145
+ const arg = (node.arguments ?? [])[0];
146
+ // A string literal is a fixed, trusted payload; only flag dynamic input.
147
+ if (arg && arg.type !== 'StringLiteral') {
148
+ push({
149
+ id: 'CTS072',
150
+ severity: 'high',
151
+ title: 'Insecure deserialization of untrusted data',
152
+ detail: `\`${full}(...)\` reconstructs objects from its input. Deserializers of this kind can ` +
153
+ 'instantiate arbitrary types and invoke their side effects, so a crafted payload is ' +
154
+ 'remote code execution — this is why `JSON.parse` is safe and these are not.',
155
+ fix: 'Deserialize untrusted data with `JSON.parse` only. If you need richer types, validate ' +
156
+ 'the parsed shape with a schema; never hand attacker-controlled bytes to an object ' +
157
+ 'deserializer.',
158
+ line: node.loc?.start.line ?? 0,
159
+ cwe: 'CWE-502: Deserialization of Untrusted Data',
160
+ owasp: 'A08:2025 - Software & Data Integrity Failures',
161
+ });
162
+ }
163
+ }
164
+ },
165
+ // --- A10: fail-open / swallowed error on a security path ---
166
+ CatchClause(path) {
167
+ const node = path.node;
168
+ const body = node.body?.body ?? [];
169
+ // Name of the nearest enclosing function, to tell whether this catch
170
+ // guards a security decision.
171
+ let fnName = '';
172
+ let p = path.parentPath;
173
+ let hops = 0;
174
+ while (p && hops++ < 8) {
175
+ const n = p.node;
176
+ if (n?.type === 'FunctionDeclaration' || n?.type === 'FunctionExpression') {
177
+ fnName = n.id?.name ?? '';
178
+ break;
179
+ }
180
+ if (n?.type === 'ArrowFunctionExpression' || n?.type === 'VariableDeclarator') {
181
+ fnName = n.id?.name ?? fnName;
182
+ }
183
+ p = p.parentPath;
184
+ }
185
+ const securityContext = SENSITIVE_NAME.test(fnName) || SECURITY_FN.test(fnName);
186
+ if (!securityContext)
187
+ return;
188
+ const returns = body.filter((s) => s.type === 'ReturnStatement');
189
+ const permissive = returns.some((r) => isPermissiveReturn(r.argument));
190
+ const empty = body.length === 0;
191
+ const swallows = empty ||
192
+ body.every((s) => s.type === 'ReturnStatement' ||
193
+ (s.type === 'ExpressionStatement' &&
194
+ /console|log/i.test(calleeName(s.expression?.callee ?? {}))));
195
+ if (permissive || (empty && securityContext) || (swallows && permissive)) {
196
+ push({
197
+ id: 'CTS071',
198
+ severity: permissive ? 'high' : 'medium',
199
+ title: permissive
200
+ ? 'Security check fails open on error'
201
+ : 'Security check swallows its error',
202
+ detail: `The catch block in \`${fnName || 'this function'}\` ` +
203
+ (permissive
204
+ ? 'returns a permissive value (authorized / a user / next()) when the guarded ' +
205
+ 'operation throws. An attacker who can make the check error — a malformed token, a ' +
206
+ 'timeout — is then let straight through.'
207
+ : 'discards the error from a security-relevant operation. A verification that throws ' +
208
+ 'is a failure, not a pass; swallowing it hides the failure and risks failing open.'),
209
+ fix: 'Fail closed: on error, deny — `return false` / throw / respond 401 — and log the error. ' +
210
+ 'Never return a success value from the catch of a security check.',
211
+ line: node.loc?.start.line ?? 0,
212
+ cwe: 'CWE-703: Improper Check or Handling of Exceptional Conditions',
213
+ owasp: 'A10:2025 - Mishandling of Exceptional Conditions',
214
+ meta: { function: fnName },
215
+ });
216
+ }
217
+ },
218
+ });
219
+ }
220
+ result.checks.push({
221
+ label: `Logging, error-handling & deserialization (${analysed} file${analysed === 1 ? '' : 's'})`,
222
+ passed: !result.findings.some((f) => f.severity === 'critical' || f.severity === 'high'),
223
+ });
224
+ return result;
225
+ },
226
+ };
@@ -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
+ }