@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
+ }
@@ -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
- const status = !isExpress || !hasAuth ? 'unknown' : hasRate ? 'passed' : 'missing';
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 === 'missing'
377
- ? 'No auth-focused rate limiting detected.'
378
- : !hasAuth
379
- ? 'Nothing here authenticates anybody, so there is no login surface to throttle.'
380
- : 'This check reads Express middleware, and this project does not use it — any throttling it has is somewhere this cannot see.',
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.59.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": {