@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
- const weakHits = fallbackHits.filter((m) => WEAK_SECRET_VALUE_RE.test(m.snippet) && SECRET_ASSIGNMENT_CONTEXT_RE.test(m.snippet));
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.35.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": {