@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.find((f) => /(^|\/)Dockerfile$/.test(f));
9
- const composeFile = ctx.files.all.find((f) => /(^|\/)(docker-compose\.ya?ml|compose\.ya?ml)$/.test(f));
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
- const hasEnvExample = ctx.files.all.includes('.env.example');
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
- if (hasEnvExample)
110
- evidence.push({ type: 'file', value: '.env.example' });
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
- 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))
418
- .map((match) => match.file));
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.8.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": {