@produtype/core 1.8.1 → 1.9.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.
|
@@ -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 = [];
|
|
@@ -104,10 +104,29 @@ async function detectEnv(ctx) {
|
|
|
104
104
|
apiKey: [],
|
|
105
105
|
unknown: [],
|
|
106
106
|
};
|
|
107
|
-
|
|
107
|
+
/**
|
|
108
|
+
* The template has more than one spelling, and one exact string knew one of them.
|
|
109
|
+
*
|
|
110
|
+
* immich ships `docker/example.env` — the name reversed, and one directory down,
|
|
111
|
+
* which is where a compose deployment keeps it — and was told at `medium` that it
|
|
112
|
+
* reads environment variables without publishing a template. The check was
|
|
113
|
+
* `files.all.includes('.env.example')`: the right idea matched against a single
|
|
114
|
+
* literal at the root.
|
|
115
|
+
*
|
|
116
|
+
* The vocabulary is small and conventional, so it is written out: `.env` followed
|
|
117
|
+
* by example, sample, template, dist or defaults, the same words in front of `.env`
|
|
118
|
+
* instead, and the `.env.local.example` shape a monorepo uses per application.
|
|
119
|
+
*
|
|
120
|
+
* `.env` itself is deliberately not in it, and neither is `.env.production`: the
|
|
121
|
+
* first is the real file — a different finding when it is committed — and the second
|
|
122
|
+
* is one environment's values rather than a blank somebody fills in.
|
|
123
|
+
*/
|
|
124
|
+
const ENV_TEMPLATE = /(^|\/)(?:\.?env(?:\.[a-z0-9-]+)?\.(?:example|sample|template|dist|defaults)|(?:example|sample|template)\.env)$/i;
|
|
125
|
+
const envTemplates = ctx.files.all.filter((file) => ENV_TEMPLATE.test(file));
|
|
126
|
+
const hasEnvExample = envTemplates.length > 0;
|
|
108
127
|
const hasEnv = ctx.files.all.includes('.env');
|
|
109
|
-
|
|
110
|
-
evidence.push({ type: 'file', value:
|
|
128
|
+
for (const file of envTemplates.slice(0, 3))
|
|
129
|
+
evidence.push({ type: 'file', value: file, file });
|
|
111
130
|
if (hasEnv)
|
|
112
131
|
evidence.push({ type: 'file', value: '.env' });
|
|
113
132
|
const sourceFiles = ctx.files.source;
|
|
@@ -414,8 +414,27 @@ async function detectSecurity(ctx) {
|
|
|
414
414
|
* structural reader here keeps.
|
|
415
415
|
*/
|
|
416
416
|
const boundLimiterUses = await (0, valuesFromPackage_1.readPackageValueUses)(ctx.root, source, RATE_LIMIT_PACKAGES);
|
|
417
|
-
|
|
418
|
-
|
|
417
|
+
/**
|
|
418
|
+
* One budget per pattern, because a noisy one was spending the whole thing.
|
|
419
|
+
*
|
|
420
|
+
* These four searches ran as one with a cap of forty matches, and the cap decided
|
|
421
|
+
* the answer. n8n ships about four hundred `*.credentials.ts` files describing how
|
|
422
|
+
* to authenticate to *other people's* APIs — Airtop, Action Network, Microsoft — and
|
|
423
|
+
* they sort ahead of `packages/cli/src/controllers/auth.controller.ts`. Thirty-eight
|
|
424
|
+
* files of third-party credential definitions filled the budget, n8n's own sign-in
|
|
425
|
+
* controller never entered the set, and its login — throttled by IP and by email —
|
|
426
|
+
* was reported unprotected.
|
|
427
|
+
*
|
|
428
|
+
* A cap is there to bound work, and it had come to decide which files are this
|
|
429
|
+
* project's authentication surface. Per pattern, the decisive search keeps its own
|
|
430
|
+
* budget however much noise the others find.
|
|
431
|
+
*/
|
|
432
|
+
const AUTH_SURFACE_PATTERNS = [/['"`]\/(login|signin|sign-in|auth|session)/i, /passport\./, /signIn\s*\(/, /authenticate\s*\(/];
|
|
433
|
+
const authSurfaceFiles = new Set();
|
|
434
|
+
for (const pattern of AUTH_SURFACE_PATTERNS) {
|
|
435
|
+
for (const match of await (0, textSearch_1.searchInFiles)(ctx.root, source, [pattern], 40))
|
|
436
|
+
authSurfaceFiles.add(match.file);
|
|
437
|
+
}
|
|
419
438
|
/**
|
|
420
439
|
* A limiter mounted on a prefix covers what is mounted under it.
|
|
421
440
|
*
|
|
@@ -433,7 +452,49 @@ async function detectSecurity(ctx) {
|
|
|
433
452
|
.map((match) => /\buse\(\s*['"`](\/[^'"`]*)['"`]/.exec(match.snippet)?.[1])
|
|
434
453
|
.filter((path) => Boolean(path));
|
|
435
454
|
const coversAnAuthMount = mountedPaths.some((prefix) => authMountPaths.some((mount) => mount === prefix || mount.startsWith(`${prefix}/`)));
|
|
455
|
+
/**
|
|
456
|
+
* The limit declared in the route's own options.
|
|
457
|
+
*
|
|
458
|
+
* n8n writes `@Post('/login', { ipRateLimit: {...}, keyedRateLimit:
|
|
459
|
+
* createBodyKeyedRateLimiter(...) })` and applies both in its controller registry.
|
|
460
|
+
* Nothing in that file imports `express-rate-limit` — a service does — and nothing
|
|
461
|
+
* in it issues a 429, because the package does that. So a login throttled by IP and
|
|
462
|
+
* by email came back `partial`, cited on a Discord node's type guard for somebody
|
|
463
|
+
* else's 429.
|
|
464
|
+
*
|
|
465
|
+
* The word `rateLimit` alone is what dokploy taught us not to trust: its
|
|
466
|
+
* `rateLimitEnabled: z.boolean().optional()` is a field in a form schema. What makes
|
|
467
|
+
* this different is where the word sits — inside the declaration of a route whose
|
|
468
|
+
* path is a sign-in path, within a few lines of it — and that the project declares a
|
|
469
|
+
* limiter package at all. A schema field is neither.
|
|
470
|
+
*/
|
|
471
|
+
const AUTH_ROUTE_DECLARATION = /['"`]\/(?:login|signin|sign-in|sessions?|auth\/login)['"`]/i;
|
|
472
|
+
const LIMIT_IN_ROUTE_OPTIONS = /rate[_-]?limit|throttle/i;
|
|
473
|
+
const ROUTE_OPTIONS_WINDOW = 8;
|
|
474
|
+
const limitedAuthRoutes = [];
|
|
475
|
+
if (rateLimitDep) {
|
|
476
|
+
for (const file of authSurfaceFiles) {
|
|
477
|
+
const text = await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, file);
|
|
478
|
+
if (!text)
|
|
479
|
+
continue;
|
|
480
|
+
const lines = text.split(/\r?\n/);
|
|
481
|
+
for (let i = 0; i < lines.length; i++) {
|
|
482
|
+
if (!AUTH_ROUTE_DECLARATION.test(lines[i]))
|
|
483
|
+
continue;
|
|
484
|
+
const window = lines.slice(i + 1, i + 1 + ROUTE_OPTIONS_WINDOW);
|
|
485
|
+
const offset = window.findIndex((line) => LIMIT_IN_ROUTE_OPTIONS.test(line));
|
|
486
|
+
if (offset === -1)
|
|
487
|
+
continue;
|
|
488
|
+
limitedAuthRoutes.push({ file, line: i + 2 + offset, snippet: window[offset].trim().slice(0, 200) });
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
for (const hit of limitedAuthRoutes.slice(0, 3)) {
|
|
494
|
+
evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line, claim: 'rate-limit' });
|
|
495
|
+
}
|
|
436
496
|
const rateLimitNearAuth = coversAnAuthMount
|
|
497
|
+
|| limitedAuthRoutes.length > 0
|
|
437
498
|
|| (boundLimiterUses ?? []).some((use) => authSurfaceFiles.has(use.file))
|
|
438
499
|
|| issuedRateLimits.some((match) => authSurfaceFiles.has(match.file));
|
|
439
500
|
if (helmetDep)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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": {
|