@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.
- package/dist/analyzer/detectUploads.js +104 -19
- package/package.json +1 -1
|
@@ -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 =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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.
|
|
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": {
|