@produtype/core 0.38.0 → 0.39.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.
@@ -5,6 +5,43 @@ 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
+ /** Lines that decide which origins may call this server. */
9
+ const ORIGIN_HANDLING = [/Access-Control-Allow-Origin/i, /ALLOWED_ORIGINS/, /allowedOrigins/i];
10
+ /**
11
+ * The same line, allowing everyone.
12
+ *
13
+ * Both spellings, because `'Access-Control-Allow-Origin', '*'` and
14
+ * `ALLOWED_ORIGINS = ["*"]` are the same decision written in two frameworks, and only
15
+ * the first was being caught.
16
+ */
17
+ const WILDCARD_ORIGIN = /(Access-Control-Allow-Origin["'\s:,]+\*)|(["']\*["'])/i;
18
+ /**
19
+ * Flask's answer, which was invisible.
20
+ *
21
+ * `\bcors\s*\(` is case-sensitive, so `CORS(app)` matched nothing, and the extension's
22
+ * default is to allow every origin — the one configuration this check exists to find.
23
+ * A Flask application that opens itself to the whole web in one line was reported as
24
+ * having no cross-origin configuration at all.
25
+ */
26
+ const IMPORTS_FLASK_CORS = /from\s+flask_cors\s+import|import\s+flask_cors/;
27
+ const FLASK_CORS_CALL = /\bCORS\s*\(/;
28
+ /**
29
+ * The line with its quoted text removed.
30
+ *
31
+ * `cors(` inside a string literal is never the middleware being applied — it is prose
32
+ * about it. This tool's own remediation catalogue, "Replace cors() defaults with an
33
+ * explicit allowlist.", was being cited as a CORS configuration, and no rule about the
34
+ * shape of the line could help: a standalone string in a list is deliberately not
35
+ * skipped, because `'django.contrib.auth',` in INSTALLED_APPS is the behaviour itself.
36
+ *
37
+ * Narrow on purpose. It is applied to this one question, where a call is what is being
38
+ * looked for, and not to the file search, where a string is often the answer.
39
+ */
40
+ function withoutStringLiterals(line) {
41
+ return line.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, '""');
42
+ }
43
+ /** The Express middleware, brought into the file that configures it. */
44
+ const IMPORTS_CORS = /(require\(['"]cors['"]\))|(from\s+['"]cors['"])|(import\s+['"]cors['"])/;
8
45
  function detectCorsConfig(text, file) {
9
46
  const loose = [];
10
47
  const strict = [];
@@ -12,7 +49,9 @@ function detectCorsConfig(text, file) {
12
49
  const corsVarRegex = /\bcors\(\s*([A-Za-z_$][\w$]*)\s*\)/;
13
50
  for (let i = 0; i < lines.length; i++) {
14
51
  const line = lines[i];
15
- if (!/\bcors\s*\(/.test(line))
52
+ if (!(0, textSearch_1.isCitableLine)(line))
53
+ continue;
54
+ if (!/\bcors\s*\(/.test(withoutStringLiterals(line)))
16
55
  continue;
17
56
  const snippet = line.trim().slice(0, 200);
18
57
  if (/\bcors\(\s*\)/.test(line)) {
@@ -41,6 +80,35 @@ function detectCorsConfig(text, file) {
41
80
  }
42
81
  return { loose, strict };
43
82
  }
83
+ /**
84
+ * A settings file that is Django's, rather than one that shares its name.
85
+ *
86
+ * `diet_hub/api/settings.py` is a FastAPI router that lets an administrator change
87
+ * application options from the browser. It was read as Django's configuration, and on
88
+ * the strength of the filename alone the project was credited with having security
89
+ * middleware it does not have — the one direction of error that matters here, because
90
+ * it hides a missing control rather than inventing a present one.
91
+ *
92
+ * Two conditions, because either alone is wrong. A repository can depend on Django and
93
+ * still own a dozen files called settings.py; a file can declare INSTALLED_APPS in a
94
+ * tutorial that the product does not run. And the first file named settings.py is not
95
+ * the right one: a project with `settings/base.py` and `settings/production.py` has
96
+ * several, so every candidate is examined and the first that is Django's is used.
97
+ */
98
+ async function findDjangoSettings(ctx) {
99
+ if (!ctx.pythonDeps.includes('django'))
100
+ return null;
101
+ const candidates = ctx.files.all.filter((f) => /(^|\/)settings(_[a-z]+)?\.py$/i.test(f) || /(^|\/)settings\/[a-z_]+\.py$/i.test(f));
102
+ for (const file of candidates) {
103
+ const text = await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, file);
104
+ if (!text)
105
+ continue;
106
+ if (/^\s*INSTALLED_APPS\s*=/m.test(text) || /^\s*MIDDLEWARE\s*=/m.test(text) || /DJANGO_SETTINGS_MODULE/.test(text)) {
107
+ return { file, text };
108
+ }
109
+ }
110
+ return null;
111
+ }
44
112
  async function detectSecurity(ctx) {
45
113
  const evidence = [];
46
114
  const source = ctx.files.source;
@@ -82,14 +150,35 @@ async function detectSecurity(ctx) {
82
150
  continue;
83
151
  // Framework-native CORS: an explicit allowlist checked against the Origin header,
84
152
  // rather than the Express cors() middleware.
85
- if (/Access-Control-Allow-Origin/i.test(text) || /ALLOWED_ORIGINS/.test(text) || /allowedOrigins/i.test(text)) {
86
- const wildcard = /Access-Control-Allow-Origin["'\s:,]+\*/i.test(text);
87
- const hit = { snippet: 'explicit origin handling', file, line: 1 };
88
- if (wildcard)
89
- corsLoose.push(hit);
153
+ //
154
+ // Line by line, because the file is the wrong unit for this question twice over.
155
+ // It decided loose-or-strict from whether a wildcard appeared anywhere in the
156
+ // file, so one permissive route made an allowlist read as permissive and one
157
+ // allowlist made a wildcard read as restricted; and it cited line 1, which in
158
+ // every repository that triggered it was an import.
159
+ for (const m of (0, textSearch_1.matchLines)(text, ORIGIN_HANDLING, file)) {
160
+ if (WILDCARD_ORIGIN.test(m.snippet))
161
+ corsLoose.push(m);
90
162
  else
91
- corsStrict.push(hit);
163
+ corsStrict.push(m);
164
+ }
165
+ // Only where the middleware is actually imported. A sentence in this tool's own
166
+ // remediation catalogue — "Replace cors() defaults with an explicit allowlist." —
167
+ // was being read as a CORS configuration, and a table of strings is not a comment,
168
+ // so no rule about the shape of the line could tell them apart. Whether the file
169
+ // imports the package can.
170
+ if (IMPORTS_FLASK_CORS.test(text)) {
171
+ const lines = text.split(/\r?\n/);
172
+ for (const m of (0, textSearch_1.matchLines)(text, [FLASK_CORS_CALL], file).filter((m) => FLASK_CORS_CALL.test(withoutStringLiterals(m.snippet)))) {
173
+ const window = lines.slice(m.line - 1, m.line + 9).join('\n');
174
+ if (/\borigins\s*=/.test(window) || /\bresources\s*=/.test(window))
175
+ corsStrict.push(m);
176
+ else
177
+ corsLoose.push(m);
178
+ }
92
179
  }
180
+ if (!IMPORTS_CORS.test(text))
181
+ continue;
93
182
  if (!/\bcors\s*\(/.test(text))
94
183
  continue;
95
184
  const detected = detectCorsConfig(text, file);
@@ -125,27 +214,36 @@ async function detectSecurity(ctx) {
125
214
  evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'body-size' });
126
215
  for (const m of contentTypeCheck)
127
216
  evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'content-type' });
128
- const djangoSettings = ctx.files.all.find((f) => f.endsWith('settings.py'));
217
+ /**
218
+ * Django was credited for headers twice, and neither credit was earned.
219
+ *
220
+ * The first was the mere existence of a file named settings.py, which a FastAPI
221
+ * router satisfied. The second looked truer — `SecurityMiddleware` really does set
222
+ * those headers — but `django-admin startproject` writes that line into every new
223
+ * project, so it distinguishes nothing and would have credited the bare skeleton
224
+ * this repository keeps as a fixture precisely because it is unprotected. What is
225
+ * left is the `SECURE_*` settings above: lines somebody chose to write.
226
+ */
227
+ const djangoSettings = await findDjangoSettings(ctx);
129
228
  let djangoDebugTrue = false;
130
229
  let djangoSecureCookies = true;
131
230
  if (djangoSettings) {
132
- const text = (await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, djangoSettings)) ?? '';
133
- if (/DEBUG\s*=\s*True/.test(text)) {
231
+ const { file, text } = djangoSettings;
232
+ const debugOn = (0, textSearch_1.matchLines)(text, [/^\s*DEBUG\s*=\s*True\b/], file);
233
+ if (debugOn.length > 0) {
134
234
  djangoDebugTrue = true;
135
- evidence.push({ type: 'snippet', value: 'DEBUG = True', file: djangoSettings, claim: 'django-debug' });
235
+ for (const m of debugOn)
236
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'django-debug' });
136
237
  }
137
- const insecureSession = /SESSION_COOKIE_SECURE\s*=\s*False/.test(text);
138
- const insecureCsrf = /CSRF_COOKIE_SECURE\s*=\s*False/.test(text);
139
- djangoSecureCookies = !(insecureSession || insecureCsrf);
140
- if (insecureSession)
141
- evidence.push({ type: 'snippet', value: 'SESSION_COOKIE_SECURE = False', file: djangoSettings, claim: 'django-cookies' });
142
- if (insecureCsrf)
143
- evidence.push({ type: 'snippet', value: 'CSRF_COOKIE_SECURE = False', file: djangoSettings, claim: 'django-cookies' });
238
+ const insecure = (0, textSearch_1.matchLines)(text, [/^\s*SESSION_COOKIE_SECURE\s*=\s*False\b/, /^\s*CSRF_COOKIE_SECURE\s*=\s*False\b/], file);
239
+ djangoSecureCookies = insecure.length === 0;
240
+ for (const m of insecure)
241
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'django-cookies' });
144
242
  }
145
243
  return {
146
244
  key: 'security.core',
147
- present: helmet || rateLimit || Boolean(djangoSettings),
148
- complete: helmet && (rateLimit || Boolean(djangoSettings)),
245
+ present: helmet || rateLimit,
246
+ complete: helmet && rateLimit,
149
247
  evidence,
150
248
  details: {
151
249
  helmet,
@@ -390,9 +390,14 @@ exports.rules = [
390
390
  const loose = Boolean(sec?.details?.corsLoose);
391
391
  const strict = Boolean(sec?.details?.corsStrict);
392
392
  // Nothing answers a cross-origin request where nothing answers a request.
393
+ //
394
+ // The permissive line decides, not the restricted one. While the file was the
395
+ // unit of judgement this hardly mattered; now that each line is read on its own,
396
+ // an allowlist on one route would otherwise absolve a wildcard on another — and
397
+ // a wildcard is open to the whole web no matter what the route beside it does.
393
398
  const status = !hasUserFacingSurface(analysis)
394
399
  ? 'unknown'
395
- : strict ? 'passed' : loose ? 'partial' : 'unknown';
400
+ : loose ? 'partial' : strict ? 'passed' : 'unknown';
396
401
  return mkFinding({
397
402
  id: 'security.cors-origin',
398
403
  title: 'CORS origin restrictions',
@@ -402,7 +407,9 @@ exports.rules = [
402
407
  description: status === 'passed'
403
408
  ? 'CORS appears configured with explicit origins.'
404
409
  : status === 'partial'
405
- ? 'CORS middleware detected without explicit origin restrictions.'
410
+ ? strict
411
+ ? 'Some routes restrict origins and at least one allows any origin.'
412
+ : 'CORS middleware detected without explicit origin restrictions.'
406
413
  : 'CORS configuration not detected.',
407
414
  recommendation: 'Configure allowlist origins and avoid permissive defaults in production.',
408
415
  evidence: evidenceForClaim(sec?.evidence, 'cors'),
@@ -3,6 +3,27 @@ export interface TextMatch {
3
3
  line: number;
4
4
  snippet: string;
5
5
  }
6
+ /**
7
+ * Whether this line may be quoted back to the reader as something the code does.
8
+ *
9
+ * The rules above were reachable only through the file search, so any detector that
10
+ * walked lines itself got none of them. The CORS middleware walk was one: it cited
11
+ * `// app.use(cors({` from a route somebody had commented out, and — pointed at this
12
+ * repository — cited this tool's own prose about the Express middleware and the
13
+ * sentence in its remediation catalogue that advises replacing `cors()` defaults.
14
+ * Three citations, none of them code that runs.
15
+ */
16
+ export declare function isCitableLine(line: string): boolean;
17
+ /**
18
+ * The lines of one already-read file that match, with the same hygiene the file
19
+ * search applies: no comments, no pattern tables, no minified lines.
20
+ *
21
+ * Exported because a detector that has the text in hand should not have to choose
22
+ * between re-reading the file and skipping those rules. The CORS branch made that
23
+ * choice the wrong way: it tested the whole file for a header name and cited line 1
24
+ * with the words "explicit origin handling" — a conclusion where a line was promised.
25
+ */
26
+ export declare function matchLines(text: string, needles: Array<string | RegExp>, file?: string): TextMatch[];
6
27
  /**
7
28
  * Search a list of relative file paths for any of the given needles
8
29
  * (string or RegExp). Returns at most `limit` matches.
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isCitableLine = isCitableLine;
4
+ exports.matchLines = matchLines;
3
5
  exports.searchInFiles = searchInFiles;
4
6
  exports.anyIncludes = anyIncludes;
5
7
  const readTextFileSafe_1 = require("./readTextFileSafe");
@@ -49,6 +51,19 @@ function declaresRatherThanDoes(line) {
49
51
  const trimmed = line.trim();
50
52
  return COMMENT_LINE.test(trimmed) || PATTERN_DECLARATION.test(trimmed);
51
53
  }
54
+ /**
55
+ * Whether this line may be quoted back to the reader as something the code does.
56
+ *
57
+ * The rules above were reachable only through the file search, so any detector that
58
+ * walked lines itself got none of them. The CORS middleware walk was one: it cited
59
+ * `// app.use(cors({` from a route somebody had commented out, and — pointed at this
60
+ * repository — cited this tool's own prose about the Express middleware and the
61
+ * sentence in its remediation catalogue that advises replacing `cors()` defaults.
62
+ * Three citations, none of them code that runs.
63
+ */
64
+ function isCitableLine(line) {
65
+ return line.length <= MAX_CITABLE_LINE && !declaresRatherThanDoes(line);
66
+ }
52
67
  /**
53
68
  * The longest line this analyzer will cite.
54
69
  *
@@ -69,6 +84,34 @@ function declaresRatherThanDoes(line) {
69
84
  * on is not worth the one it displaces.
70
85
  */
71
86
  const MAX_CITABLE_LINE = 500;
87
+ /**
88
+ * The lines of one already-read file that match, with the same hygiene the file
89
+ * search applies: no comments, no pattern tables, no minified lines.
90
+ *
91
+ * Exported because a detector that has the text in hand should not have to choose
92
+ * between re-reading the file and skipping those rules. The CORS branch made that
93
+ * choice the wrong way: it tested the whole file for a header name and cited line 1
94
+ * with the words "explicit origin handling" — a conclusion where a line was promised.
95
+ */
96
+ function matchLines(text, needles, file = '') {
97
+ const matches = [];
98
+ const lines = text.split(/\r?\n/);
99
+ for (let i = 0; i < lines.length; i++) {
100
+ const line = lines[i];
101
+ if (line.length > MAX_CITABLE_LINE)
102
+ continue;
103
+ if (declaresRatherThanDoes(line))
104
+ continue;
105
+ for (const n of needles) {
106
+ const hit = typeof n === 'string' ? line.includes(n) : n.test(line);
107
+ if (hit) {
108
+ matches.push({ file, line: i + 1, snippet: line.trim().slice(0, 200) });
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ return matches;
114
+ }
72
115
  /**
73
116
  * Search a list of relative file paths for any of the given needles
74
117
  * (string or RegExp). Returns at most `limit` matches.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.38.0",
3
+ "version": "0.39.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": {