@produtype/core 0.40.0 → 0.42.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,7 @@ const detectContext_1 = require("./detectContext");
5
5
  const textSearch_1 = require("../utils/textSearch");
6
6
  const roleChecks_1 = require("./structural/roleChecks");
7
7
  const absenceEvidence_1 = require("./absenceEvidence");
8
+ const fileNames_1 = require("./fileNames");
8
9
  /**
9
10
  * In a product that talks to a model, `role` usually means who is speaking.
10
11
  *
@@ -137,6 +138,11 @@ async function detectAuth(ctx) {
137
138
  /hashed?[_-]?(api[_-]?)?key/i,
138
139
  /token\s*scope/i,
139
140
  ], 20);
141
+ const passwordResetFiles = (0, fileNames_1.searchFileNames)(sourceFiles, [
142
+ /(password|pwd)[_-]?(reset|recovery)/i,
143
+ /(reset|recover)[_-]?password/i,
144
+ /forgot[_-]?password/i,
145
+ ]);
140
146
  const passwordResetSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
141
147
  /forgot\s*password/i,
142
148
  /password[_-]?reset/i,
@@ -154,6 +160,17 @@ async function detectAuth(ctx) {
154
160
  // The same shape in other frameworks that hand you the flow rather than the words.
155
161
  /Devise|devise_for/,
156
162
  /Auth::routes\(/,
163
+ /**
164
+ * The same flow, called recovery.
165
+ *
166
+ * supabase/auth is a product whose entire purpose is authentication, and it was
167
+ * reported as having no password reset. Its file says "Password recovery
168
+ * requires an email" and names the type `Recovery`: the words "reset" and
169
+ * "forgot" appear nowhere, because it says the thing differently.
170
+ */
171
+ /password\s*recover(y|ing)?/i,
172
+ /recover(y)?[_-]?password/i,
173
+ /passwordRecovery/,
157
174
  ], 20);
158
175
  const emailVerificationSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/verify\s*email/i, /email[_-]?verification/i, /confirm\s*email/i, /isEmailVerified/i], 20);
159
176
  const sessionSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/session/i, /cookie/i, /jwt/i, /refresh\s*token/i, /httpOnly/i], 25);
@@ -309,7 +326,8 @@ async function detectAuth(ctx) {
309
326
  key: 'auth.externalIdentityOnly',
310
327
  present: managedAuthDeps.length > 0 &&
311
328
  (0, detectContext_1.hasAnyDep)(ctx, ['bcrypt', 'bcryptjs', 'argon2', 'scrypt-kdf', 'passport-local']).length === 0 &&
312
- passwordResetSignals.length === 0,
329
+ passwordResetSignals.length === 0 &&
330
+ passwordResetFiles.length === 0,
313
331
  evidence: depEvidence(managedAuthDeps),
314
332
  details: { managedProviders: managedAuthDeps.length },
315
333
  },
@@ -325,13 +343,13 @@ async function detectAuth(ctx) {
325
343
  },
326
344
  {
327
345
  key: 'auth.passwordReset',
328
- present: passwordResetSignals.length > 0,
346
+ present: passwordResetSignals.length > 0 || passwordResetFiles.length > 0,
329
347
  // The terms, where nothing matched. "No direct evidence captured" reads like
330
348
  // "we did not look"; this lets a reader whose flow is called `recoverAccess`
331
349
  // see in one line why it was missed.
332
- evidence: passwordResetSignals.length > 0
333
- ? snippetEvidence(passwordResetSignals)
334
- : (0, absenceEvidence_1.searchedFor)('a password reset flow', ['"forgot password"', 'password_reset', 'password-reset', '"reset token"', 'django.contrib.auth.urls', 'PasswordResetView', 'devise_for', 'Auth::routes(']),
350
+ evidence: passwordResetSignals.length > 0 || passwordResetFiles.length > 0
351
+ ? [...snippetEvidence(passwordResetSignals), ...(0, fileNames_1.fileNameEvidence)(passwordResetFiles)]
352
+ : (0, absenceEvidence_1.searchedFor)('a password reset flow', ['"forgot password"', 'password_reset', 'password-reset', '"reset token"', '"password recovery"', 'django.contrib.auth.urls', 'PasswordResetView', 'devise_for', 'Auth::routes(', 'a file named for password reset or recovery']),
335
353
  },
