@produtype/core 0.89.0 → 0.91.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.
@@ -18,6 +18,8 @@ const ORIGIN_HANDLING = [/Access-Control-Allow-Origin/i, /ALLOWED_ORIGINS/, /all
18
18
  * `ALLOWED_ORIGINS = ["*"]` are the same decision written in two frameworks, and only
19
19
  * the first was being caught.
20
20
  */
21
+ /** `== StatusCode::TOO_MANY_REQUESTS` and its spellings: a status being read. */
22
+ const COMPARES_A_STATUS = /[=!]==?\s*(?:StatusCode::TOO_MANY_REQUESTS|http\.StatusTooManyRequests|HttpStatus\.TOO_MANY_REQUESTS|HttpStatusCode\.TooManyRequests|Status429TooManyRequests)|(?:StatusCode::TOO_MANY_REQUESTS|http\.StatusTooManyRequests|HttpStatus\.TOO_MANY_REQUESTS|HttpStatusCode\.TooManyRequests)\s*[=!]==?/;
21
23
  const WILDCARD_ORIGIN = /(Access-Control-Allow-Origin["'\s:,]+\*)|(["']\*["'])/i;
22
24
  /**
23
25
  * A line that names an origin somebody chose.
@@ -194,7 +196,45 @@ async function detectSecurity(ctx) {
194
196
  /^\s*SECURE_CROSS_ORIGIN_OPENER_POLICY\s*=/m,
195
197
  /^\s*CSP_DEFAULT_SRC\s*=/m,
196
198
  ], 20);
197
- const helmet = helmetDep || headerSignals.length > 0;
199
+ /**
200
+ * Spring Security, which writes the headers without being asked.
201
+ *
202
+ * Adding `spring-boot-starter-security` and configuring an `HttpSecurity` chain
203
+ * gives every response `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`
204
+ * and a no-store `Cache-Control` — the framework's defaults, applied whether or not
205
+ * anybody writes a line about headers. shopizer does exactly that and was told it
206
+ * has none.
207
+ *
208
+ * Unlike Django's `SecurityMiddleware`, which is deliberately not counted a few
209
+ * lines down: `django-admin startproject` writes that into every new project, so it
210
+ * distinguishes nothing, and its HSTS and nosniff behaviour still waits on
211
+ * `SECURE_*` settings. This starter is not in every Spring project — petclinic has
212
+ * no security at all — and its defaults need no settings.
213
+ *
214
+ * The chain is what is required. A project can pull the starter for method-level
215
+ * authorization in something that serves no requests, and then there is no filter
216
+ * chain and no headers — which is what the second fixture holds.
217
+ *
218
+ * The dependency check in front of it is a pre-filter and nothing more: it keeps
219
+ * this search off every file of every non-Spring repository. Removing it fails no
220
+ * test, and the comment says so rather than implying a safety it does not provide.
221
+ */
222
+ const springSecurity = (0, detectContext_1.hasAnyGradleDep)(ctx, ['spring-boot-starter-security', 'spring-security-config']);
223
+ const springFilterChain = springSecurity.length > 0
224
+ ? await (0, textSearch_1.searchInFiles)(ctx.root, source, [/HttpSecurity\s+\w+|\bhttp\s*\n?\s*\.\s*(?:authorizeHttpRequests|authorizeRequests|securityMatcher|antMatcher)/], 2)
225
+ : [];
226
+ if (springFilterChain.length > 0) {
227
+ for (const dep of springSecurity)
228
+ evidence.push({ type: 'dependency', value: dep, claim: 'headers' });
229
+ evidence.push({
230
+ type: 'snippet',
231
+ value: springFilterChain[0].snippet,
232
+ file: springFilterChain[0].file,
233
+ line: springFilterChain[0].line,
234
+ claim: 'headers',
235
+ });
236
+ }
237
+ const helmet = helmetDep || headerSignals.length > 0 || springFilterChain.length > 0;
198
238
  /**
199
239
  * The packages the ecosystem names, as distinct from the variables authors do.
200
240
  * Shared between the dependency check below and the binding walk further down.
@@ -252,8 +292,36 @@ async function detectSecurity(ctx) {
252
292
  /HTTPException\(\s*429/,
253
293
  /['"`]Retry-After['"`]\s*[,:]/i,
254
294
  /setHeader\(\s*['"`]Retry-After/i,
295
+ /**
296
+ * The same refusal, spelled the way each platform spells it.
297
+ *
298
+ * Every shape above is JavaScript or Python, so a service that throttles in any
299
+ * other language had to declare a package to be seen at all. crates.io writes
300
+ * its own limiter — `src/rate_limiter.rs`, no crate — and refuses with
301
+ * `StatusCode::TOO_MANY_REQUESTS`; it was told at `high` that it does not
302
+ * throttle.
303
+ *
304
+ * These are constants the standard library or the framework defines for one
305
+ * number in RFC 6585. Nobody picks the name, and each of them is the server
306
+ * issuing the refusal rather than reading one: a constant is what you construct
307
+ * a response from, where receiving is a comparison against `.status`.
308
+ */
309
+ /StatusCode::TOO_MANY_REQUESTS/,
310
+ /http\.StatusTooManyRequests/,
311
+ /HttpStatus\.TOO_MANY_REQUESTS/,
312
+ /HttpStatusCode\.TooManyRequests/,
313
+ /Status429TooManyRequests/,
255
314
  ], 20);
256
- const rateLimit = rateLimitDep || rateLimitSignals.length > 0;
315
+ /**
316
+ * Still issuing, not receiving — the constants need the same test the numbers got.
317
+ *
318
+ * `res.status(429)` can only be a server refusing, but
319
+ * `if resp.status() == StatusCode::TOO_MANY_REQUESTS` is this project being
320
+ * refused by somebody else's, which is what nocodb's webhook invoker does. A
321
+ * comparison is the reading direction; an argument is the writing one.
322
+ */
323
+ const issuedRateLimits = rateLimitSignals.filter((hit) => !COMPARES_A_STATUS.test(hit.snippet));
324
+ const rateLimit = rateLimitDep || issuedRateLimits.length > 0;
257
325
  /**
258
326
  * Rate limiting where the brute force happens.
259
327
  *
@@ -297,14 +365,14 @@ async function detectSecurity(ctx) {
297
365
  const coversAnAuthMount = mountedPaths.some((prefix) => authMountPaths.some((mount) => mount === prefix || mount.startsWith(`${prefix}/`)));
298
366
  const rateLimitNearAuth = coversAnAuthMount
299
367
  || (boundLimiterUses ?? []).some((use) => authSurfaceFiles.has(use.file))
300
- || rateLimitSignals.some((match) => authSurfaceFiles.has(match.file));
368
+ || issuedRateLimits.some((match) => authSurfaceFiles.has(match.file));
301
369
  if (helmetDep)
302
370
  evidence.push({ type: 'dependency', value: 'helmet', claim: 'headers' });
303
371
  if (rateLimitDep)
304
372
  evidence.push({ type: 'dependency', value: 'rate limiting package', claim: 'rate-limit' });
305
373
  for (const m of headerSignals)
306
374
  evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'headers' });
307
- for (const m of rateLimitSignals)
375
+ for (const m of issuedRateLimits)
308
376
  evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'rate-limit' });
309
377
  /**
310
378
  * Every identifier the `cors` package reaches, per file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.89.0",
3
+ "version": "0.91.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": {