@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.
- package/dist/analyzer/analyzeProject.js +15 -2
- package/dist/analyzer/catalogue.js +1 -0
- package/dist/analyzer/detectEnv.js +18 -0
- package/dist/analyzer/detectObservability.js +11 -2
- package/dist/analyzer/detectSecurity.js +46 -2
- package/dist/analyzer/proseLines.d.ts +1 -1
- package/dist/analyzer/proseLines.js +37 -1
- package/package.json +1 -1
|
@@ -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 ||
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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": {
|