@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 = 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) {
@@ -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.39.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": {