auditai-scan 0.6.0 → 0.6.1
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/dist/auditai-scan.mjs +148 -12
- package/package.json +1 -1
package/dist/auditai-scan.mjs
CHANGED
|
@@ -959,8 +959,23 @@ function secretScopeFor(fn, sf) {
|
|
|
959
959
|
growNames(ownDeclarations(fn), scope);
|
|
960
960
|
return scope;
|
|
961
961
|
}
|
|
962
|
+
var STORED_SECRET_FIELD = /(^|_)(secret|signing_key|api_key|key_hash|token_hash|hmac_key)$/;
|
|
963
|
+
function readsStoredSecret(e) {
|
|
964
|
+
let hit = false;
|
|
965
|
+
const visit = (n) => {
|
|
966
|
+
if (hit) return;
|
|
967
|
+
if (ts3.isPropertyAccessExpression(n) && STORED_SECRET_FIELD.test(n.name.text.toLowerCase())) {
|
|
968
|
+
hit = true;
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
n.forEachChild(visit);
|
|
972
|
+
};
|
|
973
|
+
visit(e);
|
|
974
|
+
return hit;
|
|
975
|
+
}
|
|
962
976
|
function isSecretish(e, scope) {
|
|
963
977
|
if (envNamesIn(e).some(isSecretEnvName)) return true;
|
|
978
|
+
if (readsStoredSecret(e)) return true;
|
|
964
979
|
for (const id of identifiersIn(e)) if (scope.names.has(id)) return true;
|
|
965
980
|
let calls = false;
|
|
966
981
|
const visit = (n) => {
|
|
@@ -984,7 +999,7 @@ var EQUALITY = /* @__PURE__ */ new Set([
|
|
|
984
999
|
ts3.SyntaxKind.EqualsEqualsToken,
|
|
985
1000
|
ts3.SyntaxKind.ExclamationEqualsToken
|
|
986
1001
|
]);
|
|
987
|
-
var COMPARE_CALLEE = /^(timingSafeEqual|safeCompare|secureCompare|safeEqual|constantTimeEqual|constantTimeCompare|timingSafeCompare|compare|compareSync|isEqual|equals
|
|
1002
|
+
var COMPARE_CALLEE = /^(timingSafeEqual|safeCompare|secureCompare|safeEqual|constantTimeEqual|constantTimeCompare|timingSafeCompare|compare|compareSync|isEqual|equals?|secretsMatch|secretMatch|matchesSecret|tokensMatch|tokenMatches|sameSecret|checkSecret|validSecret|isValidSecret)$/i;
|
|
988
1003
|
var VERIFY_CALLEE = /^(verify|verifySync|jwtVerify|constructEvent|constructEventAsync|verifySignature|verifyWebhook|validateSignature)$/i;
|
|
989
1004
|
var THROWING_VERIFIER = /^(jwtVerify|constructEvent|constructEventAsync)$/;
|
|
990
1005
|
var THROWING_VERIFY_RECEIVER = /jwt|jose|jsonwebtoken/i;
|
|
@@ -1035,11 +1050,39 @@ function gates(node, fnBody) {
|
|
|
1035
1050
|
}
|
|
1036
1051
|
return false;
|
|
1037
1052
|
}
|
|
1053
|
+
function receiverSecret(callee, secretish, builtFromSecret) {
|
|
1054
|
+
if (!ts3.isPropertyAccessExpression(callee)) return false;
|
|
1055
|
+
const recv = callee.expression;
|
|
1056
|
+
if (ts3.isNewExpression(recv)) return (recv.arguments ?? []).some(secretish);
|
|
1057
|
+
if (ts3.isIdentifier(recv) && builtFromSecret.has(recv.text)) return true;
|
|
1058
|
+
return secretish(recv);
|
|
1059
|
+
}
|
|
1060
|
+
function instancesBuiltFromSecret(body, secretish) {
|
|
1061
|
+
const out = /* @__PURE__ */ new Set();
|
|
1062
|
+
for (const d of collect(body, ts3.isVariableDeclaration)) {
|
|
1063
|
+
if (!d.initializer || !ts3.isIdentifier(d.name) || !ts3.isNewExpression(d.initializer)) continue;
|
|
1064
|
+
if ((d.initializer.arguments ?? []).some(secretish)) out.add(d.name.text);
|
|
1065
|
+
}
|
|
1066
|
+
return out;
|
|
1067
|
+
}
|
|
1068
|
+
function gatesByThrow(node, fnBody) {
|
|
1069
|
+
let child = node;
|
|
1070
|
+
let cur = node.parent;
|
|
1071
|
+
while (cur && cur !== fnBody && !isFunctionLikeNode(cur)) {
|
|
1072
|
+
if (ts3.isTryStatement(cur) && cur.tryBlock === child && cur.catchClause) {
|
|
1073
|
+
return exitKind(cur.catchClause.block) !== null;
|
|
1074
|
+
}
|
|
1075
|
+
child = cur;
|
|
1076
|
+
cur = cur.parent;
|
|
1077
|
+
}
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1038
1080
|
function secretChecksIn(fn, sf) {
|
|
1039
1081
|
if (!fn.body) return [];
|
|
1040
1082
|
const body = fn.body;
|
|
1041
1083
|
const secrets = secretScopeFor(fn, sf);
|
|
1042
1084
|
const secretish = (e) => isSecretish(e, secrets);
|
|
1085
|
+
const builtFromSecret = instancesBuiltFromSecret(body, secretish);
|
|
1043
1086
|
const out = [];
|
|
1044
1087
|
const push = (n) => {
|
|
1045
1088
|
out.push({ node: n, text: n.getText(sf).replace(/\s+/g, " ").slice(0, 160) });
|
|
@@ -1058,8 +1101,9 @@ function secretChecksIn(fn, sf) {
|
|
|
1058
1101
|
const verify = VERIFY_CALLEE.test(name);
|
|
1059
1102
|
if (!compare && !verify) return;
|
|
1060
1103
|
const secretArgs = n.arguments.filter(secretish).length;
|
|
1061
|
-
|
|
1062
|
-
if (
|
|
1104
|
+
const receiverHoldsSecret = verify && receiverSecret(n.expression, secretish, builtFromSecret);
|
|
1105
|
+
if (!receiverHoldsSecret && (secretArgs === 0 || secretArgs === n.arguments.length)) return;
|
|
1106
|
+
if (verify && throwsOnBadCredential(n) || gates(n, body) || gatesByThrow(n, body)) push(n);
|
|
1063
1107
|
});
|
|
1064
1108
|
return out;
|
|
1065
1109
|
}
|
|
@@ -1086,7 +1130,7 @@ function nextAuthSessionNames(stmt, nextAuthLocal) {
|
|
|
1086
1130
|
}
|
|
1087
1131
|
return out;
|
|
1088
1132
|
}
|
|
1089
|
-
var CREDENTIAL_COLUMN = /(keyhash|tokenhash|hashedkey|hashedtoken|secrethash|apikey|apikeyhash|apitoken|accesstoken|sessiontoken|secret)$/;
|
|
1133
|
+
var CREDENTIAL_COLUMN = /(keyhash|tokenhash|hashedkey|hashedtoken|secrethash|apikey|apikeyhash|apitoken|accesstoken|sessiontoken|secret|token|sessioncode|invitecode|accesscode|sharecode)$/;
|
|
1090
1134
|
function isCredentialColumn(column) {
|
|
1091
1135
|
return column !== null && CREDENTIAL_COLUMN.test(column.toLowerCase().replace(/_/g, ""));
|
|
1092
1136
|
}
|
|
@@ -3838,10 +3882,20 @@ var LOGICAL = /* @__PURE__ */ new Set([
|
|
|
3838
3882
|
ts10.SyntaxKind.BarBarToken,
|
|
3839
3883
|
ts10.SyntaxKind.AmpersandAmpersandToken
|
|
3840
3884
|
]);
|
|
3885
|
+
var SCHEMA_PARSE = /^(parse|safeParse|parseAsync|safeParseAsync)$/;
|
|
3886
|
+
function isSchemaParse(call, cx) {
|
|
3887
|
+
const callee = call.expression;
|
|
3888
|
+
if (!ts10.isPropertyAccessExpression(callee) || !SCHEMA_PARSE.test(callee.name.text)) return false;
|
|
3889
|
+
const recv = callee.expression;
|
|
3890
|
+
if (isWholeInput(recv, cx)) return false;
|
|
3891
|
+
return cx.strippingSchema(recv);
|
|
3892
|
+
}
|
|
3841
3893
|
function isWholeInput(e, cx) {
|
|
3842
3894
|
const u = unwrap(e);
|
|
3843
3895
|
if (ts10.isIdentifier(u)) return cx.wholeName(u.text);
|
|
3844
3896
|
if (ts10.isPropertyAccessExpression(u) || ts10.isElementAccessExpression(u)) {
|
|
3897
|
+
const inner = unwrap(u.expression);
|
|
3898
|
+
if (ts10.isCallExpression(inner) && isSchemaParse(inner, cx)) return false;
|
|
3845
3899
|
return isWholeInput(u.expression, cx);
|
|
3846
3900
|
}
|
|
3847
3901
|
if (ts10.isObjectLiteralExpression(u)) {
|
|
@@ -3861,6 +3915,7 @@ function isWholeInput(e, cx) {
|
|
|
3861
3915
|
}
|
|
3862
3916
|
function isWholeCall(call, cx) {
|
|
3863
3917
|
if (cx.requestBody(call)) return true;
|
|
3918
|
+
if (isSchemaParse(call, cx)) return false;
|
|
3864
3919
|
const callee = call.expression;
|
|
3865
3920
|
if (ts10.isPropertyAccessExpression(callee)) {
|
|
3866
3921
|
const method = callee.name.text;
|
|
@@ -3882,7 +3937,8 @@ function callbackKeepsWhole(cb, receiverWhole, cx) {
|
|
|
3882
3937
|
const inner = {
|
|
3883
3938
|
wholeName: (n) => elementNames.has(n) || cx.wholeName(n),
|
|
3884
3939
|
requestBody: cx.requestBody,
|
|
3885
|
-
requestName: cx.requestName
|
|
3940
|
+
requestName: cx.requestName,
|
|
3941
|
+
strippingSchema: cx.strippingSchema
|
|
3886
3942
|
};
|
|
3887
3943
|
return ownReturns(f).some((r) => isWholeInput(r, inner));
|
|
3888
3944
|
}
|
|
@@ -4207,6 +4263,7 @@ function usesInput(frame, e) {
|
|
|
4207
4263
|
});
|
|
4208
4264
|
return hit;
|
|
4209
4265
|
}
|
|
4266
|
+
var CALLER_READ = /\b(?:await\s+)?cookies\(\)\s*(?:\.|$)|cookieStore\s*\.\s*get\s*\(|\bcookies\s*\.\s*get\s*\(|\b(?:await\s+)?headers\(\)\s*\.\s*get\s*\(/;
|
|
4210
4267
|
var REQUEST_MEMBER = /^(json|formData|text|arrayBuffer|blob|body|headers|cookies|url|nextUrl|query|params|ip|geo)$/;
|
|
4211
4268
|
var REQUEST_CLIENT_CALLEE = /client|supabase|prisma|drizzle/i;
|
|
4212
4269
|
function handsOverRequest(frame, call) {
|
|
@@ -4275,13 +4332,28 @@ function isRequestBodyCall(frame, call) {
|
|
|
4275
4332
|
if (frame.reqNames.has(recv.text)) return true;
|
|
4276
4333
|
return frame.depth === 0 && frame.reqNames.size === 0 && REQUEST_NAME.test(recv.text);
|
|
4277
4334
|
}
|
|
4278
|
-
function wholeContext(frame) {
|
|
4335
|
+
function wholeContext(p, frame) {
|
|
4279
4336
|
return {
|
|
4280
4337
|
wholeName: (n) => frame.wholeNames.has(n),
|
|
4281
4338
|
requestBody: (c) => isRequestBodyCall(frame, c),
|
|
4282
|
-
requestName: (n) => frame.reqNames.has(n)
|
|
4339
|
+
requestName: (n) => frame.reqNames.has(n),
|
|
4340
|
+
strippingSchema: (e) => isStrippingSchema(p, frame, e)
|
|
4283
4341
|
};
|
|
4284
4342
|
}
|
|
4343
|
+
var OBJECT_SCHEMA = /\b(?:z|zod|v|valibot|yup)\s*\.\s*object\s*\(/;
|
|
4344
|
+
var SCHEMA_KEEPS_UNKNOWN = /\.\s*(?:passthrough|catchall|nonstrict|unknown)\s*\(/;
|
|
4345
|
+
function isStrippingSchema(p, frame, e) {
|
|
4346
|
+
const u = unwrap(e);
|
|
4347
|
+
if (ts11.isCallExpression(u) || ts11.isPropertyAccessExpression(u)) {
|
|
4348
|
+
const text2 = u.getText();
|
|
4349
|
+
return OBJECT_SCHEMA.test(text2) && !SCHEMA_KEEPS_UNKNOWN.test(text2);
|
|
4350
|
+
}
|
|
4351
|
+
if (!ts11.isIdentifier(u)) return false;
|
|
4352
|
+
const sym = scopeOf(p, frame.facts).get(u.text);
|
|
4353
|
+
if (sym?.kind !== "var") return false;
|
|
4354
|
+
const text = sym.init.getText();
|
|
4355
|
+
return OBJECT_SCHEMA.test(text) && !SCHEMA_KEEPS_UNKNOWN.test(text);
|
|
4356
|
+
}
|
|
4285
4357
|
function receiverTainted(frame, call) {
|
|
4286
4358
|
const callee = call.expression;
|
|
4287
4359
|
if (!ts11.isPropertyAccessExpression(callee) && !ts11.isElementAccessExpression(callee)) return false;
|
|
@@ -4336,7 +4408,7 @@ function argBinding(p, arg, frame) {
|
|
|
4336
4408
|
client,
|
|
4337
4409
|
instance,
|
|
4338
4410
|
tainted: derivedIn(frame, arg),
|
|
4339
|
-
whole: isWholeInput(arg, wholeContext(frame)),
|
|
4411
|
+
whole: isWholeInput(arg, wholeContext(p, frame)),
|
|
4340
4412
|
isRequest
|
|
4341
4413
|
};
|
|
4342
4414
|
}
|
|
@@ -4466,6 +4538,11 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4466
4538
|
continue;
|
|
4467
4539
|
}
|
|
4468
4540
|
}
|
|
4541
|
+
if (CALLER_READ.test(text)) {
|
|
4542
|
+
if (frame.depth === 0) handlerInput("header", names, decl);
|
|
4543
|
+
else bindInput(names, false);
|
|
4544
|
+
continue;
|
|
4545
|
+
}
|
|
4469
4546
|
if (ts11.isCallExpression(init)) {
|
|
4470
4547
|
const inst = instanceOfCall(p, init, frame, scope);
|
|
4471
4548
|
if (inst && ts11.isIdentifier(decl.name)) {
|
|
@@ -4478,7 +4555,7 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4478
4555
|
if (args.some((a) => a.tainted || a.isRequest) || receiverTainted(frame, init)) {
|
|
4479
4556
|
const rt = returnTaint(p, init, frame, scope, 0);
|
|
4480
4557
|
if (rt === null || rt.tainted && rt.props === null) {
|
|
4481
|
-
bindInput(names, isWholeInput(init, wholeContext(frame)));
|
|
4558
|
+
bindInput(names, isWholeInput(init, wholeContext(p, frame)));
|
|
4482
4559
|
} else if (rt.tainted && rt.props !== null) {
|
|
4483
4560
|
if (ts11.isIdentifier(decl.name))
|
|
4484
4561
|
frame.partialInputs.set(decl.name.text, new Set(rt.props));
|
|
@@ -4515,7 +4592,7 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4515
4592
|
} else if (partial) bindPatternFrom(frame, decl.name, partial);
|
|
4516
4593
|
}
|
|
4517
4594
|
} else if (!isChainWithQuery(init) && derivedIn(frame, init)) {
|
|
4518
|
-
bindInput(names, isWholeInput(init, wholeContext(frame)));
|
|
4595
|
+
bindInput(names, isWholeInput(init, wholeContext(p, frame)));
|
|
4519
4596
|
}
|
|
4520
4597
|
}
|
|
4521
4598
|
if (frame.depth === 0) {
|
|
@@ -4619,7 +4696,7 @@ function analyzeFrame(p, frame, acc) {
|
|
|
4619
4696
|
const payloadOf = (arg) => arg ? {
|
|
4620
4697
|
text: arg.getText(sf).replace(/\s+/g, " ").slice(0, 200),
|
|
4621
4698
|
inputDerived: derivedIn(frame, arg),
|
|
4622
|
-
wholeInput: isWholeInput(arg, wholeContext(frame))
|
|
4699
|
+
wholeInput: isWholeInput(arg, wholeContext(p, frame))
|
|
4623
4700
|
} : null;
|
|
4624
4701
|
const storageHandles = storageBindingsIn(body);
|
|
4625
4702
|
let callerScope = null;
|
|
@@ -4864,7 +4941,7 @@ function childFrame(p, call, target, frame) {
|
|
|
4864
4941
|
pathPos: [...frame.pathPos, call.getStart(frame.sf)],
|
|
4865
4942
|
exitPropagates: frame.exitPropagates && callResultChecked(call)
|
|
4866
4943
|
};
|
|
4867
|
-
const cx = wholeContext(frame);
|
|
4944
|
+
const cx = wholeContext(p, frame);
|
|
4868
4945
|
target.fn.parameters.forEach((param, i) => {
|
|
4869
4946
|
const arg = call.arguments[i];
|
|
4870
4947
|
const ab = argBinding(p, arg, frame);
|
|
@@ -5157,6 +5234,51 @@ function calleePath(e) {
|
|
|
5157
5234
|
if (ts11.isElementAccessExpression(u)) return `${calleePath(u.expression)}[]`;
|
|
5158
5235
|
return "";
|
|
5159
5236
|
}
|
|
5237
|
+
var EMPTY_GUARDS = { authChecks: [], roleChecks: [] };
|
|
5238
|
+
function layoutGuards(p, pageRel, cache) {
|
|
5239
|
+
const parts = pageRel.split("/");
|
|
5240
|
+
parts.pop();
|
|
5241
|
+
const out = { authChecks: [], roleChecks: [] };
|
|
5242
|
+
for (let i = parts.length; i > 0; i--) {
|
|
5243
|
+
const g = layoutGuardsFor(p, parts.slice(0, i).join("/"), cache);
|
|
5244
|
+
out.authChecks.push(...g.authChecks);
|
|
5245
|
+
out.roleChecks.push(...g.roleChecks);
|
|
5246
|
+
}
|
|
5247
|
+
return out;
|
|
5248
|
+
}
|
|
5249
|
+
var LAYOUT_EXTENSIONS = ["tsx", "ts", "jsx", "js"];
|
|
5250
|
+
function layoutGuardsFor(p, dir, cache) {
|
|
5251
|
+
const hit = cache.get(dir);
|
|
5252
|
+
if (hit) return hit;
|
|
5253
|
+
let guards = EMPTY_GUARDS;
|
|
5254
|
+
for (const ext of LAYOUT_EXTENSIONS) {
|
|
5255
|
+
const rel = `${dir}/layout.${ext}`;
|
|
5256
|
+
const sf = p.sources.get(rel);
|
|
5257
|
+
const facts = p.registry.get(rel);
|
|
5258
|
+
if (!sf || !facts || isClientComponentFile(sf)) continue;
|
|
5259
|
+
const fn = pageHandlerIn(sf);
|
|
5260
|
+
if (!fn) continue;
|
|
5261
|
+
try {
|
|
5262
|
+
const analysed = analyzeHandler(p, {
|
|
5263
|
+
rel,
|
|
5264
|
+
sf,
|
|
5265
|
+
facts,
|
|
5266
|
+
kind: "page",
|
|
5267
|
+
route: dir,
|
|
5268
|
+
method: "PAGE",
|
|
5269
|
+
fn: fn.fn,
|
|
5270
|
+
node: fn.node,
|
|
5271
|
+
wrapper: fn.wrapper
|
|
5272
|
+
});
|
|
5273
|
+
guards = { authChecks: analysed.authChecks, roleChecks: analysed.roleChecks ?? [] };
|
|
5274
|
+
} catch {
|
|
5275
|
+
guards = EMPTY_GUARDS;
|
|
5276
|
+
}
|
|
5277
|
+
break;
|
|
5278
|
+
}
|
|
5279
|
+
cache.set(dir, guards);
|
|
5280
|
+
return guards;
|
|
5281
|
+
}
|
|
5160
5282
|
function analyzeHandler(p, h) {
|
|
5161
5283
|
const { rel, sf, fn } = h;
|
|
5162
5284
|
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
@@ -5365,6 +5487,15 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5365
5487
|
storageBuckets: schema.storageBuckets
|
|
5366
5488
|
};
|
|
5367
5489
|
}
|
|
5490
|
+
var LAYOUT_CACHE = /* @__PURE__ */ new WeakMap();
|
|
5491
|
+
function layoutCacheOf(p) {
|
|
5492
|
+
let m = LAYOUT_CACHE.get(p);
|
|
5493
|
+
if (!m) {
|
|
5494
|
+
m = /* @__PURE__ */ new Map();
|
|
5495
|
+
LAYOUT_CACHE.set(p, m);
|
|
5496
|
+
}
|
|
5497
|
+
return m;
|
|
5498
|
+
}
|
|
5368
5499
|
function analyzeFile(project, rel, sf, facts, out) {
|
|
5369
5500
|
const { routes, exposures, fileIgnores } = out;
|
|
5370
5501
|
{
|
|
@@ -5423,6 +5554,11 @@ function analyzeFile(project, rel, sf, facts, out) {
|
|
|
5423
5554
|
node: handler.node,
|
|
5424
5555
|
wrapper: handler.wrapper
|
|
5425
5556
|
});
|
|
5557
|
+
const guards = layoutGuards(project, rel, layoutCacheOf(project));
|
|
5558
|
+
analysed.authChecks.push(...guards.authChecks);
|
|
5559
|
+
if (guards.roleChecks.length > 0) {
|
|
5560
|
+
analysed.roleChecks = [...analysed.roleChecks ?? [], ...guards.roleChecks];
|
|
5561
|
+
}
|
|
5426
5562
|
if (analysed.queries.length > 0 || analysed.inputs.length > 0) routes.push(analysed);
|
|
5427
5563
|
}
|
|
5428
5564
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auditai-scan",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Deterministic security scanner for Next.js + Supabase apps: cross-tenant reads, RLS gaps, service-role misuse, mass assignment. No account, no model, seconds.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|