@produtype/core 1.6.0 → 1.7.1

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.
@@ -233,6 +233,20 @@ function isTestOrExamplePath(file) {
233
233
  || /\.(e2e|e2e-spec|cy|stories)\.(ts|tsx|js|jsx|mjs|cjs)$/i.test(file)
234
234
  || /_spec\.rb$/i.test(file)
235
235
  || /_test\.(go|py|rb|java|cs|php)$/i.test(file)
236
+ || /_test\.exs$/i.test(file)
237
+ /**
238
+ * Mix names its environments, and the file name is the environment.
239
+ *
240
+ * `config/test.exs` and `config/dev.exs` are compiled only under `MIX_ENV=test` and
241
+ * `MIX_ENV=dev`; a production release carries `config/prod.exs` and
242
+ * `config/runtime.exs` and neither of the other two. supabase/realtime keeps
243
+ * `metrics_jwt_secret: "test"` in `config/test.exs` and it was the one `critical`
244
+ * in its report — a literal written to be fake, read as production configuration.
245
+ *
246
+ * The same argument as `_test.go`: the name is the toolchain's, not the author's,
247
+ * and it says which build the file is part of.
248
+ */
249
+ || /(^|\/)config\/(test|dev)\.exs$/i.test(file)
236
250
  || /(^|\/)test[-_][^/]+\.(ts|tsx|js|jsx|mjs|cjs|py)$/i.test(file)
237
251
  /**
238
252
  * The other half of the convention.
@@ -306,7 +320,7 @@ function isTestOrExamplePath(file) {
306
320
  * `.vue`, `.svelte` and `.astro` were missing for the same reason: a single-file
307
321
  * component holds the logic, not just the markup.
308
322
  */
309
- const SOURCE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|php|go|rb|java|cs|rs|kt|swift|dart|html?|vue|svelte|astro)$/;
323
+ const SOURCE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|php|go|rb|java|cs|rs|kt|swift|dart|ex|exs|html?|vue|svelte|astro)$/;
310
324
  function pickSource(files) {
311
325
  return files.filter((f) => SOURCE_EXTENSIONS.test(f) && !isTestOrExamplePath(f));
312
326
  }