336
354
  {
337
355
  key: 'auth.emailVerification',
@@ -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 };
@@ -3,11 +3,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectGdpr = detectGdpr;
4
4
  const textSearch_1 = require("../utils/textSearch");
5
5
  const absenceEvidence_1 = require("./absenceEvidence");
6
+ const fileNames_1 = require("./fileNames");
6
7
  function toEvidence(matches) {
7
8
  return matches.map((m) => ({ type: 'snippet', value: m.snippet, file: m.file, line: m.line }));
8
9
  }
9
10
  async function detectGdpr(ctx) {
10
11
  const consent = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/cookie[-_ ]?consent/i, /consentGiven/i, /privacyConsent/i, /gdprConsent/i], 20);
12
+ /**
13
+ * The same duty, in the words each ecosystem actually uses.
14
+ *
15
+ * The list was written in one dialect. Article 20 is `exportUserData` in a Node
16
+ * application and `UserExport` in a Rails one; article 17 is `deleteAccount` here
17
+ * and `UserAnonymizer` there. Discourse ships both and was told it had neither,
18
+ * at `high` — which is the severity a reader acts on.
19
+ */
11
20
  const exportRoute = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
12
21
  /\/gdpr\/export\b/i,
13
22
  /exportUserData/i,
@@ -16,8 +25,40 @@ async function detectGdpr(ctx) {
16
25
  /rightToAccess/i,
17
26
  /data portability/i,
18
27
  /export personal data/i,
28
+ /user[_-]?export/i,
29
+ /export[_-]?(user|account|profile)\b/i,
30
+ /download (your|my) data/i,
31
+ ], 20);
32
+ const exportFiles = (0, fileNames_1.searchFileNames)(ctx.files.source, [
33
+ /user[_-]?export/i,
34
+ /(data|account|profile)[_-]?export/i,
35
+ /export[_-]?(user|account|personal)/i,
36
+ ]);
37
+ const erasure = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
38
+ /erasure/i,
39
+ /delete account/i,
40
+ /right to be forgotten/i,
41
+ /delete user data/i,
42
+ /delete personal data/i,
43
+ /**
44
+ * Anonymisation, which is how a forum satisfies article 17 without losing the
45
+ * thread. Discourse's is `UserAnonymizer` and it was told it had no erasure.
46
+ */
47
+ /anonymi[sz]e[_-]?(user|account)/i,
48
+ /user[_-]?anonymi[sz]/i,
19
49
  ], 20);
20
- const erasure = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/erasure/i, /delete account/i, /right to be forgotten/i, /delete user data/i, /delete personal data/i], 20);
50
+ /**
51
+ * `deleteUser(id)` is not article 17 — it is every admin screen ever written, and
52
+ * as a line it matched a teaching exercise about `git log -S "deleteUser"` and a
53
+ * function that removes a cloud provider account. A file *named* for deleting
54
+ * accounts is a different thing: somebody built a feature and called it that.
55
+ */
56
+ const erasureFiles = (0, fileNames_1.searchFileNames)(ctx.files.source, [
57
+ /user[_-]?anonymi[sz]/i,
58
+ /anonymi[sz]er/i,
59
+ /(account|user)[_-]?deletion/i,
60
+ /(delete|erase)[_-]?(account|my[_-]?data)/i,
61
+ ]);
21
62
  const retention = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
22
63
  /(gdpr|privacy).*(retention|purge|delete)/i,
23
64
  /(retention|purge|delete).*(personal data|user data|data subject)/i,
@@ -44,13 +85,17 @@ async function detectGdpr(ctx) {
44
85
  },
