@produtype/core 0.63.0 → 0.65.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.
- package/dist/analyzer/detectEnv.js +15 -0
- package/dist/analyzer/detectSecurity.js +29 -6
- package/dist/analyzer/structural/secretArguments.d.ts +8 -0
- package/dist/analyzer/structural/secretArguments.js +171 -0
- package/dist/analyzer/structural/valuesFromPackage.d.ts +8 -0
- package/dist/analyzer/structural/valuesFromPackage.js +1 -0
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.detectEnv = detectEnv;
|
|
4
4
|
const textSearch_1 = require("../utils/textSearch");
|
|
5
5
|
const lookupTables_1 = require("./structural/lookupTables");
|
|
6
|
+
const secretArguments_1 = require("./structural/secretArguments");
|
|
6
7
|
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;
|
|
7
8
|
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;
|
|
8
9
|
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;
|
|
@@ -124,6 +125,20 @@ async function detectEnv(ctx) {
|
|
|
124
125
|
*/
|
|
125
126
|
const lookupTables = await (0, lookupTables_1.readLookupTableLines)(ctx.root, sourceFiles);
|
|
126
127
|
const isTableEntry = (match) => lookupTables?.get(match.file)?.has(match.line) === true;
|
|
128
|
+
/**
|
|
129
|
+
* The other end of the value, where the library says what it is.
|
|
130
|
+
*
|
|
131
|
+
* Everything above starts from the name being assigned, and the name is the
|
|
132
|
+
* author's: an Italian application signing with `const chiave =
|
|
133
|
+
* process.env.CHIAVE_FIRMA || 'cambiami'` came back `passed`. `jwt.sign(payload,
|
|
134
|
+
* secret)` defines its second argument, and arriving there is what makes a literal
|
|
135
|
+
* a secret whatever it was called on the way.
|
|
136
|
+
*/
|
|
137
|
+
const secretArguments = await (0, secretArguments_1.readHardcodedSecretArguments)(ctx.root, sourceFiles);
|
|
138
|
+
for (const hit of secretArguments ?? []) {
|
|
139
|
+
weakSecretEvidence.push({ type: 'snippet', value: `${hit.snippet} — reaches ${hit.sink}`, file: hit.file, line: hit.line });
|
|
140
|
+
weakSecretByType.unknown.push({ type: 'snippet', value: `${hit.snippet} — reaches ${hit.sink}`, file: hit.file, line: hit.line });
|
|
141
|
+
}
|
|
127
142
|
const weakHits = fallbackHits.filter((m) => WEAK_SECRET_VALUE_RE.test(m.snippet)
|
|
128
143
|
&& SECRET_ASSIGNMENT_CONTEXT_RE.test(m.snippet)
|
|
129
144
|
&& !namesItself(m.snippet)
|
|
@@ -44,19 +44,32 @@ function withoutStringLiterals(line) {
|
|
|
44
44
|
}
|
|
45
45
|
/** The Express middleware, brought into the file that configures it. */
|
|
46
46
|
const IMPORTS_CORS = /(require\(['"]cors['"]\))|(from\s+['"]cors['"])|(import\s+['"]cors['"])/;
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The middleware, under whatever name it was given.
|
|
49
|
+
*
|
|
50
|
+
* `cors(` is the name of the export, and an author is free not to use it:
|
|
51
|
+
* `const apriTutto = require('cors'); app.use(apriTutto());` is a wide-open policy
|
|
52
|
+
* that came back as "CORS configuration not detected" — a real hole reported as the
|
|
53
|
+
* absence of a question. The package is the anchor; the names come from the binding
|
|
54
|
+
* and are recognised here rather than guessed.
|
|
55
|
+
*/
|
|
56
|
+
function detectCorsConfig(text, file, boundNames) {
|
|
48
57
|
const loose = [];
|
|
49
58
|
const strict = [];
|
|
50
59
|
const lines = text.split(/\r?\n/);
|
|
51
|
-
const
|
|
60
|
+
const names = [...new Set(['cors', ...boundNames])].map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
61
|
+
const anyName = names.join('|');
|
|
62
|
+
const callRegex = new RegExp(`\\b(?:${anyName})\\s*\\(`);
|
|
63
|
+
const bareCallRegex = new RegExp(`\\b(?:${anyName})\\(\\s*\\)`);
|
|
64
|
+
const corsVarRegex = new RegExp(`\\b(?:${anyName})\\(\\s*([A-Za-z_$][\\w$]*)\\s*\\)`);
|
|
52
65
|
for (let i = 0; i < lines.length; i++) {
|
|
53
66
|
const line = lines[i];
|
|
54
67
|
if (!(0, textSearch_1.isCitableLine)(line))
|
|
55
68
|
continue;
|
|
56
|
-
if (
|
|
69
|
+
if (!callRegex.test(withoutStringLiterals(line)))
|
|
57
70
|
continue;
|
|
58
71
|
const snippet = line.trim().slice(0, 200);
|
|
59
|
-
if (
|
|
72
|
+
if (bareCallRegex.test(line)) {
|
|
60
73
|
loose.push({ file, line: i + 1, snippet });
|
|
61
74
|
continue;
|
|
62
75
|
}
|
|
@@ -202,6 +215,14 @@ async function detectSecurity(ctx) {
|
|
|
202
215
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'headers' });
|
|
203
216
|
for (const m of rateLimitSignals)
|
|
204
217
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'rate-limit' });
|
|
218
|
+
/**
|
|
219
|
+
* Every identifier the `cors` package reaches, per file.
|
|
220
|
+
*
|
|
221
|
+
* `const apriTutto = require('cors')` binds the middleware to a name no word list
|
|
222
|
+
* will ever hold, and `app.use(apriTutto())` is a wide-open policy that read as no
|
|
223
|
+
* policy at all. The import is the fact; the name is whatever this author typed.
|
|
224
|
+
*/
|
|
225
|
+
const corsBindings = await (0, valuesFromPackage_1.readPackageValueUses)(ctx.root, source, ['cors']);
|
|
205
226
|
const corsLoose = [];
|
|
206
227
|
const corsStrict = [];
|
|
207
228
|
for (const file of source) {
|
|
@@ -243,9 +264,11 @@ async function detectSecurity(ctx) {
|
|
|
243
264
|
}
|
|
244
265
|
if (!IMPORTS_CORS.test(text))
|
|
245
266
|
continue;
|
|
246
|
-
|
|
267
|
+
const boundNames = new Set((corsBindings ?? []).filter((use) => use.file === file).map((use) => use.name));
|
|
268
|
+
// `cors(` under its own name, or under the one the binding gave it.
|
|
269
|
+
if (boundNames.size === 0 && !/\bcors\s*\(/.test(text))
|
|
247
270
|
continue;
|
|
248
|
-
const detected = detectCorsConfig(text, file);
|
|
271
|
+
const detected = detectCorsConfig(text, file, boundNames);
|
|
249
272
|
corsLoose.push(...detected.loose);
|
|
250
273
|
corsStrict.push(...detected.strict);
|
|
251
274
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface HardcodedSecretArgument {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
snippet: string;
|
|
5
|
+
/** The call that defines this value as a secret, for the sentence shown to a reader. */
|
|
6
|
+
sink: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function readHardcodedSecretArguments(root: string, files: string[]): Promise<HardcodedSecretArgument[] | null>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readHardcodedSecretArguments = readHardcodedSecretArguments;
|
|
4
|
+
const readTextFileSafe_1 = require("../../utils/readTextFileSafe");
|
|
5
|
+
const loadTypeScript_1 = require("./loadTypeScript");
|
|
6
|
+
/**
|
|
7
|
+
* A literal reaching a parameter the library defines as a secret.
|
|
8
|
+
*
|
|
9
|
+
* Every other reading of "is this a hardcoded secret" starts from the name of the
|
|
10
|
+
* thing being assigned — `JWT_SECRET`, `SECRET_KEY`, `API_KEY`. That name is the
|
|
11
|
+
* author's, and an Italian application signing its tokens with
|
|
12
|
+
*
|
|
13
|
+
* const chiave = process.env.CHIAVE_FIRMA || 'cambiami';
|
|
14
|
+
* jwt.sign({ sub: utente.id }, chiave);
|
|
15
|
+
*
|
|
16
|
+
* came back `passed`: "no weak fallback secret patterns detected", about a signing
|
|
17
|
+
* key hardcoded in the source. The check congratulated it.
|
|
18
|
+
*
|
|
19
|
+
* The anchor that does not move is the other end. `jsonwebtoken.sign(payload, secret)`
|
|
20
|
+
* says what its second argument is — that is the library's contract, not a convention
|
|
21
|
+
* — and `crypto.createHmac(algorithm, key)` says the same. Whatever the value is
|
|
22
|
+
* called on the way there, arriving there is what makes it a secret.
|
|
23
|
+
*
|
|
24
|
+
* One hop of resolution, matching the rest of the structural readers: a literal
|
|
25
|
+
* passed directly, or an identifier declared in the same file whose initializer is a
|
|
26
|
+
* literal or falls back to one. Two hops would reach further and be wrong more often,
|
|
27
|
+
* and the text search remains underneath either way.
|
|
28
|
+
*/
|
|
29
|
+
const READABLE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
30
|
+
const SECRET_SINKS = [
|
|
31
|
+
{ package: 'jsonwebtoken', method: 'sign', argument: 1 },
|
|
32
|
+
{ package: 'jsonwebtoken', method: 'verify', argument: 1 },
|
|
33
|
+
{ package: 'crypto', method: 'createHmac', argument: 1 },
|
|
34
|
+
{ package: 'node:crypto', method: 'createHmac', argument: 1 },
|
|
35
|
+
{ package: 'crypto', method: 'createCipheriv', argument: 1 },
|
|
36
|
+
{ package: 'node:crypto', method: 'createCipheriv', argument: 1 },
|
|
37
|
+
{ package: 'express-session', option: 'secret' },
|
|
38
|
+
{ package: 'cookie-session', option: 'secret' },
|
|
39
|
+
{ package: 'iron-session', option: 'password' },
|
|
40
|
+
];
|
|
41
|
+
function bindingsFor(ts, source, specifier) {
|
|
42
|
+
const bound = new Set();
|
|
43
|
+
const visit = (node) => {
|
|
44
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
|
45
|
+
if (node.moduleSpecifier.text === specifier) {
|
|
46
|
+
const clause = node.importClause;
|
|
47
|
+
if (clause?.name)
|
|
48
|
+
bound.add(clause.name.text);
|
|
49
|
+
if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings))
|
|
50
|
+
bound.add(clause.namedBindings.name.text);
|
|
51
|
+
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
52
|
+
for (const element of clause.namedBindings.elements)
|
|
53
|
+
bound.add(element.name.text);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (ts.isVariableDeclaration(node)
|
|
58
|
+
&& node.initializer
|
|
59
|
+
&& ts.isCallExpression(node.initializer)
|
|
60
|
+
&& ts.isIdentifier(node.initializer.expression)
|
|
61
|
+
&& node.initializer.expression.text === 'require'
|
|
62
|
+
&& node.initializer.arguments.length === 1
|
|
63
|
+
&& ts.isStringLiteralLike(node.initializer.arguments[0])
|
|
64
|
+
&& node.initializer.arguments[0].text === specifier) {
|
|
65
|
+
if (ts.isIdentifier(node.name))
|
|
66
|
+
bound.add(node.name.text);
|
|
67
|
+
if (ts.isObjectBindingPattern(node.name)) {
|
|
68
|
+
for (const element of node.name.elements) {
|
|
69
|
+
if (ts.isIdentifier(element.name))
|
|
70
|
+
bound.add(element.name.text);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
ts.forEachChild(node, visit);
|
|
75
|
+
};
|
|
76
|
+
visit(source);
|
|
77
|
+
return bound;
|
|
78
|
+
}
|
|
79
|
+
/** The literal an expression is, or falls back to, following one declaration. */
|
|
80
|
+
function literalBehind(ts, source, expression) {
|
|
81
|
+
if (ts.isStringLiteralLike(expression))
|
|
82
|
+
return true;
|
|
83
|
+
if (ts.isBinaryExpression(expression)
|
|
84
|
+
&& (expression.operatorToken.kind === ts.SyntaxKind.BarBarToken
|
|
85
|
+
|| expression.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)) {
|
|
86
|
+
return literalBehind(ts, source, expression.right);
|
|
87
|
+
}
|
|
88
|
+
if (ts.isIdentifier(expression)) {
|
|
89
|
+
let found = false;
|
|
90
|
+
const visit = (node) => {
|
|
91
|
+
if (!found
|
|
92
|
+
&& ts.isVariableDeclaration(node)
|
|
93
|
+
&& ts.isIdentifier(node.name)
|
|
94
|
+
&& node.name.text === expression.text
|
|
95
|
+
&& node.initializer
|
|
96
|
+
&& !ts.isIdentifier(node.initializer)) {
|
|
97
|
+
found = literalBehind(ts, source, node.initializer);
|
|
98
|
+
}
|
|
99
|
+
ts.forEachChild(node, visit);
|
|
100
|
+
};
|
|
101
|
+
visit(source);
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
async function readHardcodedSecretArguments(root, files) {
|
|
107
|
+
const ts = await (0, loadTypeScript_1.loadTypeScript)();
|
|
108
|
+
if (!ts)
|
|
109
|
+
return null;
|
|
110
|
+
const found = [];
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
if (!READABLE.test(file))
|
|
113
|
+
continue;
|
|
114
|
+
const text = await (0, readTextFileSafe_1.readTextFileSafe)(root, file);
|
|
115
|
+
if (!text)
|
|
116
|
+
continue;
|
|
117
|
+
if (!SECRET_SINKS.some((sink) => text.includes(sink.package.replace('node:', ''))))
|
|
118
|
+
continue;
|
|
119
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
120
|
+
for (const sink of SECRET_SINKS) {
|
|
121
|
+
const bound = bindingsFor(ts, source, sink.package);
|
|
122
|
+
if (bound.size === 0)
|
|
123
|
+
continue;
|
|
124
|
+
const visit = (node) => {
|
|
125
|
+
if (ts.isCallExpression(node)) {
|
|
126
|
+
const callee = node.expression;
|
|
127
|
+
const named = sink.method
|
|
128
|
+
? ts.isPropertyAccessExpression(callee)
|
|
129
|
+
&& callee.name.text === sink.method
|
|
130
|
+
&& ts.isIdentifier(callee.expression)
|
|
131
|
+
&& bound.has(callee.expression.text)
|
|
132
|
+
: ts.isIdentifier(callee) && bound.has(callee.text);
|
|
133
|
+
if (named) {
|
|
134
|
+
const report = (expression) => {
|
|
135
|
+
if (!literalBehind(ts, source, expression))
|
|
136
|
+
return;
|
|
137
|
+
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line;
|
|
138
|
+
found.push({
|
|
139
|
+
file,
|
|
140
|
+
line: line + 1,
|
|
141
|
+
snippet: (text.split(/\r?\n/)[line] ?? '').trim().slice(0, 200),
|
|
142
|
+
sink: sink.method ? `${sink.package}.${sink.method}()` : `${sink.package}()`,
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
if (sink.argument !== undefined) {
|
|
146
|
+
const argument = node.arguments[sink.argument];
|
|
147
|
+
if (argument)
|
|
148
|
+
report(argument);
|
|
149
|
+
}
|
|
150
|
+
if (sink.option) {
|
|
151
|
+
for (const argument of node.arguments) {
|
|
152
|
+
if (!ts.isObjectLiteralExpression(argument))
|
|
153
|
+
continue;
|
|
154
|
+
for (const property of argument.properties) {
|
|
155
|
+
if (ts.isPropertyAssignment(property)
|
|
156
|
+
&& ts.isIdentifier(property.name)
|
|
157
|
+
&& property.name.text === sink.option) {
|
|
158
|
+
report(property.initializer);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
ts.forEachChild(node, visit);
|
|
166
|
+
};
|
|
167
|
+
visit(source);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return found;
|
|
171
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
export interface PackageValueUse {
|
|
2
2
|
file: string;
|
|
3
3
|
line: number;
|
|
4
|
+
/**
|
|
5
|
+
* The identifier the value is reached through.
|
|
6
|
+
*
|
|
7
|
+
* A caller that needs to reason about the call — is this `cors()` bare or
|
|
8
|
+
* `cors({ origin })`? — needs the name the author gave it, not to guess the name
|
|
9
|
+
* but to recognise it once the import has already proved what it is.
|
|
10
|
+
*/
|
|
11
|
+
name: string;
|
|
4
12
|
/**
|
|
5
13
|
* The path it was mounted on, where the use is an argument to `use(path, value)`.
|
|
6
14
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.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": {
|