@produtype/core 0.36.0 → 0.38.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.
|
@@ -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;
|
|
@@ -83,9 +84,21 @@ async function detectEnv(ctx) {
|
|
|
83
84
|
* the thing actually being claimed.
|
|
84
85
|
*/
|
|
85
86
|
const fallbackHits = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [FALLBACK_SECRET_RE, ENV_FALLBACK_RE, GENERIC_SECRET_ASSIGNMENT_RE], 30);
|
|
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;
|
|
86
98
|
const weakHits = fallbackHits.filter((m) => WEAK_SECRET_VALUE_RE.test(m.snippet)
|
|
87
99
|
&& SECRET_ASSIGNMENT_CONTEXT_RE.test(m.snippet)
|
|
88
|
-
&& !namesItself(m.snippet)
|
|
100
|
+
&& !namesItself(m.snippet)
|
|
101
|
+
&& !isTableEntry(m));
|
|
89
102
|
for (const m of weakHits) {
|
|
90
103
|
const hitEvidence = { type: 'snippet', value: m.snippet, file: m.file, line: m.line };
|
|
91
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
|
+
}
|
|
@@ -14,6 +14,7 @@ const evidenceDigest_1 = require("./evidenceDigest");
|
|
|
14
14
|
const complianceMapping_1 = require("./complianceMapping");
|
|
15
15
|
const executiveSummary_1 = require("./executiveSummary");
|
|
16
16
|
const remediationCatalog_1 = require("../planner/remediationCatalog");
|
|
17
|
+
const detectPackaging_1 = require("../analyzer/detectPackaging");
|
|
17
18
|
// Derived from the union in report/types.ts, so a new category cannot be missed here.
|
|
18
19
|
function bySeverityPriority(f) {
|
|
19
20
|
const order = {
|
|
@@ -50,6 +51,27 @@ function determineExpectationMode(requestedProfile, productProfile, expectationS
|
|
|
50
51
|
}
|
|
51
52
|
return 'explicit-profile';
|
|
52
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* A finding whose every citation is in the documentation site or a playground.
|
|
56
|
+
*
|
|
57
|
+
* Medusa was told its CORS policy was open, evidenced from `www/apps/cloud/`, and that
|
|
58
|
+
* its Stripe webhook handling was unverified, evidenced from the documentation's
|
|
59
|
+
* `sidebar.mjs` — the navigation menu that lists a page about webhooks. Vite keeps ten
|
|
60
|
+
* Express servers under `playground/` for the same kind of reason.
|
|
61
|
+
*
|
|
62
|
+
* These are not false matches: the lines are there. They are matches about something
|
|
63
|
+
* other than the product, and a report that cannot tell the two apart tells its reader
|
|
64
|
+
* to go and fix a documentation site.
|
|
65
|
+
*
|
|
66
|
+
* Only where the repository has source outside those directories. A documentation site
|
|
67
|
+
* that *is* the product keeps every finding it earns.
|
|
68
|
+
*/
|
|
69
|
+
function onlyEvidencedInDocumentation(finding, hasProductSource) {
|
|
70
|
+
if (!hasProductSource)
|
|
71
|
+
return false;
|
|
72
|
+
const cited = finding.evidence.filter((item) => item.file);
|
|
73
|
+
return cited.length > 0 && cited.every((item) => detectPackaging_1.DOCS_DIRECTORIES.test(item.file));
|
|
74
|
+
}
|
|
53
75
|
function buildReport(analysis, options) {
|
|
54
76
|
const observedFindings = (0, ruleEngine_1.runRules)(analysis);
|
|
55
77
|
const observedScore = (0, score_1.computeScore)(observedFindings);
|
|
@@ -109,7 +131,23 @@ function buildReport(analysis, options) {
|
|
|
109
131
|
expectationScore = evaluated.result.score;
|
|
110
132
|
}
|
|
111
133
|
}
|
|
112
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Whether this repository is more than its documentation.
|
|
136
|
+
*
|
|
137
|
+
* If every source file lives under `docs/` or `website/`, the documentation is the
|
|
138
|
+
* product and its findings are the product's.
|
|
139
|
+
*/
|
|
140
|
+
const hasProductSource = analysis.files.source.some((file) => !detectPackaging_1.DOCS_DIRECTORIES.test(file));
|
|
141
|
+
const findings = (0, evidenceDigest_1.withEvidenceDigest)((0, businessImpact_1.withBusinessImpact)([...observedFindings, ...expectationFindings]
|
|
142
|
+
.map((finding) => onlyEvidencedInDocumentation(finding, hasProductSource)
|
|
143
|
+
? {
|
|
144
|
+
...finding,
|
|
145
|
+
status: 'unknown',
|
|
146
|
+
severity: 'info',
|
|
147
|
+
description: `${finding.description} Every line behind this is in the documentation site or a playground rather than in the product, so nothing here says the product does it.`,
|
|
148
|
+
}
|
|
149
|
+
: finding)
|
|
150
|
+
.sort((a, b) => bySeverityPriority(a) - bySeverityPriority(b))));
|
|
113
151
|
const combinedScore = expectationScore === undefined
|
|
114
152
|
? observedScore
|
|
115
153
|
: Math.max(0, Math.min(100, Math.round((observedScore * 0.6) + (expectationScore * 0.4))));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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": {
|