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.
- 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 +73 -22
- package/SECURITY.md +70 -0
- package/action.yml +2 -2
- 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/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,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
|
-
/**
|
|
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
|
+
}
|