@produtype/core 0.39.0 → 0.41.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.
|
@@ -45,6 +45,26 @@ function namesItself(snippet) {
|
|
|
45
45
|
const flatten = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
46
46
|
return flatten(assignment[1]) === flatten(assignment[2]);
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* The value is the name of something, spelled the way every language spells a name.
|
|
50
|
+
*
|
|
51
|
+
* `REDIS_SECRET_KEY = "SECRET_TOKEN"` in Discourse is the Redis key under which the
|
|
52
|
+
* secret is stored — the comment directly above it says so — and it was reported as a
|
|
53
|
+
* hardcoded secret at `critical`, the severity that bars a report from the top band.
|
|
54
|
+
* `namesItself` could not catch it: the constant and its value are two different
|
|
55
|
+
* names, so the letters do not match.
|
|
56
|
+
*
|
|
57
|
+
* What settles it is the shape. `SECRET_TOKEN` is SCREAMING_SNAKE_CASE, which is how
|
|
58
|
+
* constants, environment variables and Redis keys are written and not how secrets are
|
|
59
|
+
* written — a secret is entropy, and this has none. Kept to that one casing on
|
|
60
|
+
* purpose: `dev-secret` and `changeme` are lowercase and stay caught.
|
|
61
|
+
*/
|
|
62
|
+
function valueIsAnIdentifier(snippet) {
|
|
63
|
+
const assignment = /([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*["'`]([^"'`]+)["'`]/.exec(snippet);
|
|
64
|
+
if (!assignment)
|
|
65
|
+
return false;
|
|
66
|
+
return /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/.test(assignment[2]);
|
|
67
|
+
}
|
|
48
68
|
async function detectEnv(ctx) {
|
|
49
69
|
const evidence = [];
|
|
50
70
|
const weakSecretEvidence = [];
|
|
@@ -98,6 +118,7 @@ async function detectEnv(ctx) {
|
|
|
98
118
|
const weakHits = fallbackHits.filter((m) => WEAK_SECRET_VALUE_RE.test(m.snippet)
|
|
99
119
|
&& SECRET_ASSIGNMENT_CONTEXT_RE.test(m.snippet)
|
|
100
120
|
&& !namesItself(m.snippet)
|
|
121
|
+
&& !valueIsAnIdentifier(m.snippet)
|
|
101
122
|
&& !isTableEntry(m));
|
|
102
123
|
for (const m of weakHits) {
|
|
103
124
|
const hitEvidence = { type: 'snippet', value: m.snippet, file: m.file, line: m.line };
|
|
@@ -5,6 +5,7 @@ const detectContext_1 = require("./detectContext");
|
|
|
5
5
|
const readTextFileSafe_1 = require("../utils/readTextFileSafe");
|
|
6
6
|
const textSearch_1 = require("../utils/textSearch");
|
|
7
7
|
const absenceEvidence_1 = require("./absenceEvidence");
|
|
8
|
+
const developmentOnly_1 = require("./developmentOnly");
|
|
8
9
|
/** Lines that decide which origins may call this server. */
|
|
9
10
|
const ORIGIN_HANDLING = [/Access-Control-Allow-Origin/i, /ALLOWED_ORIGINS/, /allowedOrigins/i];
|
|
10
11
|
/**
|
|
@@ -145,6 +146,10 @@ async function detectSecurity(ctx) {
|
|
|
145
146
|
const corsLoose = [];
|
|
146
147
|
const corsStrict = [];
|
|
147
148
|
for (const file of source) {
|
|
149
|
+
// Rails picks one environment file by RAILS_ENV, so a header set in
|
|
150
|
+
// development.rb is not a header this product sends.
|
|
151
|
+
if ((0, developmentOnly_1.isDevelopmentOnlyFile)(file))
|
|
152
|
+
continue;
|
|
148
153
|
const text = await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, file);
|
|
149
154
|
if (!text)
|
|
150
155
|
continue;
|
|
@@ -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) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isDevelopmentOnlyFile(file: string): boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isDevelopmentOnlyFile = isDevelopmentOnlyFile;
|
|
4
|
+
/**
|
|
5
|
+
* A file the product does not run in production.
|
|
6
|
+
*
|
|
7
|
+
* Discourse was reported as allowing every cross-origin request, and one of the two
|
|
8
|
+
* lines behind it was `config/environments/development.rb:34`. Rails loads exactly one
|
|
9
|
+
* of those files, chosen by `RAILS_ENV`, so a header set in `development.rb` is as
|
|
10
|
+
* absent from production as a line inside `if settings.DEBUG:` — the same mistake this
|
|
11
|
+
* analyzer had already made in Python and fixed there.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately only the frameworks that name the environment in the path, because
|
|
14
|
+
* that is the only case where the file's own name settles the question. A file called
|
|
15
|
+
* `dev-server.js` might be anything; `config/environments/test.rb` cannot be.
|
|
16
|
+
*/
|
|
17
|
+
const DEVELOPMENT_ONLY_PATHS = [
|
|
18
|
+
/(^|\/)config\/environments\/(?!production)[a-z_]+\.rb$/i,
|
|
19
|
+
/(^|\/)config\/environments\/(?!production)[a-z_]+\.exs$/i,
|
|
20
|
+
];
|
|
21
|
+
function isDevelopmentOnlyFile(file) {
|
|
22
|
+
return DEVELOPMENT_ONLY_PATHS.some((pattern) => pattern.test(file));
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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": {
|