45
86
  {
46
87
  key: 'gdpr.export.route',
47
- present: exportRoute.length > 0,
48
- evidence: evidenceOr(exportRoute, 'export'),
88
+ present: exportRoute.length > 0 || exportFiles.length > 0,
89
+ evidence: exportRoute.length > 0 || exportFiles.length > 0
90
+ ? [...toEvidence(exportRoute), ...(0, fileNames_1.fileNameEvidence)(exportFiles)]
91
+ : evidenceOr(exportRoute, 'export'),
49
92
  },
50
93
  {
51
94
  key: 'gdpr.erasure.route',
52
- present: erasure.length > 0,
53
- evidence: evidenceOr(erasure, 'erasure'),
95
+ present: erasure.length > 0 || erasureFiles.length > 0,
96
+ evidence: erasure.length > 0 || erasureFiles.length > 0
97
+ ? [...toEvidence(erasure), ...(0, fileNames_1.fileNameEvidence)(erasureFiles)]
98
+ : evidenceOr(erasure, 'erasure'),
54
99
  },
55
100
  {
56
101
  key: 'gdpr.retention.job',
@@ -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;
@@ -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
+ }
@@ -0,0 +1,33 @@
1
+ import type { DetectorEvidence } from './types';
2
+ /**
3
+ * What a file is called, as evidence about what the product does.
4
+ *
5
+ * This analyzer reads the inside of files and never the names on them. Discourse was
6
+ * told it offers no way to export or erase personal data while shipping
7
+ * `app/models/user_export.rb` and `app/services/user_anonymizer.rb`; supabase/auth was
8
+ * told it has no password reset while shipping `internal/api/recover.go`. In each case
9
+ * the concept is named most clearly in the one place nothing was looking.
10
+ *
11
+ * It is weaker evidence than a line and it is honest about that: the citation is the
12
+ * file, with no line number, because a file name is not a line. It is also
13
+ * language-neutral in a way no list of words can be — `user_export.rb`,
14
+ * `UserExportService.java` and `export_user.py` are one pattern.
15
+ *
16
+ * The limit is low on purpose: a Django project has ten templates named for the
17
+ * password reset and the reader needs to see two, not ten.
18
+ *
19
+ * Matched on the path with its extension removed, so a pattern never has to know
20
+ * whether it is looking at Ruby or Go.
21
+ */
22
+ export interface FileNameMatch {
23
+ file: string;
24
+ }
25
+ export declare function searchFileNames(files: string[], patterns: RegExp[], limit?: number): FileNameMatch[];
26
+ /**
27
+ * The evidence line for a file name.
28
+ *
29
+ * `type: 'file'` and no `line`, which is what distinguishes it from a snippet: the
30
+ * reader is being pointed at a file to open, not at a line to read. The alternative —
31
+ * claiming line 1 — is the mistake this analyzer spent a release removing.
32
+ */
33
+ export declare function fileNameEvidence(matches: FileNameMatch[], claim?: string): DetectorEvidence[];
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.searchFileNames = searchFileNames;
4
+ exports.fileNameEvidence = fileNameEvidence;
5
+ function searchFileNames(files, patterns, limit = 4) {
6
+ const matches = [];
7
+ for (const file of files) {
8
+ if (matches.length >= limit)
9
+ break;
10
+ const withoutExtension = file.replace(/\.[A-Za-z0-9]+$/, '');
11
+ if (patterns.some((pattern) => pattern.test(withoutExtension))) {
12
+ matches.push({ file });
13
+ }
14
+ }
15
+ return matches;
16
+ }
17
+ /**
18
+ * The evidence line for a file name.
19
+ *
20
+ * `type: 'file'` and no `line`, which is what distinguishes it from a snippet: the
21
+ * reader is being pointed at a file to open, not at a line to read. The alternative —
22
+ * claiming line 1 — is the mistake this analyzer spent a release removing.
23
+ */
24
+ function fileNameEvidence(matches, claim) {
25
+ return matches.map((m) => ({
26
+ type: 'file',
27
+ value: `a file named ${m.file}`,
28
+ file: m.file,
29
+ ...(claim ? { claim } : {}),
30
+ }));
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.40.0",
3
+ "version": "0.42.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": {