@produtype/core 0.59.0 → 0.61.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.
|
@@ -5,6 +5,7 @@ const detectContext_1 = require("./detectContext");
|
|
|
5
5
|
const readTextFileSafe_1 = require("../utils/readTextFileSafe");
|
|
6
6
|
const textSearch_1 = require("../utils/textSearch");
|
|
7
7
|
const absenceEvidence_1 = require("./absenceEvidence");
|
|
8
|
+
const valuesFromPackage_1 = require("./structural/valuesFromPackage");
|
|
8
9
|
const developmentOnly_1 = require("./developmentOnly");
|
|
9
10
|
/** Lines that decide which origins may call this server. */
|
|
10
11
|
const ORIGIN_HANDLING = [/Access-Control-Allow-Origin/i, /ALLOWED_ORIGINS/, /allowedOrigins/i];
|
|
@@ -110,6 +111,16 @@ async function findDjangoSettings(ctx) {
|
|
|
110
111
|
}
|
|
111
112
|
return null;
|
|
112
113
|
}
|
|
114
|
+
const RATE_LIMIT_PACKAGES = [
|
|
115
|
+
'express-rate-limit',
|
|
116
|
+
'@upstash/ratelimit',
|
|
117
|
+
'rate-limiter-flexible',
|
|
118
|
+
'next-rate-limit',
|
|
119
|
+
'express-slow-down',
|
|
120
|
+
'koa-ratelimit',
|
|
121
|
+
'fastify-rate-limit',
|
|
122
|
+
'@fastify/rate-limit',
|
|
123
|
+
];
|
|
113
124
|
async function detectSecurity(ctx) {
|
|
114
125
|
const evidence = [];
|
|
115
126
|
const source = ctx.files.source;
|
|
@@ -127,6 +138,10 @@ async function detectSecurity(ctx) {
|
|
|
127
138
|
/securityHeaders/i,
|
|
128
139
|
], 20);
|
|
129
140
|
const helmet = helmetDep || headerSignals.length > 0;
|
|
141
|
+
/**
|
|
142
|
+
* The packages the ecosystem names, as distinct from the variables authors do.
|
|
143
|
+
* Shared between the dependency check below and the binding walk further down.
|
|
144
|
+
*/
|
|
130
145
|
const rateLimitDep = (0, detectContext_1.hasDep)(ctx, 'express-rate-limit') ||
|
|
131
146
|
(0, detectContext_1.hasDep)(ctx, '@upstash/ratelimit') ||
|
|
132
147
|
(0, detectContext_1.hasDep)(ctx, 'rate-limiter-flexible') ||
|
|
@@ -135,6 +150,50 @@ async function detectSecurity(ctx) {
|
|
|
135
150
|
(0, detectContext_1.hasDep)(ctx, 'slowapi');
|
|
136
151
|
const rateLimitSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/rateLimit\s*\(/, /rate_?limit/i, /Retry-After/i, /\b429\b/, /TooManyRequests/i], 20);
|
|
137
152
|
const rateLimit = rateLimitDep || rateLimitSignals.length > 0;
|
|
153
|
+
/**
|
|
154
|
+
* Rate limiting where the brute force happens.
|
|
155
|
+
*
|
|
156
|
+
* The rule is titled "Rate limit on auth surfaces" and its own passing sentence says
|
|
157
|
+
* "detected on the authentication surface", and the flag behind both was rate
|
|
158
|
+
* limiting *anywhere*: a limiter on a public feed cleared the check for a sign-in
|
|
159
|
+
* page that has none.
|
|
160
|
+
*
|
|
161
|
+
* Finding the limiter by its name is the part that does not hold. `const limiter =
|
|
162
|
+
* rateLimit(...)` is found because somebody wrote "rateLimit"; the same protection
|
|
163
|
+
* written as `const thisIsFuckingTopUse = require('express-rate-limit')` is
|
|
164
|
+
* invisible, and a login that is in fact protected gets downgraded. The name is the
|
|
165
|
+
* one thing its author chose freely, and it is what every search here reads.
|
|
166
|
+
*
|
|
167
|
+
* So the anchor is the package, which the author did not name, and the chain from
|
|
168
|
+
* there is mechanical: the binding the import is assigned to, the values that
|
|
169
|
+
* binding produces when called, and every place those values are used. Where the
|
|
170
|
+
* parser is installed that answers the question outright; where it is not, the text
|
|
171
|
+
* search below is still the floor, which is the same contract every other
|
|
172
|
+
* structural reader here keeps.
|
|
173
|
+
*/
|
|
174
|
+
const boundLimiterUses = await (0, valuesFromPackage_1.readPackageValueUses)(ctx.root, source, RATE_LIMIT_PACKAGES);
|
|
175
|
+
const authSurfaceFiles = new Set((await (0, textSearch_1.searchInFiles)(ctx.root, source, [/['"`]\/(login|signin|sign-in|auth|session)/i, /passport\./, /signIn\s*\(/, /authenticate\s*\(/], 40))
|
|
176
|
+
.map((match) => match.file));
|
|
177
|
+
/**
|
|
178
|
+
* A limiter mounted on a prefix covers what is mounted under it.
|
|
179
|
+
*
|
|
180
|
+
* TranscribeAI writes `app.use('/api/', limiter)` and, seventy lines down,
|
|
181
|
+
* `app.use('/api/auth', authRoutes)`. Its login is protected and a same-file test
|
|
182
|
+
* called it unprotected, because the router lives in another file. Both mount paths
|
|
183
|
+
* are strings in this one, and `/api/auth` says what it carries — so the coverage
|
|
184
|
+
* is readable without following the router anywhere.
|
|
185
|
+
*/
|
|
186
|
+
const mountedPaths = (boundLimiterUses ?? [])
|
|
187
|
+
.map((use) => use.mountPath)
|
|
188
|
+
.filter((path) => Boolean(path))
|
|
189
|
+
.map((path) => path.replace(/\/+$/, ''));
|
|
190
|
+
const authMountPaths = (await (0, textSearch_1.searchInFiles)(ctx.root, source, [/\buse\(\s*['"`]\/[^'"`]*(auth|login|signin|session|account)/i], 20))
|
|
191
|
+
.map((match) => /\buse\(\s*['"`](\/[^'"`]*)['"`]/.exec(match.snippet)?.[1])
|
|
192
|
+
.filter((path) => Boolean(path));
|
|
193
|
+
const coversAnAuthMount = mountedPaths.some((prefix) => authMountPaths.some((mount) => mount === prefix || mount.startsWith(`${prefix}/`)));
|
|
194
|
+
const rateLimitNearAuth = coversAnAuthMount
|
|
195
|
+
|| (boundLimiterUses ?? []).some((use) => authSurfaceFiles.has(use.file))
|
|
196
|
+
|| rateLimitSignals.some((match) => authSurfaceFiles.has(match.file));
|
|
138
197
|
if (helmetDep)
|
|
139
198
|
evidence.push({ type: 'dependency', value: 'helmet', claim: 'headers' });
|
|
140
199
|
if (rateLimitDep)
|
|
@@ -253,6 +312,7 @@ async function detectSecurity(ctx) {
|
|
|
253
312
|
details: {
|
|
254
313
|
helmet,
|
|
255
314
|
rateLimit,
|
|
315
|
+
rateLimitNearAuth,
|
|
256
316
|
corsLoose: corsLoose.length > 0,
|
|
257
317
|
corsStrict: corsStrict.length > 0,
|
|
258
318
|
webhookSignature: webhookSig.length > 0,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface PackageValueUse {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
/**
|
|
5
|
+
* The path it was mounted on, where the use is an argument to `use(path, value)`.
|
|
6
|
+
*
|
|
7
|
+
* `app.use('/api/', limiter)` covers everything mounted under `/api/`, including an
|
|
8
|
+
* auth router registered three lines later. Reading that needs the mount path, and
|
|
9
|
+
* the mount path is a string in the source rather than a name anybody chose.
|
|
10
|
+
*/
|
|
11
|
+
mountPath?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Every line where a value that originated in one of `packages` is used.
|
|
15
|
+
*
|
|
16
|
+
* `null` when the parser is not installed, which is the same contract the other
|
|
17
|
+
* structural readers keep: absent means "this question was not asked", and the caller
|
|
18
|
+
* falls back to text.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readPackageValueUses(root: string, files: string[], packages: string[]): Promise<PackageValueUse[] | null>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readPackageValueUses = readPackageValueUses;
|
|
4
|
+
const readTextFileSafe_1 = require("../../utils/readTextFileSafe");
|
|
5
|
+
const loadTypeScript_1 = require("./loadTypeScript");
|
|
6
|
+
/**
|
|
7
|
+
* Where a value that came out of a given package is used, whatever the author called it.
|
|
8
|
+
*
|
|
9
|
+
* Every search in this analyzer reads names, and a name is the one part of the code
|
|
10
|
+
* its author chose freely. `const limiter = rateLimit(...)` is found because somebody
|
|
11
|
+
* wrote "rateLimit"; the same protection written as
|
|
12
|
+
*
|
|
13
|
+
* const thisIsFuckingTopUse = require('express-rate-limit');
|
|
14
|
+
* router.post('/login', thisIsFuckingTopUse({ max: 5 }), handler);
|
|
15
|
+
*
|
|
16
|
+
* is invisible, and the report downgrades a login that is in fact protected. That is
|
|
17
|
+
* not a gap in a word list. It is the word list.
|
|
18
|
+
*
|
|
19
|
+
* The anchor here is the one name the author did not choose: the package specifier.
|
|
20
|
+
* `express-rate-limit` is what the ecosystem calls it, and from that import the chain
|
|
21
|
+
* is mechanical rather than guessed — the binding it is assigned to, the values that
|
|
22
|
+
* binding produces when called, and every place those values are then used. Following
|
|
23
|
+
* a binding is not a heuristic; it is what the language already means.
|
|
24
|
+
*
|
|
25
|
+
* TypeScript and JavaScript only, because that is where the parser reaches. Elsewhere
|
|
26
|
+
* the floor is still text, and the reading-depth line in the report says so.
|
|
27
|
+
*/
|
|
28
|
+
const READABLE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
29
|
+
function packageOf(ts, node) {
|
|
30
|
+
// `require('x')`
|
|
31
|
+
if (ts.isCallExpression(node)
|
|
32
|
+
&& ts.isIdentifier(node.expression)
|
|
33
|
+
&& node.expression.text === 'require'
|
|
34
|
+
&& node.arguments.length === 1
|
|
35
|
+
&& ts.isStringLiteralLike(node.arguments[0])) {
|
|
36
|
+
return node.arguments[0].text;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function bindingsFor(ts, source, packages) {
|
|
41
|
+
const bound = new Set();
|
|
42
|
+
const visit = (node) => {
|
|
43
|
+
/** `import x from 'pkg'`, and `import { y } from 'pkg'`. */
|
|
44
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
|
45
|
+
if (packages.has(node.moduleSpecifier.text)) {
|
|
46
|
+
const clause = node.importClause;
|
|
47
|
+
if (clause?.name)
|
|
48
|
+
bound.add(clause.name.text);
|
|
49
|
+
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
50
|
+
for (const element of clause.namedBindings.elements)
|
|
51
|
+
bound.add(element.name.text);
|
|
52
|
+
}
|
|
53
|
+
if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
|
|
54
|
+
bound.add(clause.namedBindings.name.text);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** `const x = require('pkg')`, and `const { y } = require('pkg')`. */
|
|
59
|
+
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
60
|
+
const specifier = packageOf(ts, node.initializer);
|
|
61
|
+
if (specifier && packages.has(specifier)) {
|
|
62
|
+
if (ts.isIdentifier(node.name))
|
|
63
|
+
bound.add(node.name.text);
|
|
64
|
+
if (ts.isObjectBindingPattern(node.name)) {
|
|
65
|
+
for (const element of node.name.elements) {
|
|
66
|
+
if (ts.isIdentifier(element.name))
|
|
67
|
+
bound.add(element.name.text);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
ts.forEachChild(node, visit);
|
|
73
|
+
};
|
|
74
|
+
visit(source);
|
|
75
|
+
return bound;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Values produced by calling a binding, so `const limiter = rateLimit({...})` puts
|
|
79
|
+
* `limiter` in the chain alongside `rateLimit` itself.
|
|
80
|
+
*
|
|
81
|
+
* One hop, deliberately. A factory that returns a factory is rare enough that chasing
|
|
82
|
+
* it would add reach nobody has asked for, and every extra hop is another way to be
|
|
83
|
+
* wrong about what a value is.
|
|
84
|
+
*/
|
|
85
|
+
function derivedFrom(ts, source, roots) {
|
|
86
|
+
const derived = new Set();
|
|
87
|
+
const visit = (node) => {
|
|
88
|
+
if (ts.isVariableDeclaration(node)
|
|
89
|
+
&& node.initializer
|
|
90
|
+
&& ts.isCallExpression(node.initializer)
|
|
91
|
+
&& ts.isIdentifier(node.initializer.expression)
|
|
92
|
+
&& roots.has(node.initializer.expression.text)
|
|
93
|
+
&& ts.isIdentifier(node.name)) {
|
|
94
|
+
derived.add(node.name.text);
|
|
95
|
+
}
|
|
96
|
+
ts.forEachChild(node, visit);
|
|
97
|
+
};
|
|
98
|
+
visit(source);
|
|
99
|
+
return derived;
|
|
100
|
+
}
|
|
101
|
+
/** The `use(path, …)` this node sits inside, if any. */
|
|
102
|
+
function mountPathOf(ts, node) {
|
|
103
|
+
const call = node.parent;
|
|
104
|
+
if (!call || !ts.isCallExpression(call))
|
|
105
|
+
return undefined;
|
|
106
|
+
if (!call.arguments.some((argument) => argument === node))
|
|
107
|
+
return undefined;
|
|
108
|
+
const callee = call.expression;
|
|
109
|
+
const isUse = ts.isPropertyAccessExpression(callee) && callee.name.text === 'use';
|
|
110
|
+
if (!isUse)
|
|
111
|
+
return undefined;
|
|
112
|
+
const [first] = call.arguments;
|
|
113
|
+
return first && ts.isStringLiteralLike(first) ? first.text : undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Every line where a value that originated in one of `packages` is used.
|
|
117
|
+
*
|
|
118
|
+
* `null` when the parser is not installed, which is the same contract the other
|
|
119
|
+
* structural readers keep: absent means "this question was not asked", and the caller
|
|
120
|
+
* falls back to text.
|
|
121
|
+
*/
|
|
122
|
+
async function readPackageValueUses(root, files, packages) {
|
|
123
|
+
const ts = await (0, loadTypeScript_1.loadTypeScript)();
|
|
124
|
+
if (!ts)
|
|
125
|
+
return null;
|
|
126
|
+
const wanted = new Set(packages);
|
|
127
|
+
const uses = [];
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
if (!READABLE.test(file))
|
|
130
|
+
continue;
|
|
131
|
+
const text = await (0, readTextFileSafe_1.readTextFileSafe)(root, file);
|
|
132
|
+
if (!text)
|
|
133
|
+
continue;
|
|
134
|
+
if (!packages.some((name) => text.includes(name)))
|
|
135
|
+
continue;
|
|
136
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
137
|
+
const roots = bindingsFor(ts, source, wanted);
|
|
138
|
+
if (roots.size === 0)
|
|
139
|
+
continue;
|
|
140
|
+
const names = new Set([...roots, ...derivedFrom(ts, source, roots)]);
|
|
141
|
+
const visit = (node) => {
|
|
142
|
+
if (ts.isIdentifier(node) && names.has(node.text)) {
|
|
143
|
+
/**
|
|
144
|
+
* A use, not a declaration. `const limiter = rateLimit(...)` names `limiter`
|
|
145
|
+
* on the left and uses `rateLimit` on the right; only the second is a place
|
|
146
|
+
* the protection is applied.
|
|
147
|
+
*/
|
|
148
|
+
const parent = node.parent;
|
|
149
|
+
const isDeclarationName = (parent && ts.isVariableDeclaration(parent) && parent.name === node)
|
|
150
|
+
|| (parent && ts.isImportSpecifier(parent))
|
|
151
|
+
|| (parent && ts.isImportClause(parent))
|
|
152
|
+
|| (parent && ts.isNamespaceImport(parent))
|
|
153
|
+
|| (parent && ts.isBindingElement(parent) && parent.name === node);
|
|
154
|
+
if (!isDeclarationName) {
|
|
155
|
+
uses.push({
|
|
156
|
+
file,
|
|
157
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
158
|
+
mountPath: mountPathOf(ts, node),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
ts.forEachChild(node, visit);
|
|
163
|
+
};
|
|
164
|
+
visit(source);
|
|
165
|
+
}
|
|
166
|
+
return uses;
|
|
167
|
+
}
|
package/dist/rules/rules.js
CHANGED
|
@@ -358,7 +358,18 @@ exports.rules = [
|
|
|
358
358
|
const sec = analysis.detectors['security.core'];
|
|
359
359
|
const hasAuth = Boolean(auth?.present);
|
|
360
360
|
const hasRate = Boolean(sec?.details?.rateLimit);
|
|
361
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Near the login, not merely somewhere.
|
|
363
|
+
*
|
|
364
|
+
* This passed on `rateLimit` alone, which is throttling anywhere in the
|
|
365
|
+
* repository — a limiter on a public feed cleared the check for a sign-in page
|
|
366
|
+
* that has none. The rule's own passing sentence says "on the authentication
|
|
367
|
+
* surface", and now it only says that when something shows it.
|
|
368
|
+
*/
|
|
369
|
+
const nearAuth = Boolean(sec?.details?.rateLimitNearAuth);
|
|
370
|
+
const status = !isExpress || !hasAuth
|
|
371
|
+
? 'unknown'
|
|
372
|
+
: nearAuth ? 'passed' : hasRate ? 'partial' : 'missing';
|
|
362
373
|
return mkFinding({
|
|
363
374
|
id: 'security.rate-limit-auth',
|
|
364
375
|
title: 'Authentication rate limiting',
|
|
@@ -373,11 +384,13 @@ exports.rules = [
|
|
|
373
384
|
*/
|
|
374
385
|
description: status === 'passed'
|
|
375
386
|
? 'Rate limiting signals detected on the authentication surface.'
|
|
376
|
-
: status === '
|
|
377
|
-
? '
|
|
378
|
-
:
|
|
379
|
-
? '
|
|
380
|
-
:
|
|
387
|
+
: status === 'partial'
|
|
388
|
+
? 'Rate limiting is in place somewhere, but nothing here shows it covering sign-in.'
|
|
389
|
+
: status === 'missing'
|
|
390
|
+
? 'No auth-focused rate limiting detected.'
|
|
391
|
+
: !hasAuth
|
|
392
|
+
? 'Nothing here authenticates anybody, so there is no login surface to throttle.'
|
|
393
|
+
: 'This check reads Express middleware, and this project does not use it — any throttling it has is somewhere this cannot see.',
|
|
381
394
|
recommendation: 'Apply express-rate-limit (or equivalent) to login/register/password reset endpoints.',
|
|
382
395
|
/**
|
|
383
396
|
* The claim is about rate limiting, so the evidence is about rate limiting.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Deterministic CLI and library that analyzes a web application repository and reports how far it is from production-ready for the kind of product it is meant to be.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|