@produtype/core 0.39.0 → 0.40.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.
@@ -25,6 +25,84 @@ function findExpressUploadsRoutes(text, file) {
25
25
  }
26
26
  return out;
27
27
  }
28
+ /**
29
+ * The lines of a Python file that only run in development.
30
+ *
31
+ * `urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)` is the
32
+ * line Django's own documentation gives for serving uploaded files, and the
33
+ * documentation wraps it in `if settings.DEBUG:` — which is the whole point of the
34
+ * snippet. This detector already said so in a comment and then counted the line anyway,
35
+ * so a travel application was told its uploads were exposed to the public by a line
36
+ * that does not run in production.
37
+ *
38
+ * Block extent by indentation, which is how Python says it.
39
+ */
40
+ function debugOnlyLines(text) {
41
+ const guarded = new Set();
42
+ const lines = text.split(/\r?\n/);
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const opener = lines[i].match(/^(\s*)if\s+(?:settings\.)?DEBUG\s*:/);
45
+ if (!opener)
46
+ continue;
47
+ const indent = opener[1].length;
48
+ for (let j = i + 1; j < lines.length; j++) {
49
+ const line = lines[j];
50
+ if (line.trim() === '') {
51
+ guarded.add(j + 1);
52
+ continue;
53
+ }
54
+ const lineIndent = line.length - line.trimStart().length;
55
+ if (lineIndent <= indent)
56
+ break;
57
+ guarded.add(j + 1);
58
+ }
59
+ }
60
+ return guarded;
61
+ }
62
+ /**
63
+ * A URL pattern that serves uploaded files, as distinct from one that serves the
64
+ * stylesheet.
65
+ *
66
+ * `document_root=` alone matched `static(settings.STATIC_URL, document_root=STATIC_ROOT)`
67
+ * — CSS and JavaScript, which every Django project serves and nobody uploads. Two of
68
+ * the projects reported as exposing their uploads were exposing their stylesheets.
69
+ */
70
+ const DJANGO_MEDIA_ROUTES = [
71
+ /static\s*\(\s*settings\.MEDIA_URL/i,
72
+ /document_root\s*=\s*settings\.MEDIA_ROOT/i,
73
+ /re_path\s*\(\s*r?['"][^'"]*media/i,
74
+ /url\s*\(\s*r?['"][^'"]*media/i,
75
+ ];
76
+ /**
77
+ * Checking the type of a file somebody uploaded.
78
+ *
79
+ * This was `/mime/i` and `/content-type/i`, which is every JSON response header in
80
+ * every project: `res.writeHead(404, { 'Content-Type': 'application/json' })` was
81
+ * cited as upload validation, and so was `X-Content-Type-Options: nosniff`. The flag
82
+ * came out true for all six projects in the corpus that handle uploads — a measurement
83
+ * that never varies is not one — and it decides whether upload protection reads
84
+ * `present` or `partial`, so two projects were credited for a header on a 404.
85
+ */
86
+ const DJANGO_VIEW_PROTECTION = [
87
+ /@login_required/,
88
+ /LoginRequiredMixin/,
89
+ /PermissionRequiredMixin/,
90
+ /@user_passes_test/,
91
+ /@permission_required/,
92
+ ];
93
+ /** A file that does something with an uploaded file, rather than one that logs people in. */
94
+ const HANDLES_FILES = /request\.FILES|FileField|ImageField|\bupload/i;
95
+ const UPLOAD_VALIDATION = [
96
+ /\bfileFilter\b/,
97
+ /file\.mimetype/i,
98
+ /files?\[[^\]]*\]\.mimetype/i,
99
+ /allowed(?:File|Mime|Content)?_?(?:Types|Extensions)/i,
100
+ /FileExtensionValidator/,
101
+ /validate_image_file_extension/,
102
+ /\bfileTypeFrom(?:Buffer|File|Stream)\b/,
103
+ /request\.FILES\[[^\]]*\]\.content_type/,
104
+ /\.content_type\s*(?:not\s+)?in\b/,
105
+ ];
28
106
  async function detectUploads(ctx) {
29
107
  const evidence = [];
30
108
  const uploadDeps = (0, detectContext_1.hasAnyDep)(ctx, ['multer', 'formidable', '@aws-sdk/client-s3', 'aws-sdk']);
@@ -65,28 +143,35 @@ async function detectUploads(ctx) {
65
143
  * A real report on a real school platform called its uploads publicly exposed on the
66
144
  * strength of those two lines, with no pattern serving media anywhere in the project.
67
145
  */
68
- const djangoPublicSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [
69
- /static\s*\(\s*settings\.MEDIA_URL/i,
70
- /document_root\s*=/i,
71
- /re_path\s*\(\s*r?['"][^'"]*media/i,
72
- /url\s*\(\s*r?['"][^'"]*media/i,
73
- ], 15);
74
- /**
75
- * Django's own way of protecting a view: a decorator or a mixin, not middleware on a
76
- * route. The Express-shaped route scan cannot see either, so a project whose upload
77
- * view is `@login_required` read as having no protection at all.
78
- */
79
- const djangoProtectionSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [
80
- /@login_required/,
81
- /LoginRequiredMixin/,
82
- /PermissionRequiredMixin/,
83
- /@user_passes_test/,
84
- /@permission_required/,
85
- ], 15);
146
+ const djangoPublicSignals = [];
147
+ const djangoProtectionSignals = [];
148
+ const validationSignals = [];
149
+ for (const file of source) {
150
+ const text = await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, file);
151
+ if (!text)
152
+ continue;
153
+ validationSignals.push(...(0, textSearch_1.matchLines)(text, UPLOAD_VALIDATION, file));
154
+ if (!file.endsWith('.py'))
155
+ continue;
156
+ const developmentOnly = debugOnlyLines(text);
157
+ djangoPublicSignals.push(...(0, textSearch_1.matchLines)(text, DJANGO_MEDIA_ROUTES, file).filter((m) => !developmentOnly.has(m.line)));
158
+ /**
159
+ * Django's own way of protecting a view: a decorator or a mixin, not middleware on
160
+ * a route. The Express-shaped route scan cannot see either, so a project whose
161
+ * upload view is `@login_required` read as having no protection at all.
162
+ *
163
+ * Only in a file that handles files. This detector argues, three comments above,
164
+ * that authentication is not evidence about uploads — and then counted every
165
+ * `@login_required` in the project as upload protection: a photography business
166
+ * was credited for the decorators on its accounting views.
167
+ */
168
+ if (HANDLES_FILES.test(text)) {
169
+ djangoProtectionSignals.push(...(0, textSearch_1.matchLines)(text, DJANGO_VIEW_PROTECTION, file));
170
+ }
171
+ }
86
172
  for (const m of djangoProtectionSignals) {
87
173
  evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'protection' });
88
174
  }
89
- const validationSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/file-type/i, /mime/i, /content-type/i], 15);
90
175
  const protectedRoutes = routeSignals.filter((r) => r.protected);
91
176
  const unprotectedRoutes = routeSignals.filter((r) => !r.protected);
92
177
  for (const r of routeSignals) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.39.0",
3
+ "version": "0.40.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": {