@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)).filter((hit) => !LARAVEL_SERVICE_SLOTS.test(hit.file));
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.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 = [];
@@ -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 || headerSignalsThatSet.length > 0 || springFilterChain.length > 0;
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.filter((hit) => !COMPARES_A_STATUS.test(hit.snippet));
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
- 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));
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 headerSignalsThatSet)
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
- return withoutVirtualenvs(entries.map((e) => e.split(path.sep).join('/')));
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
- export declare function searchInFiles(root: string, files: string[], needles: Array<string | RegExp>, limit?: number): Promise<TextMatch[]>;
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;
@@ -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
- async function searchInFiles(root, files, needles, limit = 25) {
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
- matches.push({ file, line: i + 1, snippet: line.trim().slice(0, 200) });
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.8.2",
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": {