@produtype/core 0.8.0 → 0.8.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.
@@ -44,7 +44,38 @@ async function detectUploads(ctx) {
44
44
  routeSignals.push(...findExpressUploadsRoutes(text, file));
45
45
  }
46
46
  const source = ctx.files.source;
47
- const djangoPublicSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/MEDIA_ROOT/i, /MEDIA_URL/i], 15);
47
+ /**
48
+ * Django media served to the public, which is a route and not a setting.
49
+ *
50
+ * This matched `MEDIA_URL` and `MEDIA_ROOT` — two lines in settings.py that say where
51
+ * uploaded files live on disk and under which prefix they *would* be served. Neither
52
+ * serves anything. Django exposes them only when a URL pattern says so, and the
53
+ * idiomatic one is wrapped in `if settings.DEBUG`.
54
+ *
55
+ * A real report on a real school platform called its uploads publicly exposed on the
56
+ * strength of those two lines, with no pattern serving media anywhere in the project.
57
+ */
58
+ const djangoPublicSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [
59
+ /static\s*\(\s*settings\.MEDIA_URL/i,
60
+ /document_root\s*=/i,
61
+ /re_path\s*\(\s*r?['"][^'"]*media/i,
62
+ /url\s*\(\s*r?['"][^'"]*media/i,
63
+ ], 15);
64
+ /**
65
+ * Django's own way of protecting a view: a decorator or a mixin, not middleware on a
66
+ * route. The Express-shaped route scan cannot see either, so a project whose upload
67
+ * view is `@login_required` read as having no protection at all.
68
+ */
69
+ const djangoProtectionSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [
70
+ /@login_required/,
71
+ /LoginRequiredMixin/,
72
+ /PermissionRequiredMixin/,
73
+ /@user_passes_test/,
74
+ /@permission_required/,
75
+ ], 15);
76
+ for (const m of djangoProtectionSignals) {
77
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
78
+ }
48
79
  const validationSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/file-type/i, /mime/i, /content-type/i], 15);
49
80
  const protectedRoutes = routeSignals.filter((r) => r.protected);
50
81
  const unprotectedRoutes = routeSignals.filter((r) => !r.protected);
@@ -56,6 +87,7 @@ async function detectUploads(ctx) {
56
87
  for (const m of validationSignals)
57
88
  evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
58
89
  const publicExposure = unprotectedRoutes.length > 0 || djangoPublicSignals.length > 0;
90
+ const protectedSomehow = protectedRoutes.length > 0 || djangoProtectionSignals.length > 0;
59
91
  return {
60
92
  key: 'uploads.exposure',
61
93
  present: uploadDeps.length > 0 || routeSignals.length > 0 || djangoPublicSignals.length > 0,
@@ -63,7 +95,7 @@ async function detectUploads(ctx) {
63
95
  evidence,
64
96
  details: {
65
97
  publicExposure,
66
- protectedUploads: protectedRoutes.length > 0,
98
+ protectedUploads: protectedSomehow,
67
99
  protectedUploadsSameRoute: protectedRoutes.length > 0,
68
100
  unprotectedUploadRoutes: unprotectedRoutes.length,
69
101
  validation: validationSignals.length > 0,
@@ -96,7 +96,20 @@ function deriveStatus(analysis, capability) {
96
96
  return 'present';
97
97
  if (loose)
98
98
  return 'partial';
99
- return 'missing';
99
+ /**
100
+ * A cross-origin policy is only a question for something that answers
101
+ * cross-origin requests.
102
+ *
103
+ * A server-rendered monolith with no API, no CORS library installed and no
104
+ * cross-origin handling anywhere is not missing a policy — the browser's own
105
+ * default already refuses those requests, and the absence *is* the safe
106
+ * configuration. Reporting it as a critical gap rewards adding middleware that
107
+ * can only loosen what is currently closed.
108
+ *
109
+ * An independent review of a report on a Django school platform put it as
110
+ * "not applicable, and inverted". It was right.
111
+ */
112
+ return servesCrossOrigin(analysis) ? 'missing' : 'not_applicable';
100
113
  }
101
114
  case 'security.rate-limit': {
102
115
  return boolDetail(sec, 'rateLimit') ? 'present' : 'missing';
@@ -275,6 +288,22 @@ function evidenceFor(analysis, capability) {
275
288
  .map((key) => detector(analysis, key))
276
289
  .filter(Boolean);
277
290
  }
291
+ /**
292
+ * Whether anything here could receive a cross-origin request.
293
+ *
294
+ * An API surface, a framework built to serve one, or a CORS library someone installed
295
+ * on purpose. None of the three means the question does not arise.
296
+ */
297
+ function servesCrossOrigin(analysis) {
298
+ // Somebody wrote cross-origin handling, however badly: the question plainly arises.
299
+ const sec = detector(analysis, 'security.core');
300
+ if (sec?.evidence.some((item) => /cors/i.test(String(item.value))))
301
+ return true;
302
+ // An API meant for other callers.
303
+ if (detector(analysis, 'auth.apiKeys')?.present)
304
+ return true;
305
+ return analysis.files.source.some((file) => /(^|\/)(api|routes?|controllers?|serializers?|graphql)(\/|\.)/i.test(file));
306
+ }
278
307
  function evidenceQualityFor(detectors) {
279
308
  if (detectors.some((detectorResult) => detectorResult.evidence.some((item) => item.type === 'file' && typeof item.line === 'number'))) {
280
309
  return 'strong';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.8.0",
3
+ "version": "0.8.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": {