cleartoship 0.4.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/LICENSE +21 -0
- package/README.md +264 -0
- package/action.yml +154 -0
- package/dist/banner.d.ts +1 -0
- package/dist/banner.js +14 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +103 -0
- package/dist/data/popular.d.ts +17 -0
- package/dist/data/popular.js +103 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/report.d.ts +15 -0
- package/dist/report.js +317 -0
- package/dist/scan.d.ts +24 -0
- package/dist/scan.js +81 -0
- package/dist/scanners/community.d.ts +2 -0
- package/dist/scanners/community.js +155 -0
- package/dist/scanners/dependencies.d.ts +21 -0
- package/dist/scanners/dependencies.js +549 -0
- package/dist/scanners/index.d.ts +8 -0
- package/dist/scanners/index.js +13 -0
- package/dist/scanners/rls.d.ts +7 -0
- package/dist/scanners/rls.js +531 -0
- package/dist/scanners/secrets.d.ts +2 -0
- package/dist/scanners/secrets.js +439 -0
- package/dist/scanners/server-actions.d.ts +2 -0
- package/dist/scanners/server-actions.js +466 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +10 -0
- package/dist/utils/ast.d.ts +13 -0
- package/dist/utils/ast.js +107 -0
- package/dist/utils/detect.d.ts +2 -0
- package/dist/utils/detect.js +77 -0
- package/dist/utils/entropy.d.ts +7 -0
- package/dist/utils/entropy.js +19 -0
- package/dist/utils/files.d.ts +13 -0
- package/dist/utils/files.js +154 -0
- package/dist/utils/osv.d.ts +26 -0
- package/dist/utils/osv.js +123 -0
- package/dist/utils/registry.d.ts +44 -0
- package/dist/utils/registry.js +195 -0
- package/dist/utils/sql.d.ts +20 -0
- package/dist/utils/sql.js +157 -0
- package/dist/utils/suppress.d.ts +19 -0
- package/dist/utils/suppress.js +56 -0
- package/dist/utils/traverse.d.ts +2 -0
- package/dist/utils/traverse.js +14 -0
- package/dist/vendor/gitleaks/rules.d.ts +13 -0
- package/dist/vendor/gitleaks/rules.js +1571 -0
- package/dist/vendor/guardvibe/index.d.ts +11 -0
- package/dist/vendor/guardvibe/index.js +60 -0
- package/dist/vendor/guardvibe/rules/advanced-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/advanced-security.js +418 -0
- package/dist/vendor/guardvibe/rules/ai-host-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/ai-host-security.js +190 -0
- package/dist/vendor/guardvibe/rules/ai-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/ai-security.js +413 -0
- package/dist/vendor/guardvibe/rules/ai-tool-runtime.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/ai-tool-runtime.js +194 -0
- package/dist/vendor/guardvibe/rules/api-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/api-security.js +154 -0
- package/dist/vendor/guardvibe/rules/auth.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/auth.js +222 -0
- package/dist/vendor/guardvibe/rules/cicd.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/cicd.js +108 -0
- package/dist/vendor/guardvibe/rules/core.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/core.js +484 -0
- package/dist/vendor/guardvibe/rules/cve-versions.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/cve-versions.js +1130 -0
- package/dist/vendor/guardvibe/rules/database.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/database.js +173 -0
- package/dist/vendor/guardvibe/rules/deployment.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/deployment.js +269 -0
- package/dist/vendor/guardvibe/rules/dockerfile.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/dockerfile.js +99 -0
- package/dist/vendor/guardvibe/rules/firebase.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/firebase.js +96 -0
- package/dist/vendor/guardvibe/rules/go.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/go.js +85 -0
- package/dist/vendor/guardvibe/rules/modern-stack.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/modern-stack.js +631 -0
- package/dist/vendor/guardvibe/rules/nextjs.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/nextjs.js +233 -0
- package/dist/vendor/guardvibe/rules/other-services.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/other-services.js +72 -0
- package/dist/vendor/guardvibe/rules/payments.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/payments.js +123 -0
- package/dist/vendor/guardvibe/rules/react-native.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/react-native.js +132 -0
- package/dist/vendor/guardvibe/rules/services.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/services.js +149 -0
- package/dist/vendor/guardvibe/rules/shell.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/shell.js +72 -0
- package/dist/vendor/guardvibe/rules/sql.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/sql.js +60 -0
- package/dist/vendor/guardvibe/rules/supply-chain.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/supply-chain.js +243 -0
- package/dist/vendor/guardvibe/rules/terraform.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/terraform.js +84 -0
- package/dist/vendor/guardvibe/rules/types.d.ts +14 -0
- package/dist/vendor/guardvibe/rules/types.js +11 -0
- package/dist/vendor/guardvibe/rules/web-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/web-security.js +258 -0
- package/examples/security.yml +23 -0
- package/package.json +70 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { read, rel, isScript, snippetAt } from '../utils/files.js';
|
|
2
|
+
import { parseSource, calleeName, calleeTail, hasDirective } 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
|
+
* Calls that prove the caller's identity was checked server-side.
|
|
8
|
+
* Matched on the dotted callee name (suffix match) so `supabase.auth.getUser`,
|
|
9
|
+
* `client.auth.getUser` and a bare `getUser` all hit.
|
|
10
|
+
*/
|
|
11
|
+
const AUTH_CALLS = [
|
|
12
|
+
'auth.getUser', 'auth.getClaims', 'getUser', 'getSession',
|
|
13
|
+
'getServerSession', 'getServerAuthSession', 'currentUser', 'auth', 'clerkClient',
|
|
14
|
+
'getAuth', 'validateRequest', 'verifySession', 'requireUser', 'requireAuth',
|
|
15
|
+
'requireSession', 'assertAuthenticated', 'getCurrentUser', 'getLoggedInUser',
|
|
16
|
+
'getToken', 'verifyToken', 'verifyIdToken', 'protect', 'ensureUser',
|
|
17
|
+
];
|
|
18
|
+
/** Higher-order wrappers that apply auth (and often validation) for the action. */
|
|
19
|
+
const AUTH_WRAPPERS = [
|
|
20
|
+
'withAuth', 'withUser', 'withSession', 'authedProcedure', 'protectedProcedure',
|
|
21
|
+
'authActionClient', 'authenticatedAction', 'actionClient', 'createSafeActionClient',
|
|
22
|
+
'createServerAction', 'safeAction', 'guarded', 'requireAuth', 'withGuard',
|
|
23
|
+
];
|
|
24
|
+
/** Data-writing calls across Supabase, Prisma, Drizzle, Mongoose and raw SQL. */
|
|
25
|
+
const MUTATION_CALLS = new Set([
|
|
26
|
+
'insert', 'update', 'upsert', 'delete', 'create', 'createMany', 'updateMany',
|
|
27
|
+
'deleteMany', 'upsertMany', 'destroy', 'save', 'remove', 'findOneAndUpdate',
|
|
28
|
+
'findOneAndDelete', 'updateOne', 'updateMany', 'deleteOne', 'insertOne',
|
|
29
|
+
'insertMany', 'replaceOne', 'bulkWrite', 'increment', 'decrement',
|
|
30
|
+
]);
|
|
31
|
+
/** Runtime schema validation. Absence of all of these on a parameterised action is a finding. */
|
|
32
|
+
const VALIDATION_CALLS = new Set([
|
|
33
|
+
'parse', 'safeParse', 'parseAsync', 'safeParseAsync', 'validate', 'validateSync',
|
|
34
|
+
'assert', 'check', 'decode', 'is', 'coerce', 'cast', 'schema', 'input', 'with',
|
|
35
|
+
]);
|
|
36
|
+
/**
|
|
37
|
+
* Verification calls that make an inbound webhook trustworthy.
|
|
38
|
+
*/
|
|
39
|
+
const SIGNATURE_CHECKS = [
|
|
40
|
+
'webhooks.constructEvent', 'constructEvent', 'constructEventAsync', 'verifyHeader',
|
|
41
|
+
'verify', 'verifySignature', 'createHmac', 'timingSafeEqual', 'Webhook', 'validateRequest',
|
|
42
|
+
'verifyWebhook', 'verifyWebhookSignature',
|
|
43
|
+
];
|
|
44
|
+
/** Schema escape hatches that make validation decorative. */
|
|
45
|
+
const LOOSE_SCHEMA = /\.passthrough\s*\(|z\s*\.\s*(any|unknown)\s*\(|\.catchall\s*\(/g;
|
|
46
|
+
const SERVICE_ROLE_HINTS = [
|
|
47
|
+
'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_SERVICE_KEY', 'SERVICE_ROLE_KEY',
|
|
48
|
+
'SUPABASE_SECRET_KEY',
|
|
49
|
+
];
|
|
50
|
+
/** Identifiers that plausibly carry the authenticated principal's id. */
|
|
51
|
+
const OWNER_HINTS = [
|
|
52
|
+
'user.id', 'user?.id', 'session.user.id', 'userId', 'user_id', 'auth.uid',
|
|
53
|
+
'currentUser.id', 'ctx.user', 'claims.sub', 'uid',
|
|
54
|
+
];
|
|
55
|
+
function matchesAny(name, list) {
|
|
56
|
+
for (const candidate of list) {
|
|
57
|
+
if (name === candidate || name.endsWith('.' + candidate))
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
function analyseFunction(path, name) {
|
|
63
|
+
const node = path.node;
|
|
64
|
+
const info = {
|
|
65
|
+
name,
|
|
66
|
+
line: node.loc?.start.line ?? 0,
|
|
67
|
+
params: (node.params ?? []).length,
|
|
68
|
+
hasAuth: false,
|
|
69
|
+
hasMutation: false,
|
|
70
|
+
hasRead: false,
|
|
71
|
+
hasValidation: false,
|
|
72
|
+
serviceRoleLine: null,
|
|
73
|
+
ownerScoped: false,
|
|
74
|
+
mutationLine: null,
|
|
75
|
+
getSessionLine: null,
|
|
76
|
+
hasSignatureCheck: false,
|
|
77
|
+
spreadLine: null,
|
|
78
|
+
looseSchemaLine: null,
|
|
79
|
+
readsAuthHeader: false,
|
|
80
|
+
};
|
|
81
|
+
const inspect = (inner) => {
|
|
82
|
+
const full = calleeName(inner.node.callee);
|
|
83
|
+
const tail = calleeTail(inner.node.callee);
|
|
84
|
+
// `supabase.auth.getSession()` reads the cookie without asking the auth
|
|
85
|
+
// server whether the token is still valid, so it proves nothing on the
|
|
86
|
+
// server. It must not satisfy the auth check via the generic `getSession`
|
|
87
|
+
// entry, which exists for hand-rolled helpers.
|
|
88
|
+
const isSupabaseGetSession = /(^|\.)auth\.getSession$/.test(full);
|
|
89
|
+
if (isSupabaseGetSession) {
|
|
90
|
+
info.getSessionLine ??= inner.node.loc?.start.line ?? info.line;
|
|
91
|
+
}
|
|
92
|
+
else if (matchesAny(full, AUTH_CALLS)) {
|
|
93
|
+
info.hasAuth = true;
|
|
94
|
+
}
|
|
95
|
+
if (matchesAny(full, AUTH_WRAPPERS))
|
|
96
|
+
info.hasAuth = true;
|
|
97
|
+
if (matchesAny(full, SIGNATURE_CHECKS))
|
|
98
|
+
info.hasSignatureCheck = true;
|
|
99
|
+
if (VALIDATION_CALLS.has(tail))
|
|
100
|
+
info.hasValidation = true;
|
|
101
|
+
if (MUTATION_CALLS.has(tail)) {
|
|
102
|
+
info.hasMutation = true;
|
|
103
|
+
if (info.mutationLine === null) {
|
|
104
|
+
info.mutationLine = inner.node.loc?.start.line ?? info.line;
|
|
105
|
+
}
|
|
106
|
+
// `.update({ ...body })` writes whatever keys the caller chose to send.
|
|
107
|
+
for (const arg of inner.node.arguments ?? []) {
|
|
108
|
+
const props = arg?.type === 'ObjectExpression' ? arg.properties : null;
|
|
109
|
+
if (!props)
|
|
110
|
+
continue;
|
|
111
|
+
for (const prop of props) {
|
|
112
|
+
if (prop?.type !== 'SpreadElement')
|
|
113
|
+
continue;
|
|
114
|
+
const spreadOf = calleeName(prop.argument);
|
|
115
|
+
if (/^(this|process|env)\b/.test(spreadOf))
|
|
116
|
+
continue;
|
|
117
|
+
info.spreadLine ??= prop.loc?.start.line ?? info.line;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (tail === 'from' || tail === 'select' || tail === 'findMany' || tail === 'findUnique' || tail === 'findFirst') {
|
|
122
|
+
info.hasRead = true;
|
|
123
|
+
}
|
|
124
|
+
// Raw SQL: db.query(`DELETE FROM ...`) / sql`UPDATE ...`
|
|
125
|
+
if (tail === 'query' || tail === 'execute' || tail === 'unsafe' || tail === 'raw') {
|
|
126
|
+
for (const arg of inner.node.arguments ?? []) {
|
|
127
|
+
const text = arg?.type === 'StringLiteral'
|
|
128
|
+
? arg.value
|
|
129
|
+
: arg?.type === 'TemplateLiteral'
|
|
130
|
+
? arg.quasis.map((q) => q.value.raw).join(' ')
|
|
131
|
+
: '';
|
|
132
|
+
if (/\b(insert\s+into|update\s+|delete\s+from|drop\s+|alter\s+)/i.test(text)) {
|
|
133
|
+
info.hasMutation = true;
|
|
134
|
+
if (info.mutationLine === null) {
|
|
135
|
+
info.mutationLine = inner.node.loc?.start.line ?? info.line;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
path.traverse({
|
|
142
|
+
CallExpression: inspect,
|
|
143
|
+
OptionalCallExpression: inspect,
|
|
144
|
+
TaggedTemplateExpression(inner) {
|
|
145
|
+
const tag = calleeName(inner.node.tag);
|
|
146
|
+
if (!/(^|\.)sql$/.test(tag))
|
|
147
|
+
return;
|
|
148
|
+
const text = inner.node.quasi.quasis.map((q) => q.value.raw).join(' ');
|
|
149
|
+
if (/\b(insert\s+into|update\s+|delete\s+from)/i.test(text)) {
|
|
150
|
+
info.hasMutation = true;
|
|
151
|
+
if (info.mutationLine === null) {
|
|
152
|
+
info.mutationLine = inner.node.loc?.start.line ?? info.line;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
MemberExpression(inner) {
|
|
157
|
+
const full = calleeName(inner.node);
|
|
158
|
+
if (/headers\.get$/.test(full) || full.endsWith('CRON_SECRET'))
|
|
159
|
+
info.readsAuthHeader = true;
|
|
160
|
+
for (const hint of SERVICE_ROLE_HINTS) {
|
|
161
|
+
if (full.endsWith(hint)) {
|
|
162
|
+
info.serviceRoleLine ??= inner.node.loc?.start.line ?? info.line;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const hint of OWNER_HINTS) {
|
|
166
|
+
if (full === hint || full.endsWith('.' + hint))
|
|
167
|
+
info.ownerScoped = true;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
Identifier(inner) {
|
|
171
|
+
if (SERVICE_ROLE_HINTS.includes(inner.node.name)) {
|
|
172
|
+
info.serviceRoleLine ??= inner.node.loc?.start.line ?? info.line;
|
|
173
|
+
}
|
|
174
|
+
if (inner.node.name === 'userId' || inner.node.name === 'user_id') {
|
|
175
|
+
info.ownerScoped = true;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
// An action wrapped by an auth HOC inherits the check from its wrapper.
|
|
180
|
+
let parent = path.parentPath;
|
|
181
|
+
let hops = 0;
|
|
182
|
+
while (parent && hops++ < 4) {
|
|
183
|
+
if (parent.node?.type === 'CallExpression') {
|
|
184
|
+
if (matchesAny(calleeName(parent.node.callee), AUTH_WRAPPERS))
|
|
185
|
+
info.hasAuth = true;
|
|
186
|
+
if (VALIDATION_CALLS.has(calleeTail(parent.node.callee)))
|
|
187
|
+
info.hasValidation = true;
|
|
188
|
+
}
|
|
189
|
+
parent = parent.parentPath;
|
|
190
|
+
}
|
|
191
|
+
return info;
|
|
192
|
+
}
|
|
193
|
+
const HTTP_MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
194
|
+
function isRouteHandlerFile(relPath) {
|
|
195
|
+
return /(^|\/)(app|src\/app)\/.*\/route\.(t|j)sx?$/.test(relPath);
|
|
196
|
+
}
|
|
197
|
+
export const serverActionsScanner = {
|
|
198
|
+
name: 'Next.js Server Actions & Route Handlers',
|
|
199
|
+
applies(ctx) {
|
|
200
|
+
return ctx.framework.nextjs !== null || ctx.files.some(isScript);
|
|
201
|
+
},
|
|
202
|
+
async run(ctx) {
|
|
203
|
+
const result = emptyResult();
|
|
204
|
+
let actionCount = 0;
|
|
205
|
+
let routeCount = 0;
|
|
206
|
+
for (const file of ctx.files) {
|
|
207
|
+
if (!isScript(file))
|
|
208
|
+
continue;
|
|
209
|
+
const source = read(file);
|
|
210
|
+
if (source === null)
|
|
211
|
+
continue;
|
|
212
|
+
const relPath = rel(ctx.root, file);
|
|
213
|
+
const routeFile = isRouteHandlerFile(relPath);
|
|
214
|
+
const moduleUseServer = /^\s*(['"])use server\1/m.test(source.slice(0, 400));
|
|
215
|
+
if (!moduleUseServer && !routeFile && !source.includes('use server'))
|
|
216
|
+
continue;
|
|
217
|
+
const ast = parseSource(source, file);
|
|
218
|
+
if (!ast) {
|
|
219
|
+
result.warnings.push(`could not parse ${relPath}`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const suppress = new Suppressions(source);
|
|
223
|
+
const programUseServer = moduleUseServer || hasDirective(ast.program, 'use server');
|
|
224
|
+
const push = (f) => {
|
|
225
|
+
if (suppress.suppressed(f.line, f.id))
|
|
226
|
+
return;
|
|
227
|
+
result.findings.push({
|
|
228
|
+
...f,
|
|
229
|
+
file: relPath,
|
|
230
|
+
snippet: snippetAt(source, f.line),
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
const handle = (path, name, exported, httpMethod) => {
|
|
234
|
+
const node = path.node;
|
|
235
|
+
const inlineUseServer = hasDirective(node, 'use server');
|
|
236
|
+
const isAction = exported && (programUseServer || inlineUseServer);
|
|
237
|
+
const isRoute = Boolean(httpMethod);
|
|
238
|
+
if (!isAction && !isRoute)
|
|
239
|
+
return;
|
|
240
|
+
const info = analyseFunction(path, name);
|
|
241
|
+
if (isAction)
|
|
242
|
+
actionCount++;
|
|
243
|
+
if (isRoute)
|
|
244
|
+
routeCount++;
|
|
245
|
+
const kind = isRoute ? 'Route Handler' : 'Server Action';
|
|
246
|
+
const exposure = isRoute
|
|
247
|
+
? `\`${httpMethod} ${relPath.replace(/^(src\/)?app/, '').replace(/\/route\.[tj]sx?$/, '') || '/'}\` is a public HTTP endpoint.`
|
|
248
|
+
: 'Server Actions compile to public HTTP POST endpoints — anyone can invoke this by ID, the UI is not a gate.';
|
|
249
|
+
const writes = info.hasMutation || (isRoute && HTTP_MUTATION_METHODS.has(httpMethod));
|
|
250
|
+
const isWebhook = isRoute && httpMethod === 'POST' && /webhook|\bhooks?\b|stripe|clerk|svix/i.test(relPath);
|
|
251
|
+
const isCron = isRoute && /(^|\/)(cron|scheduled|jobs?)(\/|$)/i.test(relPath);
|
|
252
|
+
if (writes && !info.hasAuth && info.getSessionLine === null) {
|
|
253
|
+
push({
|
|
254
|
+
id: 'CTS001',
|
|
255
|
+
severity: 'critical',
|
|
256
|
+
title: `Missing ${kind} authorization`,
|
|
257
|
+
detail: `${kind} \`${name}\` performs a database mutation without verifying the caller. ` +
|
|
258
|
+
exposure,
|
|
259
|
+
fix: 'Resolve and check the session before touching the database, e.g.\n' +
|
|
260
|
+
' const { data: { user } } = await supabase.auth.getUser()\n' +
|
|
261
|
+
" if (!user) throw new Error('Unauthorized')\n" +
|
|
262
|
+
'then scope the write to that user. A wrapper such as next-safe-action’s ' +
|
|
263
|
+
'`authActionClient` also satisfies this check.',
|
|
264
|
+
line: info.mutationLine ?? info.line,
|
|
265
|
+
cwe: 'CWE-306: Missing Authentication for Critical Function',
|
|
266
|
+
owasp: 'A01:2025 - Broken Access Control',
|
|
267
|
+
meta: { action: name, kind },
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (info.params > 0 && writes && !info.hasValidation) {
|
|
271
|
+
push({
|
|
272
|
+
id: 'CTS002',
|
|
273
|
+
severity: 'high',
|
|
274
|
+
title: `${kind} input is never validated at runtime`,
|
|
275
|
+
detail: `\`${name}\` accepts caller-supplied arguments and writes them to the database ` +
|
|
276
|
+
'without runtime schema validation. TypeScript types are erased at runtime, so a ' +
|
|
277
|
+
'crafted payload can carry extra fields straight into the write (mass assignment).',
|
|
278
|
+
fix: 'Parse the input before use:\n' +
|
|
279
|
+
" const parsed = MySchema.safeParse(raw)\n" +
|
|
280
|
+
" if (!parsed.success) throw new Error('Invalid input')\n" +
|
|
281
|
+
'and pass `parsed.data` — never the raw argument — to the query.',
|
|
282
|
+
line: info.line,
|
|
283
|
+
cwe: 'CWE-20: Improper Input Validation',
|
|
284
|
+
owasp: 'A05:2025 - Injection',
|
|
285
|
+
meta: { action: name, kind },
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (info.serviceRoleLine !== null) {
|
|
289
|
+
push({
|
|
290
|
+
id: 'CTS003',
|
|
291
|
+
severity: 'critical',
|
|
292
|
+
title: `Service-role key used inside a ${kind}`,
|
|
293
|
+
detail: `\`${name}\` builds a Supabase client with the service-role key. That key bypasses ` +
|
|
294
|
+
'every Row Level Security policy, so any authorization bug in this function exposes ' +
|
|
295
|
+
'the whole table rather than one row.',
|
|
296
|
+
fix: 'Use the request-scoped anon client (`createClient()` from your server helper) so RLS ' +
|
|
297
|
+
'still applies. Reserve the service-role key for trusted background jobs that no user ' +
|
|
298
|
+
'request can reach.',
|
|
299
|
+
line: info.serviceRoleLine,
|
|
300
|
+
cwe: 'CWE-250: Execution with Unnecessary Privileges',
|
|
301
|
+
owasp: 'A01:2025 - Broken Access Control',
|
|
302
|
+
meta: { action: name, kind },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
if (info.getSessionLine !== null && !info.hasAuth) {
|
|
306
|
+
push({
|
|
307
|
+
id: 'CTS041',
|
|
308
|
+
severity: 'high',
|
|
309
|
+
title: `${kind} authenticates with getSession() instead of getUser()`,
|
|
310
|
+
detail: `\`${name}\` calls \`supabase.auth.getSession()\` to decide who the caller is. On the ` +
|
|
311
|
+
'server that only decodes the session cookie — it never asks the auth server whether the ' +
|
|
312
|
+
'token is still valid, so a forged or revoked cookie passes. Supabase documents ' +
|
|
313
|
+
'`getSession()` as safe on the client only.',
|
|
314
|
+
fix: 'Use `const { data: { user } } = await supabase.auth.getUser()` instead, which revalidates ' +
|
|
315
|
+
'the JWT against the auth server, and branch on `user`.',
|
|
316
|
+
line: info.getSessionLine,
|
|
317
|
+
cwe: 'CWE-287: Improper Authentication',
|
|
318
|
+
owasp: 'A07:2025 - Authentication Failures',
|
|
319
|
+
meta: { action: name, kind },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
if (info.spreadLine !== null) {
|
|
323
|
+
push({
|
|
324
|
+
id: 'CTS043',
|
|
325
|
+
severity: 'high',
|
|
326
|
+
title: `${kind} spreads caller-supplied data into a write`,
|
|
327
|
+
detail: `\`${name}\` spreads an object it received into a database write, so every key the ` +
|
|
328
|
+
'caller chose to send is written. Adding `"is_admin": true` or `"credits": 999999` to the ' +
|
|
329
|
+
'request body is enough to set those columns — the classic mass-assignment escalation.',
|
|
330
|
+
fix: 'Write an explicit column list built from validated fields — ' +
|
|
331
|
+
'`{ name: parsed.data.name }` — rather than spreading the request body.',
|
|
332
|
+
line: info.spreadLine,
|
|
333
|
+
cwe: 'CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes',
|
|
334
|
+
owasp: 'A01:2025 - Broken Access Control',
|
|
335
|
+
meta: { action: name, kind },
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (isWebhook && !info.hasSignatureCheck) {
|
|
339
|
+
push({
|
|
340
|
+
id: 'CTS042',
|
|
341
|
+
severity: 'critical',
|
|
342
|
+
title: 'Webhook endpoint does not verify its signature',
|
|
343
|
+
detail: `\`${relPath}\` looks like a webhook receiver but nothing in \`${name}\` verifies the ` +
|
|
344
|
+
'provider signature. Webhook URLs are not secret and the payload is entirely ' +
|
|
345
|
+
'attacker-controlled, so anyone who learns the URL can post a forged event — a fake ' +
|
|
346
|
+
'`checkout.session.completed` grants themselves a paid plan.',
|
|
347
|
+
fix: 'Verify before trusting anything in the body, e.g. ' +
|
|
348
|
+
'`stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET)` ' +
|
|
349
|
+
'for Stripe or `new Webhook(secret).verify(payload, headers)` for Clerk/svix. Read the ' +
|
|
350
|
+
'raw body, not the parsed JSON.',
|
|
351
|
+
line: info.line,
|
|
352
|
+
cwe: 'CWE-345: Insufficient Verification of Data Authenticity',
|
|
353
|
+
owasp: 'A08:2025 - Software & Data Integrity Failures',
|
|
354
|
+
meta: { kind, route: relPath },
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (isCron && !info.readsAuthHeader && !info.hasAuth) {
|
|
358
|
+
push({
|
|
359
|
+
id: 'CTS046',
|
|
360
|
+
severity: 'high',
|
|
361
|
+
title: 'Cron endpoint is callable by anyone',
|
|
362
|
+
detail: `\`${relPath}\` is a scheduled job route, but \`${name}\` checks neither a shared ` +
|
|
363
|
+
'secret nor a session. The path is public HTTP like any other, so anyone can trigger the ' +
|
|
364
|
+
'job — repeatedly, and at a time of their choosing.',
|
|
365
|
+
fix: 'Compare an `Authorization` header against `process.env.CRON_SECRET` and return 401 on ' +
|
|
366
|
+
'mismatch. Vercel Cron sends that header automatically when the variable is set.',
|
|
367
|
+
line: info.line,
|
|
368
|
+
cwe: 'CWE-306: Missing Authentication for Critical Function',
|
|
369
|
+
owasp: 'A01:2025 - Broken Access Control',
|
|
370
|
+
meta: { kind, route: relPath },
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
if (writes && info.hasAuth && !info.ownerScoped && info.params > 0) {
|
|
374
|
+
push({
|
|
375
|
+
id: 'CTS004',
|
|
376
|
+
severity: 'medium',
|
|
377
|
+
title: `${kind} mutation is authenticated but not owner-scoped`,
|
|
378
|
+
detail: `\`${name}\` checks that *someone* is logged in, then mutates a row identified only by ` +
|
|
379
|
+
'a caller-supplied argument. Any logged-in user can pass another tenant’s id (IDOR).',
|
|
380
|
+
fix: 'Constrain the write to the authenticated principal, e.g. ' +
|
|
381
|
+
"`.eq('id', id).eq('user_id', user.id)`, or rely on an RLS policy that compares " +
|
|
382
|
+
'`auth.uid()` against the owning column.',
|
|
383
|
+
line: info.mutationLine ?? info.line,
|
|
384
|
+
cwe: 'CWE-639: Authorization Bypass Through User-Controlled Key',
|
|
385
|
+
owasp: 'A01:2025 - Broken Access Control',
|
|
386
|
+
meta: { action: name, kind },
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
// Validation that opts out of validating. Reported per file, since the
|
|
391
|
+
// schema is usually declared at module scope, away from the action.
|
|
392
|
+
if (programUseServer || routeFile) {
|
|
393
|
+
LOOSE_SCHEMA.lastIndex = 0;
|
|
394
|
+
const loose = LOOSE_SCHEMA.exec(source);
|
|
395
|
+
if (loose) {
|
|
396
|
+
const line = source.slice(0, loose.index).split('\n').length;
|
|
397
|
+
push({
|
|
398
|
+
id: 'CTS044',
|
|
399
|
+
severity: 'medium',
|
|
400
|
+
title: 'Schema opts out of validating',
|
|
401
|
+
detail: `\`${loose[0]}\` in a server-side module means the schema accepts keys it does not ` +
|
|
402
|
+
'declare. Input then passes validation while still carrying whatever extra fields the ' +
|
|
403
|
+
'caller attached, which is the situation the schema was added to prevent.',
|
|
404
|
+
fix: 'Drop `.passthrough()` / `.catchall()` and replace `z.any()` or `z.unknown()` with the ' +
|
|
405
|
+
'shape you actually expect. Zod strips unknown keys by default — that default is the point.',
|
|
406
|
+
line,
|
|
407
|
+
cwe: 'CWE-20: Improper Input Validation',
|
|
408
|
+
owasp: 'A05:2025 - Injection',
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
traverse(ast, {
|
|
413
|
+
ExportNamedDeclaration(path) {
|
|
414
|
+
const decl = path.node.declaration;
|
|
415
|
+
if (!decl)
|
|
416
|
+
return;
|
|
417
|
+
if (decl.type === 'FunctionDeclaration' && decl.id) {
|
|
418
|
+
const name = decl.id.name;
|
|
419
|
+
const method = routeFile && /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/.test(name) ? name : undefined;
|
|
420
|
+
handle(path.get('declaration'), name, true, method);
|
|
421
|
+
}
|
|
422
|
+
else if (decl.type === 'VariableDeclaration') {
|
|
423
|
+
decl.declarations.forEach((d, i) => {
|
|
424
|
+
if (d.id?.type !== 'Identifier')
|
|
425
|
+
return;
|
|
426
|
+
if (d.init?.type !== 'ArrowFunctionExpression' && d.init?.type !== 'FunctionExpression')
|
|
427
|
+
return;
|
|
428
|
+
const name = d.id.name;
|
|
429
|
+
const method = routeFile && /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/.test(name) ? name : undefined;
|
|
430
|
+
// cleartoship-ignore VG010 — Babel's AST accessor, not a SQL call;
|
|
431
|
+
// the rule's verb list includes the generic `get`.
|
|
432
|
+
const initPath = path.get(`declaration.declarations.${i}.init`);
|
|
433
|
+
handle(initPath, name, true, method);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
ExportDefaultDeclaration(path) {
|
|
438
|
+
const decl = path.node.declaration;
|
|
439
|
+
if (decl?.type === 'FunctionDeclaration' || decl?.type === 'ArrowFunctionExpression') {
|
|
440
|
+
handle(path.get('declaration'), decl.id?.name ?? 'default', true);
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (actionCount > 0) {
|
|
446
|
+
result.checks.push({
|
|
447
|
+
label: `Server Action authorization (${actionCount} action${actionCount === 1 ? '' : 's'} analysed)`,
|
|
448
|
+
passed: !result.findings.some((f) => f.id === 'CTS001' || f.id === 'CTS003'),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
if (routeCount > 0) {
|
|
452
|
+
result.checks.push({
|
|
453
|
+
label: `Route Handler authorization (${routeCount} handler${routeCount === 1 ? '' : 's'} analysed)`,
|
|
454
|
+
passed: !result.findings.some((f) => f.id === 'CTS001' && f.meta?.kind === 'Route Handler'),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
if (actionCount === 0 && routeCount === 0) {
|
|
458
|
+
result.checks.push({
|
|
459
|
+
label: 'Server Action authorization',
|
|
460
|
+
passed: true,
|
|
461
|
+
note: 'no Server Actions or Route Handlers found',
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
465
|
+
},
|
|
466
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info';
|
|
2
|
+
export declare const SEVERITY_ORDER: Record<Severity, number>;
|
|
3
|
+
/** A single security problem found in the project. */
|
|
4
|
+
export interface Finding {
|
|
5
|
+
/** Stable rule id, e.g. CTS001. Used for --ignore and for suppression comments. */
|
|
6
|
+
id: string;
|
|
7
|
+
severity: Severity;
|
|
8
|
+
/** Short headline, e.g. "Missing Server Action authorization". */
|
|
9
|
+
title: string;
|
|
10
|
+
/** What is wrong here specifically, in one or two sentences. */
|
|
11
|
+
detail: string;
|
|
12
|
+
/** What to actually do about it. May be multi-line. */
|
|
13
|
+
fix: string;
|
|
14
|
+
/** Path relative to the scan root. Absent for project-wide findings. */
|
|
15
|
+
file?: string;
|
|
16
|
+
line?: number;
|
|
17
|
+
column?: number;
|
|
18
|
+
/** The offending source line, trimmed, for terminal context. */
|
|
19
|
+
snippet?: string;
|
|
20
|
+
cwe?: string;
|
|
21
|
+
owasp?: string;
|
|
22
|
+
/** Extra machine-readable context (package name, table name, ...). */
|
|
23
|
+
meta?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
export interface CheckSummary {
|
|
26
|
+
/** Scanner-level line for the "✔ PASS" rows, e.g. "42 packages verified on npm". */
|
|
27
|
+
label: string;
|
|
28
|
+
passed: boolean;
|
|
29
|
+
note?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface ScanResult {
|
|
32
|
+
findings: Finding[];
|
|
33
|
+
checks: CheckSummary[];
|
|
34
|
+
/** Non-fatal problems: unreadable file, registry offline, etc. */
|
|
35
|
+
warnings: string[];
|
|
36
|
+
}
|
|
37
|
+
export interface ProjectContext {
|
|
38
|
+
root: string;
|
|
39
|
+
/** Files to analyse, absolute paths, already filtered by ignore rules. */
|
|
40
|
+
files: string[];
|
|
41
|
+
framework: FrameworkInfo;
|
|
42
|
+
offline: boolean;
|
|
43
|
+
/** Absolute path of the on-disk registry cache directory. */
|
|
44
|
+
cacheDir: string;
|
|
45
|
+
verbose: boolean;
|
|
46
|
+
}
|
|
47
|
+
export interface FrameworkInfo {
|
|
48
|
+
nextjs: 'app-router' | 'pages-router' | 'both' | null;
|
|
49
|
+
supabase: boolean;
|
|
50
|
+
prisma: boolean;
|
|
51
|
+
drizzle: boolean;
|
|
52
|
+
clerk: boolean;
|
|
53
|
+
nextAuth: boolean;
|
|
54
|
+
stripe: boolean;
|
|
55
|
+
python: boolean;
|
|
56
|
+
/** Human-readable one-liner for the CLI header. */
|
|
57
|
+
describe(): string;
|
|
58
|
+
}
|
|
59
|
+
export interface Scanner {
|
|
60
|
+
name: string;
|
|
61
|
+
/** Skip the scanner entirely when it has nothing to look at. */
|
|
62
|
+
applies(ctx: ProjectContext): boolean;
|
|
63
|
+
run(ctx: ProjectContext): Promise<ScanResult>;
|
|
64
|
+
}
|
|
65
|
+
export declare function emptyResult(): ScanResult;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { File } from '@babel/types';
|
|
2
|
+
/**
|
|
3
|
+
* Parses TS/TSX/JS permissively. Vibe-coded repos routinely contain
|
|
4
|
+
* decorators, JSX in .ts files and other syntax soup, so every plugin that
|
|
5
|
+
* cannot conflict is enabled and errors are recovered from rather than thrown.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseSource(code: string, filename: string): File | null;
|
|
8
|
+
/** Dotted name for a callee expression: `supabase.auth.getUser` -> that string. */
|
|
9
|
+
export declare function calleeName(node: any): string;
|
|
10
|
+
/** Last segment of a dotted callee name. */
|
|
11
|
+
export declare function calleeTail(node: any): string;
|
|
12
|
+
/** True when the directive list of a function or program contains `use server`. */
|
|
13
|
+
export declare function hasDirective(node: any, directive: string): boolean;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
/**
|
|
3
|
+
* Parses TS/TSX/JS permissively. Vibe-coded repos routinely contain
|
|
4
|
+
* decorators, JSX in .ts files and other syntax soup, so every plugin that
|
|
5
|
+
* cannot conflict is enabled and errors are recovered from rather than thrown.
|
|
6
|
+
*/
|
|
7
|
+
export function parseSource(code, filename) {
|
|
8
|
+
const isTs = /\.(ts|tsx|mts|cts)$/.test(filename);
|
|
9
|
+
const isTsx = /\.tsx$/.test(filename);
|
|
10
|
+
const plugins = [
|
|
11
|
+
'jsx',
|
|
12
|
+
'decorators-legacy',
|
|
13
|
+
'classProperties',
|
|
14
|
+
'classPrivateProperties',
|
|
15
|
+
'classPrivateMethods',
|
|
16
|
+
'dynamicImport',
|
|
17
|
+
'topLevelAwait',
|
|
18
|
+
'importAssertions',
|
|
19
|
+
'explicitResourceManagement',
|
|
20
|
+
];
|
|
21
|
+
if (isTs || isTsx)
|
|
22
|
+
plugins.push('typescript');
|
|
23
|
+
else
|
|
24
|
+
plugins.push('flow');
|
|
25
|
+
try {
|
|
26
|
+
return parse(code, {
|
|
27
|
+
sourceType: 'unambiguous',
|
|
28
|
+
allowReturnOutsideFunction: true,
|
|
29
|
+
allowAwaitOutsideFunction: true,
|
|
30
|
+
allowSuperOutsideMethod: true,
|
|
31
|
+
allowUndeclaredExports: true,
|
|
32
|
+
errorRecovery: true,
|
|
33
|
+
plugins,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Dotted name for a callee expression: `supabase.auth.getUser` -> that string. */
|
|
41
|
+
export function calleeName(node) {
|
|
42
|
+
const parts = [];
|
|
43
|
+
let cur = node;
|
|
44
|
+
let guard = 0;
|
|
45
|
+
while (cur && guard++ < 24) {
|
|
46
|
+
if (cur.type === 'Identifier') {
|
|
47
|
+
parts.unshift(cur.name);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
if (cur.type === 'ThisExpression') {
|
|
51
|
+
parts.unshift('this');
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
if (cur.type === 'MemberExpression') {
|
|
55
|
+
if (cur.property?.type === 'Identifier' && !cur.computed) {
|
|
56
|
+
parts.unshift(cur.property.name);
|
|
57
|
+
}
|
|
58
|
+
else if (cur.property?.type === 'StringLiteral') {
|
|
59
|
+
parts.unshift(cur.property.value);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
parts.unshift('*');
|
|
63
|
+
}
|
|
64
|
+
cur = cur.object;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (cur.type === 'CallExpression' || cur.type === 'OptionalCallExpression') {
|
|
68
|
+
cur = cur.callee;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (cur.type === 'TSNonNullExpression' || cur.type === 'AwaitExpression') {
|
|
72
|
+
cur = cur.expression ?? cur.argument;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
return parts.join('.');
|
|
78
|
+
}
|
|
79
|
+
/** Last segment of a dotted callee name. */
|
|
80
|
+
export function calleeTail(node) {
|
|
81
|
+
const full = calleeName(node);
|
|
82
|
+
const i = full.lastIndexOf('.');
|
|
83
|
+
return i === -1 ? full : full.slice(i + 1);
|
|
84
|
+
}
|
|
85
|
+
/** True when the directive list of a function or program contains `use server`. */
|
|
86
|
+
export function hasDirective(node, directive) {
|
|
87
|
+
const body = node?.body?.type === 'BlockStatement' ? node.body : node;
|
|
88
|
+
const directives = body?.directives;
|
|
89
|
+
if (Array.isArray(directives)) {
|
|
90
|
+
for (const d of directives) {
|
|
91
|
+
if (d?.value?.value === directive)
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Some parses keep the directive as a plain expression statement.
|
|
96
|
+
const stmts = body?.body;
|
|
97
|
+
if (Array.isArray(stmts)) {
|
|
98
|
+
for (const s of stmts.slice(0, 3)) {
|
|
99
|
+
if (s?.type === 'ExpressionStatement' &&
|
|
100
|
+
s.expression?.type === 'StringLiteral' &&
|
|
101
|
+
s.expression.value === directive) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|