@produtype/core 1.8.2 → 1.9.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.
|
@@ -55,7 +55,9 @@ async function detectBilling(ctx) {
|
|
|
55
55
|
/stripeSubscriptionId/i,
|
|
56
56
|
/\/webhooks?\/stripe/i,
|
|
57
57
|
/\/stripe\/webhooks?/i,
|
|
58
|
-
], 30
|
|
58
|
+
], 30,
|
|
59
|
+
/** The budget counts real signals, not entries in Laravel's table of slots. */
|
|
60
|
+
(match) => !LARAVEL_SERVICE_SLOTS.test(match.file)));
|
|
59
61
|
/**
|
|
60
62
|
* The processor, declared wherever this project declares its dependencies.
|
|
61
63
|
*
|
|
@@ -3,10 +3,33 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.detectDocker = detectDocker;
|
|
4
4
|
const readTextFileSafe_1 = require("../utils/readTextFileSafe");
|
|
5
5
|
const absenceEvidence_1 = require("./absenceEvidence");
|
|
6
|
+
/**
|
|
7
|
+
* The container the editor opens is not the container the product ships in.
|
|
8
|
+
*
|
|
9
|
+
* `.devcontainer/` is the Development Containers convention — VS Code, Codespaces and
|
|
10
|
+
* anything else that implements containers.dev read it to build the environment a
|
|
11
|
+
* *contributor* works in. n8n has one, and it sorted ahead of
|
|
12
|
+
* `docker/images/n8n/Dockerfile`, so the report answered "how does this ship" with the
|
|
13
|
+
* development environment: the wrong file cited, and the HEALTHCHECK question asked of
|
|
14
|
+
* a Dockerfile that has no reason to answer it.
|
|
15
|
+
*
|
|
16
|
+
* The first match in path order decided before, which is no decision at all. The
|
|
17
|
+
* shallowest of the real ones is the project's own, the same tie-break the package
|
|
18
|
+
* manager uses.
|
|
19
|
+
*
|
|
20
|
+
* A repository whose only container definition is its devcontainer keeps it rather
|
|
21
|
+
* than losing the answer: nothing that used to resolve stops resolving.
|
|
22
|
+
*/
|
|
23
|
+
const DEVELOPMENT_CONTAINER = /(^|\/)\.devcontainer\//;
|
|
24
|
+
function theShippedOne(candidates) {
|
|
25
|
+
const shipped = candidates.filter((file) => !DEVELOPMENT_CONTAINER.test(file));
|
|
26
|
+
const pool = shipped.length > 0 ? shipped : candidates;
|
|
27
|
+
return pool.reduce((best, file) => (best === undefined || file.split('/').length < best.split('/').length ? file : best), undefined);
|
|
28
|
+
}
|
|
6
29
|
async function detectDocker(ctx) {
|
|
7
30
|
const evidence = [];
|
|
8
|
-
const dockerfile = ctx.files.all.
|
|
9
|
-
const composeFile = ctx.files.all.
|
|
31
|
+
const dockerfile = theShippedOne(ctx.files.all.filter((f) => /(^|\/)Dockerfile$/.test(f)));
|
|
32
|
+
const composeFile = theShippedOne(ctx.files.all.filter((f) => /(^|\/)(docker-compose\.ya?ml|compose\.ya?ml)$/.test(f)));
|
|
10
33
|
let hasHealthcheck = false;
|
|
11
34
|
let hasExpose = false;
|
|
12
35
|
let services = [];
|
|
@@ -11,6 +11,10 @@ const readingDepth_1 = require("./readingDepth");
|
|
|
11
11
|
const developmentOnly_1 = require("./developmentOnly");
|
|
12
12
|
/** Lines that decide which origins may call this server. */
|
|
13
13
|
const ORIGIN_HANDLING = [/Access-Control-Allow-Origin/i, /ALLOWED_ORIGINS/, /allowedOrigins/i];
|
|
14
|
+
/** A header name used as a key into a headers object: a lookup, not a decision. */
|
|
15
|
+
const READS_A_HEADER = /\[\s*(['"`])[^'"`]+\1\s*\](?!\s*=[^=])/;
|
|
16
|
+
/** The calls that put one on a response, in the frameworks this reads. */
|
|
17
|
+
const SETS_A_HEADER = /put_resp_header|setHeader|set_header|add_header|headers\.(?:set|append)|writeHead/i;
|
|
14
18
|
/** The cloud SDKs' names for a bucket's own cross-origin rules. */
|
|
15
19
|
const CLOUD_STORAGE_CORS = /\bStorageCorsRule\b|\bCorsRules\b|\bCORSRule\b|\bCORSConfiguration\b|\bsetCorsConfiguration\b/;
|
|
16
20
|
/**
|
|
@@ -227,7 +231,12 @@ async function detectSecurity(ctx) {
|
|
|
227
231
|
*/
|
|
228
232
|
/config\.content_security_policy\b/,
|
|
229
233
|
/^\s*config\.force_ssl\s*=\s*true/m,
|
|
230
|
-
], 20
|
|
234
|
+
], 20,
|
|
235
|
+
/**
|
|
236
|
+
* The filter runs inside the search, so the budget is spent on lines that set a
|
|
237
|
+
* header rather than on lines that merely name one — see the rule below.
|
|
238
|
+
*/
|
|
239
|
+
(match) => !READS_A_HEADER.test(match.snippet) || SETS_A_HEADER.test(match.snippet));
|
|
231
240
|
/**
|
|
232
241
|
* Reading a header is not setting one.
|
|
233
242
|
*
|
|
@@ -242,9 +251,6 @@ async function detectSecurity(ctx) {
|
|
|
242
251
|
* `headers.set`, `add_header` — or an assignment to that subscript, and a line doing
|
|
243
252
|
* either is left alone.
|
|
244
253
|
*/
|
|
245
|
-
const READS_A_HEADER = /\[\s*(['"`])[^'"`]+\1\s*\](?!\s*=[^=])/;
|
|
246
|
-
const SETS_A_HEADER = /put_resp_header|setHeader|set_header|add_header|headers\.(?:set|append)|writeHead/i;
|
|
247
|
-
const headerSignalsThatSet = headerSignals.filter((hit) => !READS_A_HEADER.test(hit.snippet) || SETS_A_HEADER.test(hit.snippet));
|
|
248
254
|
/**
|
|
249
255
|
* Spring Security, which writes the headers without being asked.
|
|
250
256
|
*
|
|
@@ -283,7 +289,7 @@ async function detectSecurity(ctx) {
|
|
|
283
289
|
claim: 'headers',
|
|
284
290
|
});
|
|
285
291
|
}
|
|
286
|
-
const helmet = helmetDep ||
|
|
292
|
+
const helmet = helmetDep || headerSignals.length > 0 || springFilterChain.length > 0;
|
|
287
293
|
/**
|
|
288
294
|
* The packages the ecosystem names, as distinct from the variables authors do.
|
|
289
295
|
* Shared between the dependency check below and the binding walk further down.
|
|
@@ -381,7 +387,9 @@ async function detectSecurity(ctx) {
|
|
|
381
387
|
/HttpStatus\.TOO_MANY_REQUESTS/,
|
|
382
388
|
/HttpStatusCode\.TooManyRequests/,
|
|
383
389
|
/Status429TooManyRequests/,
|
|
384
|
-
], 20
|
|
390
|
+
], 20,
|
|
391
|
+
/** The budget counts refusals issued, not 429s this project received. */
|
|
392
|
+
(match) => !COMPARES_A_STATUS.test(match.snippet));
|
|
385
393
|
/**
|
|
386
394
|
* Still issuing, not receiving — the constants need the same test the numbers got.
|
|
387
395
|
*
|
|
@@ -390,7 +398,7 @@ async function detectSecurity(ctx) {
|
|
|
390
398
|
* refused by somebody else's, which is what nocodb's webhook invoker does. A
|
|
391
399
|
* comparison is the reading direction; an argument is the writing one.
|
|
392
400
|
*/
|
|
393
|
-
const issuedRateLimits = rateLimitSignals
|
|
401
|
+
const issuedRateLimits = rateLimitSignals;
|
|
394
402
|
const rateLimit = rateLimitDep || issuedRateLimits.length > 0;
|
|
395
403
|
/**
|
|
396
404
|
* Rate limiting where the brute force happens.
|
|
@@ -414,8 +422,27 @@ async function detectSecurity(ctx) {
|
|
|
414
422
|
* structural reader here keeps.
|
|
415
423
|
*/
|
|
416
424
|
const boundLimiterUses = await (0, valuesFromPackage_1.readPackageValueUses)(ctx.root, source, RATE_LIMIT_PACKAGES);
|
|
417
|
-
|
|
418
|
-
|
|
425
|
+
/**
|
|
426
|
+
* One budget per pattern, because a noisy one was spending the whole thing.
|
|
427
|
+
*
|
|
428
|
+
* These four searches ran as one with a cap of forty matches, and the cap decided
|
|
429
|
+
* the answer. n8n ships about four hundred `*.credentials.ts` files describing how
|
|
430
|
+
* to authenticate to *other people's* APIs — Airtop, Action Network, Microsoft — and
|
|
431
|
+
* they sort ahead of `packages/cli/src/controllers/auth.controller.ts`. Thirty-eight
|
|
432
|
+
* files of third-party credential definitions filled the budget, n8n's own sign-in
|
|
433
|
+
* controller never entered the set, and its login — throttled by IP and by email —
|
|
434
|
+
* was reported unprotected.
|
|
435
|
+
*
|
|
436
|
+
* A cap is there to bound work, and it had come to decide which files are this
|
|
437
|
+
* project's authentication surface. Per pattern, the decisive search keeps its own
|
|
438
|
+
* budget however much noise the others find.
|
|
439
|
+
*/
|
|
440
|
+
const AUTH_SURFACE_PATTERNS = [/['"`]\/(login|signin|sign-in|auth|session)/i, /passport\./, /signIn\s*\(/, /authenticate\s*\(/];
|
|
441
|
+
const authSurfaceFiles = new Set();
|
|
442
|
+
for (const pattern of AUTH_SURFACE_PATTERNS) {
|
|
443
|
+
for (const match of await (0, textSearch_1.searchInFiles)(ctx.root, source, [pattern], 40))
|
|
444
|
+
authSurfaceFiles.add(match.file);
|
|
445
|
+
}
|
|
419
446
|
/**
|
|
420
447
|
* A limiter mounted on a prefix covers what is mounted under it.
|
|
421
448
|
*
|
|
@@ -433,14 +460,56 @@ async function detectSecurity(ctx) {
|
|
|
433
460
|
.map((match) => /\buse\(\s*['"`](\/[^'"`]*)['"`]/.exec(match.snippet)?.[1])
|
|
434
461
|
.filter((path) => Boolean(path));
|
|
435
462
|
const coversAnAuthMount = mountedPaths.some((prefix) => authMountPaths.some((mount) => mount === prefix || mount.startsWith(`${prefix}/`)));
|
|
463
|
+
/**
|
|
464
|
+
* The limit declared in the route's own options.
|
|
465
|
+
*
|
|
466
|
+
* n8n writes `@Post('/login', { ipRateLimit: {...}, keyedRateLimit:
|
|
467
|
+
* createBodyKeyedRateLimiter(...) })` and applies both in its controller registry.
|
|
468
|
+
* Nothing in that file imports `express-rate-limit` — a service does — and nothing
|
|
469
|
+
* in it issues a 429, because the package does that. So a login throttled by IP and
|
|
470
|
+
* by email came back `partial`, cited on a Discord node's type guard for somebody
|
|
471
|
+
* else's 429.
|
|
472
|
+
*
|
|
473
|
+
* The word `rateLimit` alone is what dokploy taught us not to trust: its
|
|
474
|
+
* `rateLimitEnabled: z.boolean().optional()` is a field in a form schema. What makes
|
|
475
|
+
* this different is where the word sits — inside the declaration of a route whose
|
|
476
|
+
* path is a sign-in path, within a few lines of it — and that the project declares a
|
|
477
|
+
* limiter package at all. A schema field is neither.
|
|
478
|
+
*/
|
|
479
|
+
const AUTH_ROUTE_DECLARATION = /['"`]\/(?:login|signin|sign-in|sessions?|auth\/login)['"`]/i;
|
|
480
|
+
const LIMIT_IN_ROUTE_OPTIONS = /rate[_-]?limit|throttle/i;
|
|
481
|
+
const ROUTE_OPTIONS_WINDOW = 8;
|
|
482
|
+
const limitedAuthRoutes = [];
|
|
483
|
+
if (rateLimitDep) {
|
|
484
|
+
for (const file of authSurfaceFiles) {
|
|
485
|
+
const text = await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, file);
|
|
486
|
+
if (!text)
|
|
487
|
+
continue;
|
|
488
|
+
const lines = text.split(/\r?\n/);
|
|
489
|
+
for (let i = 0; i < lines.length; i++) {
|
|
490
|
+
if (!AUTH_ROUTE_DECLARATION.test(lines[i]))
|
|
491
|
+
continue;
|
|
492
|
+
const window = lines.slice(i + 1, i + 1 + ROUTE_OPTIONS_WINDOW);
|
|
493
|
+
const offset = window.findIndex((line) => LIMIT_IN_ROUTE_OPTIONS.test(line));
|
|
494
|
+
if (offset === -1)
|
|
495
|
+
continue;
|
|
496
|
+
limitedAuthRoutes.push({ file, line: i + 2 + offset, snippet: window[offset].trim().slice(0, 200) });
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
for (const hit of limitedAuthRoutes.slice(0, 3)) {
|
|
502
|
+
evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line, claim: 'rate-limit' });
|
|
503
|
+
}
|
|
436
504
|
const rateLimitNearAuth = coversAnAuthMount
|
|
505
|
+
|| limitedAuthRoutes.length > 0
|
|
437
506
|
|| (boundLimiterUses ?? []).some((use) => authSurfaceFiles.has(use.file))
|
|
438
507
|
|| issuedRateLimits.some((match) => authSurfaceFiles.has(match.file));
|
|
439
508
|
if (helmetDep)
|
|
440
509
|
evidence.push({ type: 'dependency', value: 'helmet', claim: 'headers' });
|
|
441
510
|
if (rateLimitDep)
|
|
442
511
|
evidence.push({ type: 'dependency', value: 'rate limiting package', claim: 'rate-limit' });
|
|
443
|
-
for (const m of
|
|
512
|
+
for (const m of headerSignals)
|
|
444
513
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'headers' });
|
|
445
514
|
for (const m of issuedRateLimits)
|
|
446
515
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'rate-limit' });
|
|
@@ -167,7 +167,21 @@ async function scanFiles(opts) {
|
|
|
167
167
|
ignore,
|
|
168
168
|
suppressErrors: true,
|
|
169
169
|
});
|
|
170
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Sorted, because every search below has a cap and the cap counts in this order.
|
|
172
|
+
*
|
|
173
|
+
* fast-glob returns what the filesystem hands it, which is the directory entry order
|
|
174
|
+
* on that machine. Every detector here reads the first N matches and stops, so the
|
|
175
|
+
* same repository could be read differently on two computers, or after a fresh clone
|
|
176
|
+
* — in a tool whose first word is "deterministic".
|
|
177
|
+
*
|
|
178
|
+
* It was found in n8n, where the four searches that decide a project's
|
|
179
|
+
* authentication surface filled their budget with third-party credential
|
|
180
|
+
* definitions and never reached `auth.controller.ts`. Which files won that race was
|
|
181
|
+
* nobody's decision. Sorting does not make the budget large enough; it makes the
|
|
182
|
+
* answer the same every time, which is the part that was promised.
|
|
183
|
+
*/
|
|
184
|
+
return withoutVirtualenvs(entries.map((e) => e.split(path.sep).join('/'))).sort();
|
|
171
185
|
}
|
|
172
186
|
/**
|
|
173
187
|
* Quick check: does at least one path matching glob exist?
|
|
@@ -28,5 +28,19 @@ export declare function matchLines(text: string, needles: Array<string | RegExp>
|
|
|
28
28
|
* Search a list of relative file paths for any of the given needles
|
|
29
29
|
* (string or RegExp). Returns at most `limit` matches.
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* A budget counts answers, not candidates.
|
|
33
|
+
*
|
|
34
|
+
* Several detectors search with a cap and then filter what came back — the header
|
|
35
|
+
* search drops lines that *read* a header rather than set one, the rate-limit search
|
|
36
|
+
* drops a 429 this project received rather than issued. The cap was spent on the
|
|
37
|
+
* candidates, so a repository with enough noise never handed the filter anything to
|
|
38
|
+
* keep: seventy files reading `content-security-policy` filled a budget of twenty,
|
|
39
|
+
* and the file setting one was never opened.
|
|
40
|
+
*
|
|
41
|
+
* Giving the filter to the search fixes it at the root. `keep` runs per match, and
|
|
42
|
+
* only a match it keeps costs budget, so the limit means what it says — "up to this
|
|
43
|
+
* many findings" — rather than "up to this many lines that might have been findings".
|
|
44
|
+
*/
|
|
45
|
+
export declare function searchInFiles(root: string, files: string[], needles: Array<string | RegExp>, limit?: number, keep?: (match: TextMatch) => boolean): Promise<TextMatch[]>;
|
|
32
46
|
export declare function anyIncludes(haystack: string, needles: string[]): boolean;
|
package/dist/utils/textSearch.js
CHANGED
|
@@ -137,7 +137,21 @@ function matchLines(text, needles, file = '') {
|
|
|
137
137
|
* Search a list of relative file paths for any of the given needles
|
|
138
138
|
* (string or RegExp). Returns at most `limit` matches.
|
|
139
139
|
*/
|
|
140
|
-
|
|
140
|
+
/**
|
|
141
|
+
* A budget counts answers, not candidates.
|
|
142
|
+
*
|
|
143
|
+
* Several detectors search with a cap and then filter what came back — the header
|
|
144
|
+
* search drops lines that *read* a header rather than set one, the rate-limit search
|
|
145
|
+
* drops a 429 this project received rather than issued. The cap was spent on the
|
|
146
|
+
* candidates, so a repository with enough noise never handed the filter anything to
|
|
147
|
+
* keep: seventy files reading `content-security-policy` filled a budget of twenty,
|
|
148
|
+
* and the file setting one was never opened.
|
|
149
|
+
*
|
|
150
|
+
* Giving the filter to the search fixes it at the root. `keep` runs per match, and
|
|
151
|
+
* only a match it keeps costs budget, so the limit means what it says — "up to this
|
|
152
|
+
* many findings" — rather than "up to this many lines that might have been findings".
|
|
153
|
+
*/
|
|
154
|
+
async function searchInFiles(root, files, needles, limit = 25, keep) {
|
|
141
155
|
const matches = [];
|
|
142
156
|
for (const file of files) {
|
|
143
157
|
if (matches.length >= limit)
|
|
@@ -160,7 +174,9 @@ async function searchInFiles(root, files, needles, limit = 25) {
|
|
|
160
174
|
for (const n of needles) {
|
|
161
175
|
const hit = typeof n === 'string' ? line.includes(n) : n.test(line);
|
|
162
176
|
if (hit) {
|
|
163
|
-
|
|
177
|
+
const match = { file, line: i + 1, snippet: line.trim().slice(0, 200) };
|
|
178
|
+
if (!keep || keep(match))
|
|
179
|
+
matches.push(match);
|
|
164
180
|
break;
|
|
165
181
|
}
|
|
166
182
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
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": {
|