@@ -321,7 +335,6 @@ function pickSource(files) {
321
335
  * Being wrong is recoverable. Being wrong while announcing no reservations is not.
322
336
  */
323
337
  const KNOWN_UNREADABLE = [
324
- [/\.(ex|exs)$/, 'Elixir'],
325
338
  [/\.(scala|sc)$/, 'Scala'],
326
339
  [/\.(clj|cljs)$/, 'Clojure'],
327
340
  [/\.(cpp|cc|hpp)$/, 'C++'],
@@ -193,6 +193,7 @@ exports.LANGUAGES = [
193
193
  { id: 'kotlin', label: 'Kotlin', extensions: /\.(kt|kts)$/ },
194
194
  { id: 'swift', label: 'Swift', extensions: /\.swift$/ },
195
195
  { id: 'dart', label: 'Dart', extensions: /\.dart$/ },
196
+ { id: 'elixir', label: 'Elixir', extensions: /\.exs?$/ },
196
197
  ];
197
198
  /** Display names for ids the tables above produce. */
198
199
  const LABELS = {
@@ -25,6 +25,23 @@ const GENERIC_SECRET_ASSIGNMENT_RE = /(JWT_SECRET|SECRET_KEY|SESSION_SECRET|API_
25
25
  * because the identifier does — so the value itself was never looked at.
26
26
  */
27
27
  const REDACTED_VALUE_RE = /[:=]\s*['"`](\*{2,}|x{3,}|<?\[?redacted\]?>?|hidden|\.{3,})['"`]/i;
28
+ /**
29
+ * A sentence assigned to a secret-named identifier is not a secret.
30
+ *
31
+ * `secret_key: "must be #{@secret_key_size} bytes in Base 64 URL alphabet"` is
32
+ * Livebook's validation message, returned when somebody types a bad key, and it was
33
+ * the one `critical` in its report. The finding is "Weak/fallback secret values" and
34
+ * the value was never looked at: every test above runs on the whole line, and the
35
+ * line says "secret" because the identifier does. The redaction rule beside this one
36
+ * was the same discovery made once, for one shape, and patched there.
37
+ *
38
+ * A secret is a token. It has no spaces in it and it is not a template with a
39
+ * variable in the middle — an error message, a label and a sentence all do. Three
40
+ * Elixir products raised this `critical` on the day Elixir became readable, and
41
+ * `critical` is the severity that caps maturity, so it was the loudest thing in each
42
+ * of three reports about products that had done nothing wrong.
43
+ */
44
+ const VALUE_IS_A_SENTENCE_RE = /[:=]\s*(['"`])[^'"`]*(?:\s|#\{|\$\{)[^'"`]*\1/;
28
45
  function classifySecretFallback(snippet) {
29
46
  if (/JWT_SECRET/i.test(snippet))
30
47
  return 'jwt';
@@ -166,6 +183,7 @@ async function detectEnv(ctx) {
166
183
  && !namesItself(m.snippet)
167
184
  && !valueIsAnIdentifier(m.snippet)
168
185
  && !REDACTED_VALUE_RE.test(m.snippet)
186
+ && !VALUE_IS_A_SENTENCE_RE.test(m.snippet)
169
187
  && !isTableEntry(m)
170
188
  && !isAnotherSettingsModule(m.file));
171
189
  for (const m of weakHits) {
@@ -30,9 +30,11 @@ const LOGGING_PACKAGES = [
30
30
  'debug',
31
31
  'npmlog',
32
32
  ];
33
+ /** Elixir reaches for a backend rather than a logger: Logger itself is in OTP. */
34
+ const ELIXIR_LOGGING_PACKAGES = ['logger_json', 'logger_file_backend', 'sentry'];
33
35
  async function detectObservability(ctx) {
34
36
  const evidence = [];
35
- const logDeps = (0, detectContext_1.hasAnyDep)(ctx, LOGGING_PACKAGES);
37
+ const logDeps = [...(0, detectContext_1.hasAnyDep)(ctx, LOGGING_PACKAGES), ...(0, detectContext_1.hasAnyElixirDep)(ctx, ELIXIR_LOGGING_PACKAGES)];
36
38
  const sentryDeps = (0, detectContext_1.hasAnyDep)(ctx, ['@sentry/node', 'sentry-sdk']);
37
39
  for (const d of logDeps)
38
40
  evidence.push({ type: 'dependency', value: d, claim: 'logging' });
@@ -100,6 +102,13 @@ async function detectObservability(ctx) {
100
102
  /LoggerFactory\.getLogger|org\.slf4j/,
101
103
  /Rails\.logger/,
102
104
  /\btracing::(info|warn|error|debug)!|\blog::(info|warn|error)!/,
105
+ /**
106
+ * Elixir's, which is one module in the standard library.
107
+ *
108
+ * `Logger.info(...)` is how every Phoenix application logs, and `Logger` is
109
+ * OTP's name rather than anybody's variable.
110
+ */
111
+ /\bLogger\.(info|warning|warn|error|debug|notice)\(/,
103
112
  ], 15);
104
113
  for (const m of structuredHits)
105
114
  evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'logging' });
@@ -140,7 +149,7 @@ async function detectObservability(ctx) {
140
149
  evidence.push(...(0, absenceEvidence_1.searchedFor)('a health endpoint', ['/health', '/healthz', '/readyz', 'a health, healthz, readyz, liveness or readiness route file'], 'health'));
141
150
  }
142
151
  if (!hasAnyLogging) {
143
- evidence.push(...(0, absenceEvidence_1.searchedFor)('logging', ['winston', 'pino', 'morgan', 'bunyan', 'Monolog', 'slog', 'zap', 'logrus', 'slf4j', 'Rails.logger', 'tracing::', 'logger.info/warn/error/debug', 'error_log(', 'JSON.stringify with a level field'], 'logging'));
152
+ evidence.push(...(0, absenceEvidence_1.searchedFor)('logging', ['winston', 'pino', 'morgan', 'bunyan', 'Monolog', 'slog', 'zap', 'logrus', 'slf4j', 'Rails.logger', 'tracing::', 'logger.info/warn/error/debug', 'Logger.info', 'error_log(', 'JSON.stringify with a level field'], 'logging'));
144
153
  }
145
154
  return {
146
155
  key: 'observability.core',
@@ -211,6 +211,23 @@ async function detectSecurity(ctx) {
211
211
  /config\.content_security_policy\b/,
212
212
  /^\s*config\.force_ssl\s*=\s*true/m,
213
213
  ], 20);
214
+ /**
215
+ * Reading a header is not setting one.
216
+ *
217
+ * plausible ships `tracker/installation_support/check-disallowed-by-csp.js`, whose
218
+ * whole job is to look at somebody else's `content-security-policy` and tell a user
219
+ * why the tracker was blocked. Its line `responseHeaders?.['content-security-policy']`
220
+ * was the evidence behind "security headers: passed" — a tool that inspects other
221
+ * people's headers credited with setting its own.
222
+ *
223
+ * The shape is the anchor, not the file: a header name used as a key into a headers
224
+ * object is a lookup. Setting one is a call — `put_resp_header`, `setHeader`,
225
+ * `headers.set`, `add_header` — or an assignment to that subscript, and a line doing
226
+ * either is left alone.
227
+ */
228
+ const READS_A_HEADER = /\[\s*(['"`])[^'"`]+\1\s*\](?!\s*=[^=])/;
229
+ const SETS_A_HEADER = /put_resp_header|setHeader|set_header|add_header|headers\.(?:set|append)|writeHead/i;
230
+ const headerSignalsThatSet = headerSignals.filter((hit) => !READS_A_HEADER.test(hit.snippet) || SETS_A_HEADER.test(hit.snippet));
214
231
  /**
215
232
  * Spring Security, which writes the headers without being asked.
216
233
  *
@@ -249,7 +266,7 @@ async function detectSecurity(ctx) {
249
266
  claim: 'headers',
250
267
  });
251
268
  }
252
- const helmet = helmetDep || headerSignals.length > 0 || springFilterChain.length > 0;
269
+ const helmet = helmetDep || headerSignalsThatSet.length > 0 || springFilterChain.length > 0;
253
270
  /**
254
271
  * The packages the ecosystem names, as distinct from the variables authors do.
255
272
  * Shared between the dependency check below and the binding walk further down.
@@ -406,7 +423,7 @@ async function detectSecurity(ctx) {
406
423
  evidence.push({ type: 'dependency', value: 'helmet', claim: 'headers' });
407
424
  if (rateLimitDep)
408
425
  evidence.push({ type: 'dependency', value: 'rate limiting package', claim: 'rate-limit' });
409
- for (const m of headerSignals)
426
+ for (const m of headerSignalsThatSet)
410
427
  evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'headers' });
411
428
  for (const m of issuedRateLimits)
412
429
  evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'rate-limit' });
@@ -580,6 +597,33 @@ async function detectSecurity(ctx) {
580
597
  const cited = anyOrigin[0] ?? chosen[0] ?? aspNetCors[0];
581
598
  target.push({ file: cited.file, line: cited.line, snippet: cited.snippet.trim().slice(0, 200) });
582
599
  }
600
+ /**
601
+ * Elixir's, which is a plug in the endpoint or the router.
602
+ *
603
+ * `cors_plug` and `corsica` are the two packages, and both are used the same way:
604
+ * `plug CORSPlug, origin: ["https://app.example.com"]` or `plug Corsica, origins:
605
+ * "*"`. The module name comes from the package, so it is the anchor; what the
606
+ * author chose is the value of `origin`.
607
+ *
608
+ * `plug` takes parentheses as readily as not — plausible writes `plug(CORSPlug)` in
609
+ * its endpoint, and a rule that required a space after the keyword could not see
610
+ * it. Both forms are ordinary Elixir and the formatter leaves either alone.
611
+ *
612
+ * `"*"` is the wide-open one. A list, a function or a regex is a decision somebody
613
+ * made, and the same reflection-versus-allowlist question the other frameworks are
614
+ * asked. Where the plug names no origin at all, cors_plug's own default is `"*"`,
615
+ * so silence is the open branch rather than the strict one.
616
+ */
617
+ const elixirCorsPackages = (0, detectContext_1.hasAnyElixirDep)(ctx, ['cors_plug', 'corsica']);
618
+ if (elixirCorsPackages.length > 0) {
619
+ const elixirSource = source.filter((f) => /\.exs?$/i.test(f));
620
+ const plugged = await (0, textSearch_1.searchInFiles)(ctx.root, elixirSource, [/\bplug[\s(]+(?:CORSPlug|Corsica)\b/], 5);
621
+ for (const hit of plugged) {
622
+ const origin = /origins?:\s*(.+)$/.exec(hit.snippet);
623
+ const wideOpen = !origin || /^["']\*["']/.test(origin[1].trim());
624
+ (wideOpen ? corsLoose : corsStrict).push({ file: hit.file, line: hit.line, snippet: hit.snippet.trim().slice(0, 200) });
625
+ }
626
+ }
583
627
  const django = await (0, djangoSettings_1.findDjangoSettings)(ctx);
584
628
  if (django) {
585
629
  const middlewareLine = django.text
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The lines of a Python file that are a docstring rather than code.
2
+ * The lines of a Python or Elixir file that are prose rather than code.
3
3
  *
4
4
  * This analyzer has skipped comments since the day it read its own prose about Stripe
5
5
  * as evidence that it takes payments. The rule looks for a line marker — `#`, `//`,
@@ -2,7 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.proseLines = proseLines;
4
4
  /**
5
- * The lines of a Python file that are a docstring rather than code.
5
+ * The lines of a Python or Elixir file that are prose rather than code.
6
6
  *
7
7
  * This analyzer has skipped comments since the day it read its own prose about Stripe
8
8
  * as evidence that it takes payments. The rule looks for a line marker — `#`, `//`,
@@ -20,6 +20,8 @@ exports.proseLines = proseLines;
20
20
  * has nothing before it but indentation.
21
21
  */
22
22
  function proseLines(file, text) {
23
+ if (/\.exs?$/i.test(file))
24
+ return elixirDocLines(text);
23
25
  const prose = new Set();
24
26
  if (!/\.py$/i.test(file))
25
27
  return prose;
@@ -54,3 +56,37 @@ function proseLines(file, text) {
54
56
  }
55
57
  return prose;
56
58
  }
59
+ /**
60
+ * Elixir writes its prose as a module attribute, and the rest is a heredoc.
61
+ *
62
+ * `@moduledoc """ ... """` is the same trap as a Python docstring with a different
63
+ * marker: the interior lines begin with whatever the author was saying, and nothing
64
+ * about them says they are not code. Phoenix generators put one at the top of every
65
+ * controller, context and channel, so a repository has thousands.
66
+ *
67
+ * Anchored on Elixir's own attribute names — `@moduledoc`, `@doc`, `@typedoc`,
68
+ * `@shortdoc` — rather than on the heredoc, because `@query """SELECT ..."""` is data
69
+ * assigned to a name and a hardcoded secret could live in one. Same distinction the
70
+ * Python reader draws, made by a different marker.
71
+ */
72
+ function elixirDocLines(text) {
73
+ const prose = new Set();
74
+ const lines = text.split(/\r?\n/);
75
+ let open = false;
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const line = lines[i];
78
+ if (open) {
79
+ prose.add(i + 1);
80
+ if (/"""/.test(line))
81
+ open = false;
82
+ continue;
83
+ }
84
+ const doc = /^\s*@(?:module|type|short)?doc\s+(?:~[A-Za-z])?"""/.exec(line);
85
+ if (!doc)
86
+ continue;
87
+ prose.add(i + 1);
88
+ /** A heredoc cannot close on its opening line, so the block is always open here. */
89
+ open = true;
90
+ }
91
+ return prose;
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
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": {