@produtype/core 1.9.0 → 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
  *
@@ -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.
@@ -501,7 +509,7 @@ async function detectSecurity(ctx) {
501
509
  evidence.push({ type: 'dependency', value: 'helmet', claim: 'headers' });
502
510
  if (rateLimitDep)
503
511
  evidence.push({ type: 'dependency', value: 'rate limiting package', claim: 'rate-limit' });
504
- for (const m of headerSignalsThatSet)
512
+ for (const m of headerSignals)
505
513
  evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'headers' });
506
514
  for (const m of issuedRateLimits)
507
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.9.0",
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": {