@produtype/core 1.9.3 → 1.11.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/dist/analyzer/detectSecurity.js +74 -2
- package/dist/rules/rules.js +14 -3
- package/dist/utils/textSearch.js +54 -14
- package/package.json +1 -1
|
@@ -180,6 +180,16 @@ const RATE_LIMIT_PACKAGES = [
|
|
|
180
180
|
'koa-ratelimit',
|
|
181
181
|
'fastify-rate-limit',
|
|
182
182
|
'@fastify/rate-limit',
|
|
183
|
+
/**
|
|
184
|
+
* Nest's own, which is a module and a guard rather than a middleware function.
|
|
185
|
+
*
|
|
186
|
+
* ghostfolio registers `ThrottlerModule.forRootAsync` and puts a
|
|
187
|
+
* `CustomThrottlerGuard` on its sign-in route, and was told at `medium` that it has
|
|
188
|
+
* no rate limiting at all. The package name is Nest's; what the guard is called is
|
|
189
|
+
* ghostfolio's business.
|
|
190
|
+
*/
|
|
191
|
+
'@nestjs/throttler',
|
|
192
|
+
'@nest-lab/throttler-storage-redis',
|
|
183
193
|
];
|
|
184
194
|
async function detectSecurity(ctx) {
|
|
185
195
|
const evidence = [];
|
|
@@ -310,6 +320,7 @@ async function detectSecurity(ctx) {
|
|
|
310
320
|
(0, detectContext_1.hasDep)(ctx, '@upstash/ratelimit') ||
|
|
311
321
|
(0, detectContext_1.hasDep)(ctx, 'rate-limiter-flexible') ||
|
|
312
322
|
(0, detectContext_1.hasDep)(ctx, 'next-rate-limit') ||
|
|
323
|
+
(0, detectContext_1.hasDep)(ctx, '@nestjs/throttler') ||
|
|
313
324
|
(0, detectContext_1.hasAnyPyDep)(ctx, ['django-ratelimit', 'slowapi', 'flask-limiter']).length > 0 ||
|
|
314
325
|
(0, detectContext_1.hasAnyRustDep)(ctx, ['governor', 'tower_governor', 'tower-governor', 'actix-governor', 'ratelimit']).length > 0 ||
|
|
315
326
|
/**
|
|
@@ -437,7 +448,22 @@ async function detectSecurity(ctx) {
|
|
|
437
448
|
* project's authentication surface. Per pattern, the decisive search keeps its own
|
|
438
449
|
* budget however much noise the others find.
|
|
439
450
|
*/
|
|
440
|
-
|
|
451
|
+
/**
|
|
452
|
+
* A route decorator writes the path without a leading slash.
|
|
453
|
+
*
|
|
454
|
+
* Every pattern here wanted `'/login'`, and NestJS writes `@Controller('auth')` and
|
|
455
|
+
* `@Post('login')` — the slash belongs to the router, not to the string. ghostfolio
|
|
456
|
+
* throttles its sign-in with a `CustomThrottlerGuard` two lines under
|
|
457
|
+
* `@Controller('auth')`, and `auth.controller.ts` was not in this set at all: four
|
|
458
|
+
* files were, none of them the controller, and the report said the product has no
|
|
459
|
+
* rate limiting.
|
|
460
|
+
*
|
|
461
|
+
* The decorator name is the anchor. Dropping the slash from the plain pattern
|
|
462
|
+
* instead would have let every `from './auth'` into the authentication surface, and
|
|
463
|
+
* that surface decides what counts as protecting a sign-in.
|
|
464
|
+
*/
|
|
465
|
+
const ROUTE_DECORATOR = /@(?:Controller|Post|Get|Put|Patch|All|RequestMapping)\(\s*(?:value\s*=\s*)?['"`]\/?(?:login|signin|sign-in|auth|session)/i;
|
|
466
|
+
const AUTH_SURFACE_PATTERNS = [/['"`]\/(login|signin|sign-in|auth|session)/i, ROUTE_DECORATOR, /passport\./, /signIn\s*\(/, /authenticate\s*\(/];
|
|
441
467
|
const authSurfaceFiles = new Set();
|
|
442
468
|
for (const pattern of AUTH_SURFACE_PATTERNS) {
|
|
443
469
|
for (const match of await (0, textSearch_1.searchInFiles)(ctx.root, source, [pattern], 40))
|
|
@@ -477,6 +503,7 @@ async function detectSecurity(ctx) {
|
|
|
477
503
|
* limiter package at all. A schema field is neither.
|
|
478
504
|
*/
|
|
479
505
|
const AUTH_ROUTE_DECLARATION = /['"`]\/(?:login|signin|sign-in|sessions?|auth\/login)['"`]/i;
|
|
506
|
+
const DECLARES_AN_AUTH_ROUTE = (line) => AUTH_ROUTE_DECLARATION.test(line) || ROUTE_DECORATOR.test(line);
|
|
480
507
|
const LIMIT_IN_ROUTE_OPTIONS = /rate[_-]?limit|throttle/i;
|
|
481
508
|
const ROUTE_OPTIONS_WINDOW = 8;
|
|
482
509
|
const limitedAuthRoutes = [];
|
|
@@ -487,7 +514,7 @@ async function detectSecurity(ctx) {
|
|
|
487
514
|
continue;
|
|
488
515
|
const lines = text.split(/\r?\n/);
|
|
489
516
|
for (let i = 0; i < lines.length; i++) {
|
|
490
|
-
if (!
|
|
517
|
+
if (!DECLARES_AN_AUTH_ROUTE(lines[i]))
|
|
491
518
|
continue;
|
|
492
519
|
const window = lines.slice(i + 1, i + 1 + ROUTE_OPTIONS_WINDOW);
|
|
493
520
|
const offset = window.findIndex((line) => LIMIT_IN_ROUTE_OPTIONS.test(line));
|
|
@@ -501,8 +528,33 @@ async function detectSecurity(ctx) {
|
|
|
501
528
|
for (const hit of limitedAuthRoutes.slice(0, 3)) {
|
|
502
529
|
evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line, claim: 'rate-limit' });
|
|
503
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* A guard is applied to a class, not to a line.
|
|
533
|
+
*
|
|
534
|
+
* ghostfolio puts `@UseGuards(CustomThrottlerGuard)` on three routes of
|
|
535
|
+
* `auth.controller.ts`, nine lines below `@Controller('auth')` — one past the window
|
|
536
|
+
* that reads a route's own options, and that window is the wrong question anyway. A
|
|
537
|
+
* Nest guard protects the handler it decorates, wherever in the class it sits.
|
|
538
|
+
*
|
|
539
|
+
* `Throttler` is the word `@nestjs/throttler` exports; what the guard wrapping it is
|
|
540
|
+
* called is the author's business. So the claim is narrow: the package is declared,
|
|
541
|
+
* and its name appears in a file this analyzer already established as the
|
|
542
|
+
* authentication surface.
|
|
543
|
+
*
|
|
544
|
+
* Matched without word boundaries and without case, because the author's name for it
|
|
545
|
+
* wraps the package's: `CustomThrottlerGuard` has no boundary before `Throttler`,
|
|
546
|
+
* and `custom-throttler.guard` spells it in lower case. The first version of this
|
|
547
|
+
* rule asked for `\bThrottler` and found neither.
|
|
548
|
+
*/
|
|
549
|
+
const nestThrottler = (0, detectContext_1.hasDep)(ctx, '@nestjs/throttler')
|
|
550
|
+
? (await (0, textSearch_1.searchInFiles)(ctx.root, source, [/throttler/i], 20)).filter((hit) => authSurfaceFiles.has(hit.file))
|
|
551
|
+
: [];
|
|
552
|
+
for (const hit of nestThrottler.slice(0, 2)) {
|
|
553
|
+
evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line, claim: 'rate-limit' });
|
|
554
|
+
}
|
|
504
555
|
const rateLimitNearAuth = coversAnAuthMount
|
|
505
556
|
|| limitedAuthRoutes.length > 0
|
|
557
|
+
|| nestThrottler.length > 0
|
|
506
558
|
|| (boundLimiterUses ?? []).some((use) => authSurfaceFiles.has(use.file))
|
|
507
559
|
|| issuedRateLimits.some((match) => authSurfaceFiles.has(match.file));
|
|
508
560
|
if (helmetDep)
|
|
@@ -639,6 +691,26 @@ async function detectSecurity(ctx) {
|
|
|
639
691
|
corsLoose.push(...detected.loose);
|
|
640
692
|
corsStrict.push(...detected.strict);
|
|
641
693
|
}
|
|
694
|
+
/**
|
|
695
|
+
* NestJS turns it on with a method of its own.
|
|
696
|
+
*
|
|
697
|
+
* `app.enableCors()` is how a Nest application does this, and bare — with no
|
|
698
|
+
* argument — it is Nest's default, which is every origin. ghostfolio calls exactly
|
|
699
|
+
* that in `apps/api/src/main.ts`, and the one line the report cited was
|
|
700
|
+
* `allowedOrigins: [hostname]` in an MCP module twelve directories away: an
|
|
701
|
+
* allowlist, while the whole API answers anybody.
|
|
702
|
+
*
|
|
703
|
+
* `enableCors` is the framework's name for the method, so it is the anchor. An
|
|
704
|
+
* argument means somebody chose something and the choice is read the same way as
|
|
705
|
+
* everywhere else here — a literal `'*'` is open, anything else is a decision this
|
|
706
|
+
* search cannot settle and says so.
|
|
707
|
+
*/
|
|
708
|
+
const nestCors = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/\.enableCors\s*\(/], 5);
|
|
709
|
+
for (const hit of nestCors) {
|
|
710
|
+
const argument = /\.enableCors\s*\(\s*([^)]*)/.exec(hit.snippet)?.[1]?.trim() ?? '';
|
|
711
|
+
const wideOpen = argument === '' || /^["']\*["']/.test(argument) || /origin\s*:\s*["']\*["']/.test(argument);
|
|
712
|
+
(wideOpen ? corsLoose : corsStrict).push(hit);
|
|
713
|
+
}
|
|
642
714
|
/**
|
|
643
715
|
* Django's answer, which is a string in a list.
|
|
644
716
|
*
|
package/dist/rules/rules.js
CHANGED
|
@@ -404,7 +404,18 @@ exports.rules = [
|
|
|
404
404
|
category: 'security',
|
|
405
405
|
severity: 'medium',
|
|
406
406
|
evaluate: ({ analysis }) => {
|
|
407
|
-
|
|
407
|
+
/**
|
|
408
|
+
* The backends this check can actually read.
|
|
409
|
+
*
|
|
410
|
+
* It was `express` alone, and said so in its own unknown sentence — the
|
|
411
|
+
* coverage reading follows `app.use('/api', limiter)`, which is Express. Two
|
|
412
|
+
* readers have joined it since: a limit declared in a route's own options, and
|
|
413
|
+
* a Nest guard applied in the controller that declares the sign-in. A NestJS
|
|
414
|
+
* application that does not also declare express — which is most of them, since
|
|
415
|
+
* `@nestjs/platform-express` is the adapter rather than the framework — got no
|
|
416
|
+
* verdict at all, on a capability this analyzer can now read there.
|
|
417
|
+
*/
|
|
418
|
+
const readableBackend = ['express', 'nestjs'].some((name) => analysis.stack.backend.includes(name));
|
|
408
419
|
const auth = analysis.detectors['auth.core'];
|
|
409
420
|
const sec = analysis.detectors['security.core'];
|
|
410
421
|
const hasAuth = Boolean(auth?.present);
|
|
@@ -427,7 +438,7 @@ exports.rules = [
|
|
|
427
438
|
* showed it.
|
|
428
439
|
*/
|
|
429
440
|
const coverageUnasked = Boolean(sec?.details?.rateLimitCoverageUnasked);
|
|
430
|
-
const status = !
|
|
441
|
+
const status = !readableBackend || !hasAuth
|
|
431
442
|
? 'unknown'
|
|
432
443
|
: nearAuth ? 'passed' : coverageUnasked && hasRate ? 'unknown' : hasRate ? 'partial' : 'missing';
|
|
433
444
|
return mkFinding({
|
|
@@ -452,7 +463,7 @@ exports.rules = [
|
|
|
452
463
|
? 'Not assessed: this project throttles something, and whether it reaches the login is read from where the limiter is mounted — which needs the optional `typescript` peer dependency. Install it and re-run.'
|
|
453
464
|
: !hasAuth
|
|
454
465
|
? 'Nothing here authenticates anybody, so there is no login surface to throttle.'
|
|
455
|
-
: 'This check reads Express
|
|
466
|
+
: 'This check reads Express and NestJS, and this project uses neither — any throttling it has is somewhere this cannot see.',
|
|
456
467
|
recommendation: 'Apply express-rate-limit (or equivalent) to login/register/password reset endpoints.',
|
|
457
468
|
/**
|
|
458
469
|
* The claim is about rate limiting, so the evidence is about rate limiting.
|
package/dist/utils/textSearch.js
CHANGED
|
@@ -127,6 +127,54 @@ function isCitableLine(line) {
|
|
|
127
127
|
* on is not worth the one it displaces.
|
|
128
128
|
*/
|
|
129
129
|
const MAX_CITABLE_LINE = 500;
|
|
130
|
+
/**
|
|
131
|
+
* A placeholder is not a value.
|
|
132
|
+
*
|
|
133
|
+
* pocketbase was reported as having tenant boundaries — `passed`, which raises a
|
|
134
|
+
* score — and the only two strong matches in its readable source were
|
|
135
|
+
* `"Ex. https://login.microsoftonline.com/YOUR_DIRECTORY_TENANT_ID/oauth2/v2.0/authorize"`,
|
|
136
|
+
* twice, in the help text of the form where somebody configures Microsoft sign-in.
|
|
137
|
+
* Microsoft Entra calls its directory a tenant; the string is telling a reader where
|
|
138
|
+
* to paste theirs. pocketbase has no organizations at all, and the rest of that
|
|
139
|
+
* finding was Apple's developer `teamId`, a weak word that cannot stand alone.
|
|
140
|
+
*
|
|
141
|
+
* `YOUR_SOMETHING` is the convention for "replace this", in documentation, in example
|
|
142
|
+
* configuration and in the help text beside a field. This analyzer already knows the
|
|
143
|
+
* shape: `your[_-]?secret` has been in the weak-secret list since the beginning.
|
|
144
|
+
*
|
|
145
|
+
* The token around the match is what decides, not the line. A line may hold a
|
|
146
|
+
* placeholder and a real value both, and only the matched one is being judged.
|
|
147
|
+
*/
|
|
148
|
+
const PLACEHOLDER_TOKEN = /^(?:your|my|sample|example|placeholder|changeme|todo|xxx+)[_-]/i;
|
|
149
|
+
function insideAPlaceholder(line, index, length) {
|
|
150
|
+
let start = index;
|
|
151
|
+
while (start > 0 && /[A-Za-z0-9_-]/.test(line[start - 1]))
|
|
152
|
+
start--;
|
|
153
|
+
let end = index + length;
|
|
154
|
+
while (end < line.length && /[A-Za-z0-9_-]/.test(line[end]))
|
|
155
|
+
end++;
|
|
156
|
+
return PLACEHOLDER_TOKEN.test(line.slice(start, end));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Where a needle first matches, or -1. Shared so that both searches below judge a
|
|
160
|
+
* match the same way.
|
|
161
|
+
*/
|
|
162
|
+
function findNeedle(line, needle) {
|
|
163
|
+
if (typeof needle === 'string') {
|
|
164
|
+
const index = line.indexOf(needle);
|
|
165
|
+
return index === -1 ? null : { index, length: needle.length };
|
|
166
|
+
}
|
|
167
|
+
const found = new RegExp(needle.source, needle.flags.replace('g', '')).exec(line);
|
|
168
|
+
return found ? { index: found.index, length: found[0].length } : null;
|
|
169
|
+
}
|
|
170
|
+
function matchesHere(line, needles) {
|
|
171
|
+
for (const needle of needles) {
|
|
172
|
+
const found = findNeedle(line, needle);
|
|
173
|
+
if (found && !insideAPlaceholder(line, found.index, found.length))
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
130
178
|
/**
|
|
131
179
|
* The lines of one already-read file that match, with the same hygiene the file
|
|
132
180
|
* search applies: no comments, no pattern tables, no minified lines.
|
|
@@ -157,12 +205,8 @@ function matchLines(text, needles, file = '') {
|
|
|
157
205
|
continue;
|
|
158
206
|
if (declaresRatherThanDoes(line))
|
|
159
207
|
continue;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (hit) {
|
|
163
|
-
matches.push({ file, line: i + 1, snippet: line.trim().slice(0, 200) });
|
|
164
|
-
break;
|
|
165
|
-
}
|
|
208
|
+
if (matchesHere(line, needles)) {
|
|
209
|
+
matches.push({ file, line: i + 1, snippet: line.trim().slice(0, 200) });
|
|
166
210
|
}
|
|
167
211
|
}
|
|
168
212
|
return matches;
|
|
@@ -205,14 +249,10 @@ async function searchInFiles(root, files, needles, limit = 25, keep) {
|
|
|
205
249
|
continue;
|
|
206
250
|
if (declaresRatherThanDoes(line))
|
|
207
251
|
continue;
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
if (!keep || keep(match))
|
|
213
|
-
matches.push(match);
|
|
214
|
-
break;
|
|
215
|
-
}
|
|
252
|
+
if (matchesHere(line, needles)) {
|
|
253
|
+
const match = { file, line: i + 1, snippet: line.trim().slice(0, 200) };
|
|
254
|
+
if (!keep || keep(match))
|
|
255
|
+
matches.push(match);
|
|
216
256
|
}
|
|
217
257
|
if (matches.length >= limit)
|
|
218
258
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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": {
|