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.
- package/ATTRIBUTION.md +115 -0
- package/LICENSES/gitleaks-MIT.txt +21 -0
- package/LICENSES/guardvibe-Apache-2.0.txt +191 -0
- package/LICENSES/guardvibe-NOTICE.txt +4 -0
- package/README.md +125 -25
- package/SECURITY.md +70 -0
- package/action.yml +80 -37
- package/dist/scanners/auth-helpers.d.ts +16 -0
- package/dist/scanners/auth-helpers.js +169 -0
- package/dist/scanners/community.js +49 -1
- package/dist/scanners/index.d.ts +2 -1
- package/dist/scanners/index.js +3 -1
- package/dist/scanners/logic.d.ts +2 -0
- package/dist/scanners/logic.js +226 -0
- package/dist/scanners/server-actions.js +171 -14
- package/dist/utils/modules.d.ts +16 -0
- package/dist/utils/modules.js +67 -0
- package/examples/security-cli.yml +42 -0
- package/examples/security.yml +7 -1
- package/package.json +4 -4
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}\`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|