@produtype/core 0.35.0 → 0.37.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.
|
@@ -164,6 +164,20 @@ function isTestOrExamplePath(file) {
|
|
|
164
164
|
// made PHP one of the languages of an Elixir analytics product — and of cal.com,
|
|
165
165
|
// which is TypeScript.
|
|
166
166
|
return /(^|\/)(__tests__|__mocks__|mocks?|tests?|test-data|fixtures?|frontend-example)(\/|$)/i.test(file)
|
|
167
|
+
/**
|
|
168
|
+
* The conventions other ecosystems use, which this list did not know.
|
|
169
|
+
*
|
|
170
|
+
* Three real products each raised a critical — the severity that bars a report from
|
|
171
|
+
* the top band — and every piece of evidence behind all three came from a test:
|
|
172
|
+
* cal.com from `playwright/` and `*.e2e.ts`, chatwoot from `spec/`, which is where
|
|
173
|
+
* every Ruby project puts its tests, medusa from `integration-tests/`. A syntax tree
|
|
174
|
+
* would have parsed the same files and reached the same wrong conclusion, which is
|
|
175
|
+
* the argument for fixing what gets read before fixing how.
|
|
176
|
+
*/
|
|
177
|
+
|| /(^|\/)(spec|specs|e2e|integration-tests?|cypress|playwright|testing)(\/|$)/i.test(file)
|
|
178
|
+
|| /\.(e2e|e2e-spec|cy|stories)\.(ts|tsx|js|jsx|mjs|cjs)$/i.test(file)
|
|
179
|
+
|| /_spec\.rb$/i.test(file)
|
|
180
|
+
|| /_test\.(go|py|rb|java|cs|php)$/i.test(file)
|
|
167
181
|
|| /(^|\/)test[-_][^/]+\.(ts|tsx|js|jsx|mjs|cjs|py)$/i.test(file)
|
|
168
182
|
/**
|
|
169
183
|
* The other half of the convention.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.detectEnv = detectEnv;
|
|
4
4
|
const textSearch_1 = require("../utils/textSearch");
|
|
5
|
+
const lookupTables_1 = require("./structural/lookupTables");
|
|
5
6
|
const WEAK_SECRET_VALUE_RE = /(changeme|your[_-]?secret|fallback-secret(?:-change-in-production)?|change[_-]in[_-]production|your_jwt_secret_key_change_in_production|local[-_]?secret|development[-_]?secret|dev[-_]?secret|not[_-]?for[_-]?production|test123|secret)/i;
|
|
6
7
|
const FALLBACK_SECRET_RE = /(jwt_secret|secret_key|session_secret)\s*[:=]\s*['"][^'"]*(changeme|your[_-]?secret|fallback-secret(?:-change-in-production)?|change[_-]in[_-]production|your_jwt_secret_key_change_in_production|local[-_]?secret|development[-_]?secret|dev[-_]?secret|not[_-]?for[_-]?production|test123|secret)[^'"]*['"]/i;
|
|
7
8
|
const ENV_FALLBACK_RE = /(process\.env\.(JWT_SECRET|SECRET_KEY|SESSION_SECRET)\s*(\|\||\?\?)\s*['"][^'"]*(changeme|your[_-]?secret|fallback-secret(?:-change-in-production)?|change[_-]in[_-]production|your_jwt_secret_key_change_in_production|local[-_]?secret|development[-_]?secret|dev[-_]?secret|not[_-]?for[_-]?production|test123|secret)[^'"]*['"])/i;
|
|
@@ -23,6 +24,27 @@ function classifySecretFallback(snippet) {
|
|
|
23
24
|
return 'apiKey';
|
|
24
25
|
return 'unknown';
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* A value that is its own name is a name, not a secret.
|
|
29
|
+
*
|
|
30
|
+
* cal.com declares `export const X_CAL_SECRET_KEY = "x-cal-secret-key"` — the name of
|
|
31
|
+
* an HTTP header, spelled in kebab-case beside the constant that holds it — and it was
|
|
32
|
+
* reported as a hardcoded secret with the severity that bars a report from the top
|
|
33
|
+
* band. The same shape appears wherever a library names a field: `SECRET_KEY =
|
|
34
|
+
* 'AWS_SECRET_ACCESS_KEY'` in botocore is the name of an environment variable.
|
|
35
|
+
*
|
|
36
|
+
* Compared with the punctuation and case stripped, because the whole trick is that the
|
|
37
|
+
* two differ only in spelling: `X_CAL_SECRET_KEY` and `x-cal-secret-key` are the same
|
|
38
|
+
* eleven letters. Nothing else is excused — a real secret does not happen to equal the
|
|
39
|
+
* identifier it is assigned to.
|
|
40
|
+
*/
|
|
41
|
+
function namesItself(snippet) {
|
|
42
|
+
const assignment = /([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*["'`]([^"'`]+)["'`]/.exec(snippet);
|
|
43
|
+
if (!assignment)
|
|
44
|
+
return false;
|
|
45
|
+
const flatten = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
46
|
+
return flatten(assignment[1]) === flatten(assignment[2]);
|
|
47
|
+
}
|
|
26
48
|
async function detectEnv(ctx) {
|
|
27
49
|
const evidence = [];
|
|
28
50
|
const weakSecretEvidence = [];
|
|
@@ -62,7 +84,21 @@ async function detectEnv(ctx) {
|
|
|
62
84
|
* the thing actually being claimed.
|
|
63
85
|
*/
|
|
64
86
|
const fallbackHits = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [FALLBACK_SECRET_RE, ENV_FALLBACK_RE, GENERIC_SECRET_ASSIGNMENT_RE], 30);
|
|
65
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Entries in a lookup table, which text cannot tell from assignments.
|
|
89
|
+
*
|
|
90
|
+
* `admin_session_secret: 'core'` is one property of a sixty-entry map from settings to
|
|
91
|
+
* the group they belong to, and Ghost was told it was a hardcoded secret. The table is
|
|
92
|
+
* visible only in the shape, so this is one of the places a syntax tree earns its
|
|
93
|
+
* keep. Absent the optional parser the map is null and nothing is excused, which is
|
|
94
|
+
* the behaviour this product had yesterday.
|
|
95
|
+
*/
|
|
96
|
+
const lookupTables = await (0, lookupTables_1.readLookupTableLines)(ctx.root, sourceFiles);
|
|
97
|
+
const isTableEntry = (match) => lookupTables?.get(match.file)?.has(match.line) === true;
|
|
98
|
+
const weakHits = fallbackHits.filter((m) => WEAK_SECRET_VALUE_RE.test(m.snippet)
|
|
99
|
+
&& SECRET_ASSIGNMENT_CONTEXT_RE.test(m.snippet)
|
|
100
|
+
&& !namesItself(m.snippet)
|
|
101
|
+
&& !isTableEntry(m));
|
|
66
102
|
for (const m of weakHits) {
|
|
67
103
|
const hitEvidence = { type: 'snippet', value: m.snippet, file: m.file, line: m.line };
|
|
68
104
|
evidence.push(hitEvidence);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which lines of which files are table entries, or null when the question cannot be asked.
|
|
3
|
+
*
|
|
4
|
+
* `null` means the optional TypeScript dependency is absent, not that there are no
|
|
5
|
+
* tables — the caller keeps whatever it found rather than discarding it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function readLookupTableLines(root: string, files: string[]): Promise<Map<string, Set<number>> | null>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readLookupTableLines = readLookupTableLines;
|
|
4
|
+
const readTextFileSafe_1 = require("../../utils/readTextFileSafe");
|
|
5
|
+
const loadTypeScript_1 = require("./loadTypeScript");
|
|
6
|
+
/**
|
|
7
|
+
* Lines that are entries in a lookup table rather than assignments.
|
|
8
|
+
*
|
|
9
|
+
* Ghost keeps a map of every setting to the group it belongs to:
|
|
10
|
+
*
|
|
11
|
+
* admin_session_secret: 'core',
|
|
12
|
+
* theme_session_secret: 'core',
|
|
13
|
+
* members_email_auth_secret: 'core',
|
|
14
|
+
*
|
|
15
|
+
* Read as text this is a secret-named key assigned a short string, which is exactly
|
|
16
|
+
* the shape of a hardcoded fallback — and Ghost was told it had a critical. Read as a
|
|
17
|
+
* tree it is one property of an object with sixty of them, and what it says is "this
|
|
18
|
+
* setting is in the core group".
|
|
19
|
+
*
|
|
20
|
+
* Nothing in the words tells the two apart. The table is visible only in the shape.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* How many string-valued properties make an object a table.
|
|
24
|
+
*
|
|
25
|
+
* Low enough to catch a modest map, high enough that a small configuration object —
|
|
26
|
+
* `{ secret: 'dev-secret', port: 3000 }`, which is a real finding — is not excused.
|
|
27
|
+
* A genuine fallback secret sits in an object with a handful of siblings at most.
|
|
28
|
+
*/
|
|
29
|
+
const TABLE_SIZE = 8;
|
|
30
|
+
const READABLE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
31
|
+
const MAX_READABLE_LINE = 500;
|
|
32
|
+
function tableLines(ts, file, text) {
|
|
33
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
34
|
+
const lines = new Set();
|
|
35
|
+
const visit = (node) => {
|
|
36
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
37
|
+
const properties = node.properties.filter(ts.isPropertyAssignment);
|
|
38
|
+
const stringValued = properties.filter((property) => ts.isStringLiteral(property.initializer)
|
|
39
|
+
|| ts.isNoSubstitutionTemplateLiteral(property.initializer));
|
|
40
|
+
/**
|
|
41
|
+
* Every property a string, and plenty of them. A mixed object — some strings,
|
|
42
|
+
* some calls, some numbers — is configuration, and configuration is where a
|
|
43
|
+
* fallback secret actually lives.
|
|
44
|
+
*/
|
|
45
|
+
if (stringValued.length >= TABLE_SIZE && stringValued.length === properties.length) {
|
|
46
|
+
for (const property of stringValued) {
|
|
47
|
+
lines.add(source.getLineAndCharacterOfPosition(property.getStart(source)).line + 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
ts.forEachChild(node, visit);
|
|
52
|
+
};
|
|
53
|
+
visit(source);
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Which lines of which files are table entries, or null when the question cannot be asked.
|
|
58
|
+
*
|
|
59
|
+
* `null` means the optional TypeScript dependency is absent, not that there are no
|
|
60
|
+
* tables — the caller keeps whatever it found rather than discarding it.
|
|
61
|
+
*/
|
|
62
|
+
async function readLookupTableLines(root, files) {
|
|
63
|
+
const ts = await (0, loadTypeScript_1.loadTypeScript)();
|
|
64
|
+
if (!ts)
|
|
65
|
+
return null;
|
|
66
|
+
const found = new Map();
|
|
67
|
+
for (const file of files.filter((candidate) => READABLE.test(candidate))) {
|
|
68
|
+
const text = await (0, readTextFileSafe_1.readTextFileSafe)(root, file);
|
|
69
|
+
if (!text || text.split(/\r?\n/).some((line) => line.length > MAX_READABLE_LINE))
|
|
70
|
+
continue;
|
|
71
|
+
try {
|
|
72
|
+
const lines = tableLines(ts, file, text);
|
|
73
|
+
if (lines.size > 0)
|
|
74
|
+
found.set(file, lines);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// A file the parser refuses keeps whatever the text layer decided about it.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return found;
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.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": {
|