@produtype/core 0.60.0 → 0.62.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.
|
@@ -4,9 +4,35 @@ exports.detectObservability = detectObservability;
|
|
|
4
4
|
const detectContext_1 = require("./detectContext");
|
|
5
5
|
const textSearch_1 = require("../utils/textSearch");
|
|
6
6
|
const absenceEvidence_1 = require("./absenceEvidence");
|
|
7
|
+
const valuesFromPackage_1 = require("./structural/valuesFromPackage");
|
|
8
|
+
/**
|
|
9
|
+
* Logging packages, which the ecosystem names and the author does not.
|
|
10
|
+
*
|
|
11
|
+
* The list was four long and the search beside it read `logger.` — the author's own
|
|
12
|
+
* variable. A repository using Roarr, bound to `shout`, was reported as having no
|
|
13
|
+
* logging at all: wrong package, wrong variable, two ways to miss the same thing.
|
|
14
|
+
*
|
|
15
|
+
* A list of package names is still a list, but it is a list of names nobody in the
|
|
16
|
+
* repository being analysed chose. That is the difference the whole exercise is about.
|
|
17
|
+
*/
|
|
18
|
+
const LOGGING_PACKAGES = [
|
|
19
|
+
'winston',
|
|
20
|
+
'pino',
|
|
21
|
+
'morgan',
|
|
22
|
+
'bunyan',
|
|
23
|
+
'roarr',
|
|
24
|
+
'loglevel',
|
|
25
|
+
'signale',
|
|
26
|
+
'consola',
|
|
27
|
+
'tslog',
|
|
28
|
+
'log4js',
|
|
29
|
+
'@logtail/node',
|
|
30
|
+
'debug',
|
|
31
|
+
'npmlog',
|
|
32
|
+
];
|
|
7
33
|
async function detectObservability(ctx) {
|
|
8
34
|
const evidence = [];
|
|
9
|
-
const logDeps = (0, detectContext_1.hasAnyDep)(ctx,
|
|
35
|
+
const logDeps = (0, detectContext_1.hasAnyDep)(ctx, LOGGING_PACKAGES);
|
|
10
36
|
const sentryDeps = (0, detectContext_1.hasAnyDep)(ctx, ['@sentry/node', 'sentry-sdk']);
|
|
11
37
|
for (const d of logDeps)
|
|
12
38
|
evidence.push({ type: 'dependency', value: d, claim: 'logging' });
|
|
@@ -70,6 +96,26 @@ async function detectObservability(ctx) {
|
|
|
70
96
|
const plainLogging = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/\berror_log\s*\(/, /\bsyslog\s*\(/, /console\.(error|warn)\s*\(/, /\bprintStackTrace\s*\(/], 10);
|
|
71
97
|
for (const m of plainLogging)
|
|
72
98
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'logging' });
|
|
99
|
+
/**
|
|
100
|
+
* Where a value from a logging package is used, whatever it was called.
|
|
101
|
+
*
|
|
102
|
+
* `const shout = Roarr.child(...)` then `shout.info(...)` is structured logging, and
|
|
103
|
+
* the text search cannot see it because `shout` is a name its author invented. The
|
|
104
|
+
* import is the anchor and the binding is the chain.
|
|
105
|
+
*
|
|
106
|
+
* Evidence, not verdict, and the distinction is measured rather than assumed. This
|
|
107
|
+
* walk can only fire where one of the packages above is imported, and a package that
|
|
108
|
+
* is imported is a package the manifest declares — workspace manifests included, as
|
|
109
|
+
* a monorepo fixture confirmed. So it never changes the answer, and the clause that
|
|
110
|
+
* pretended it might was removed rather than left to look load-bearing.
|
|
111
|
+
*
|
|
112
|
+
* What it does change is what the reader is shown: "pino is in your package.json"
|
|
113
|
+
* becomes "pino is used at server.js:8". The claim was always about the second.
|
|
114
|
+
*/
|
|
115
|
+
const boundLoggerUses = await (0, valuesFromPackage_1.readPackageValueUses)(ctx.root, ctx.files.source, LOGGING_PACKAGES);
|
|
116
|
+
for (const use of (boundLoggerUses ?? []).slice(0, 10)) {
|
|
117
|
+
evidence.push({ type: 'file', value: `a logger from ${logDeps[0] ?? 'a logging package'} is used here`, file: use.file, line: use.line, claim: 'logging' });
|
|
118
|
+
}
|
|
73
119
|
const hasStructuredLogging = logDeps.length > 0 || structuredHits.length > 0;
|
|
74
120
|
const hasAnyLogging = hasStructuredLogging || plainLogging.length > 0;
|
|
75
121
|
if (!hasHealth) {
|
|
@@ -5,6 +5,7 @@ const detectContext_1 = require("./detectContext");
|
|
|
5
5
|
const readTextFileSafe_1 = require("../utils/readTextFileSafe");
|
|
6
6
|
const textSearch_1 = require("../utils/textSearch");
|
|
7
7
|
const absenceEvidence_1 = require("./absenceEvidence");
|
|
8
|
+
const valuesFromPackage_1 = require("./structural/valuesFromPackage");
|
|
8
9
|
const developmentOnly_1 = require("./developmentOnly");
|
|
9
10
|
/** Lines that decide which origins may call this server. */
|
|
10
11
|
const ORIGIN_HANDLING = [/Access-Control-Allow-Origin/i, /ALLOWED_ORIGINS/, /allowedOrigins/i];
|
|
@@ -110,6 +111,16 @@ async function findDjangoSettings(ctx) {
|
|
|
110
111
|
}
|
|
111
112
|
return null;
|
|
112
113
|
}
|
|
114
|
+
const RATE_LIMIT_PACKAGES = [
|
|
115
|
+
'express-rate-limit',
|
|
116
|
+
'@upstash/ratelimit',
|
|
117
|
+
'rate-limiter-flexible',
|
|
118
|
+
'next-rate-limit',
|
|
119
|
+
'express-slow-down',
|
|
120
|
+
'koa-ratelimit',
|
|
121
|
+
'fastify-rate-limit',
|
|
122
|
+
'@fastify/rate-limit',
|
|
123
|
+
];
|
|
113
124
|
async function detectSecurity(ctx) {
|
|
114
125
|
const evidence = [];
|
|
115
126
|
const source = ctx.files.source;
|
|
@@ -127,6 +138,10 @@ async function detectSecurity(ctx) {
|
|
|
127
138
|
/securityHeaders/i,
|
|
128
139
|
], 20);
|
|
129
140
|
const helmet = helmetDep || headerSignals.length > 0;
|
|
141
|
+
/**
|
|
142
|
+
* The packages the ecosystem names, as distinct from the variables authors do.
|
|
143
|
+
* Shared between the dependency check below and the binding walk further down.
|
|
144
|
+
*/
|
|
130
145
|
const rateLimitDep = (0, detectContext_1.hasDep)(ctx, 'express-rate-limit') ||
|
|
131
146
|
(0, detectContext_1.hasDep)(ctx, '@upstash/ratelimit') ||
|
|
132
147
|
(0, detectContext_1.hasDep)(ctx, 'rate-limiter-flexible') ||
|
|
@@ -141,23 +156,44 @@ async function detectSecurity(ctx) {
|
|
|
141
156
|
* The rule is titled "Rate limit on auth surfaces" and its own passing sentence says
|
|
142
157
|
* "detected on the authentication surface", and the flag behind both was rate
|
|
143
158
|
* limiting *anywhere*: a limiter on a public feed cleared the check for a sign-in
|
|
144
|
-
* page that has none.
|
|
159
|
+
* page that has none.
|
|
145
160
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
161
|
+
* Finding the limiter by its name is the part that does not hold. `const limiter =
|
|
162
|
+
* rateLimit(...)` is found because somebody wrote "rateLimit"; the same protection
|
|
163
|
+
* written as `const thisIsFuckingTopUse = require('express-rate-limit')` is
|
|
164
|
+
* invisible, and a login that is in fact protected gets downgraded. The name is the
|
|
165
|
+
* one thing its author chose freely, and it is what every search here reads.
|
|
149
166
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* two; TranscribeAI stays `partial`, which is what this can show.
|
|
167
|
+
* So the anchor is the package, which the author did not name, and the chain from
|
|
168
|
+
* there is mechanical: the binding the import is assigned to, the values that
|
|
169
|
+
* binding produces when called, and every place those values are used. Where the
|
|
170
|
+
* parser is installed that answers the question outright; where it is not, the text
|
|
171
|
+
* search below is still the floor, which is the same contract every other
|
|
172
|
+
* structural reader here keeps.
|
|
157
173
|
*/
|
|
174
|
+
const boundLimiterUses = await (0, valuesFromPackage_1.readPackageValueUses)(ctx.root, source, RATE_LIMIT_PACKAGES);
|
|
158
175
|
const authSurfaceFiles = new Set((await (0, textSearch_1.searchInFiles)(ctx.root, source, [/['"`]\/(login|signin|sign-in|auth|session)/i, /passport\./, /signIn\s*\(/, /authenticate\s*\(/], 40))
|
|
159
176
|
.map((match) => match.file));
|
|
160
|
-
|
|
177
|
+
/**
|
|
178
|
+
* A limiter mounted on a prefix covers what is mounted under it.
|
|
179
|
+
*
|
|
180
|
+
* TranscribeAI writes `app.use('/api/', limiter)` and, seventy lines down,
|
|
181
|
+
* `app.use('/api/auth', authRoutes)`. Its login is protected and a same-file test
|
|
182
|
+
* called it unprotected, because the router lives in another file. Both mount paths
|
|
183
|
+
* are strings in this one, and `/api/auth` says what it carries — so the coverage
|
|
184
|
+
* is readable without following the router anywhere.
|
|
185
|
+
*/
|
|
186
|
+
const mountedPaths = (boundLimiterUses ?? [])
|
|
187
|
+
.map((use) => use.mountPath)
|
|
188
|
+
.filter((path) => Boolean(path))
|
|
189
|
+
.map((path) => path.replace(/\/+$/, ''));
|
|
190
|
+
const authMountPaths = (await (0, textSearch_1.searchInFiles)(ctx.root, source, [/\buse\(\s*['"`]\/[^'"`]*(auth|login|signin|session|account)/i], 20))
|
|
191
|
+
.map((match) => /\buse\(\s*['"`](\/[^'"`]*)['"`]/.exec(match.snippet)?.[1])
|
|
192
|
+
.filter((path) => Boolean(path));
|
|
193
|
+
const coversAnAuthMount = mountedPaths.some((prefix) => authMountPaths.some((mount) => mount === prefix || mount.startsWith(`${prefix}/`)));
|
|
194
|
+
const rateLimitNearAuth = coversAnAuthMount
|
|
195
|
+
|| (boundLimiterUses ?? []).some((use) => authSurfaceFiles.has(use.file))
|
|
196
|
+
|| rateLimitSignals.some((match) => authSurfaceFiles.has(match.file));
|
|
161
197
|
if (helmetDep)
|
|
162
198
|
evidence.push({ type: 'dependency', value: 'helmet', claim: 'headers' });
|
|
163
199
|
if (rateLimitDep)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface PackageValueUse {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
/**
|
|
5
|
+
* The path it was mounted on, where the use is an argument to `use(path, value)`.
|
|
6
|
+
*
|
|
7
|
+
* `app.use('/api/', limiter)` covers everything mounted under `/api/`, including an
|
|
8
|
+
* auth router registered three lines later. Reading that needs the mount path, and
|
|
9
|
+
* the mount path is a string in the source rather than a name anybody chose.
|
|
10
|
+
*/
|
|
11
|
+
mountPath?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Every line where a value that originated in one of `packages` is used.
|
|
15
|
+
*
|
|
16
|
+
* `null` when the parser is not installed, which is the same contract the other
|
|
17
|
+
* structural readers keep: absent means "this question was not asked", and the caller
|
|
18
|
+
* falls back to text.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readPackageValueUses(root: string, files: string[], packages: string[]): Promise<PackageValueUse[] | null>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readPackageValueUses = readPackageValueUses;
|
|
4
|
+
const readTextFileSafe_1 = require("../../utils/readTextFileSafe");
|
|
5
|
+
const loadTypeScript_1 = require("./loadTypeScript");
|
|
6
|
+
/**
|
|
7
|
+
* Where a value that came out of a given package is used, whatever the author called it.
|
|
8
|
+
*
|
|
9
|
+
* Every search in this analyzer reads names, and a name is the one part of the code
|
|
10
|
+
* its author chose freely. `const limiter = rateLimit(...)` is found because somebody
|
|
11
|
+
* wrote "rateLimit"; the same protection written as
|
|
12
|
+
*
|
|
13
|
+
* const thisIsFuckingTopUse = require('express-rate-limit');
|
|
14
|
+
* router.post('/login', thisIsFuckingTopUse({ max: 5 }), handler);
|
|
15
|
+
*
|
|
16
|
+
* is invisible, and the report downgrades a login that is in fact protected. That is
|
|
17
|
+
* not a gap in a word list. It is the word list.
|
|
18
|
+
*
|
|
19
|
+
* The anchor here is the one name the author did not choose: the package specifier.
|
|
20
|
+
* `express-rate-limit` is what the ecosystem calls it, and from that import the chain
|
|
21
|
+
* is mechanical rather than guessed — the binding it is assigned to, the values that
|
|
22
|
+
* binding produces when called, and every place those values are then used. Following
|
|
23
|
+
* a binding is not a heuristic; it is what the language already means.
|
|
24
|
+
*
|
|
25
|
+
* TypeScript and JavaScript only, because that is where the parser reaches. Elsewhere
|
|
26
|
+
* the floor is still text, and the reading-depth line in the report says so.
|
|
27
|
+
*/
|
|
28
|
+
const READABLE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
29
|
+
function packageOf(ts, node) {
|
|
30
|
+
// `require('x')`
|
|
31
|
+
if (ts.isCallExpression(node)
|
|
32
|
+
&& ts.isIdentifier(node.expression)
|
|
33
|
+
&& node.expression.text === 'require'
|
|
34
|
+
&& node.arguments.length === 1
|
|
35
|
+
&& ts.isStringLiteralLike(node.arguments[0])) {
|
|
36
|
+
return node.arguments[0].text;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function bindingsFor(ts, source, packages) {
|
|
41
|
+
const bound = new Set();
|
|
42
|
+
const visit = (node) => {
|
|
43
|
+
/** `import x from 'pkg'`, and `import { y } from 'pkg'`. */
|
|
44
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
|
45
|
+
if (packages.has(node.moduleSpecifier.text)) {
|
|
46
|
+
const clause = node.importClause;
|
|
47
|
+
if (clause?.name)
|
|
48
|
+
bound.add(clause.name.text);
|
|
49
|
+
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
50
|
+
for (const element of clause.namedBindings.elements)
|
|
51
|
+
bound.add(element.name.text);
|
|
52
|
+
}
|
|
53
|
+
if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
|
|
54
|
+
bound.add(clause.namedBindings.name.text);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** `const x = require('pkg')`, and `const { y } = require('pkg')`. */
|
|
59
|
+
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
60
|
+
const specifier = packageOf(ts, node.initializer);
|
|
61
|
+
if (specifier && packages.has(specifier)) {
|
|
62
|
+
if (ts.isIdentifier(node.name))
|
|
63
|
+
bound.add(node.name.text);
|
|
64
|
+
if (ts.isObjectBindingPattern(node.name)) {
|
|
65
|
+
for (const element of node.name.elements) {
|
|
66
|
+
if (ts.isIdentifier(element.name))
|
|
67
|
+
bound.add(element.name.text);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
ts.forEachChild(node, visit);
|
|
73
|
+
};
|
|
74
|
+
visit(source);
|
|
75
|
+
return bound;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Values produced by calling a binding, so `const limiter = rateLimit({...})` puts
|
|
79
|
+
* `limiter` in the chain alongside `rateLimit` itself.
|
|
80
|
+
*
|
|
81
|
+
* One hop, deliberately. A factory that returns a factory is rare enough that chasing
|
|
82
|
+
* it would add reach nobody has asked for, and every extra hop is another way to be
|
|
83
|
+
* wrong about what a value is.
|
|
84
|
+
*/
|
|
85
|
+
function derivedFrom(ts, source, roots) {
|
|
86
|
+
const derived = new Set();
|
|
87
|
+
const visit = (node) => {
|
|
88
|
+
if (ts.isVariableDeclaration(node)
|
|
89
|
+
&& node.initializer
|
|
90
|
+
&& ts.isCallExpression(node.initializer)
|
|
91
|
+
&& ts.isIdentifier(node.initializer.expression)
|
|
92
|
+
&& roots.has(node.initializer.expression.text)
|
|
93
|
+
&& ts.isIdentifier(node.name)) {
|
|
94
|
+
derived.add(node.name.text);
|
|
95
|
+
}
|
|
96
|
+
ts.forEachChild(node, visit);
|
|
97
|
+
};
|
|
98
|
+
visit(source);
|
|
99
|
+
return derived;
|
|
100
|
+
}
|
|
101
|
+
/** The `use(path, …)` this node sits inside, if any. */
|
|
102
|
+
function mountPathOf(ts, node) {
|
|
103
|
+
const call = node.parent;
|
|
104
|
+
if (!call || !ts.isCallExpression(call))
|
|
105
|
+
return undefined;
|
|
106
|
+
if (!call.arguments.some((argument) => argument === node))
|
|
107
|
+
return undefined;
|
|
108
|
+
const callee = call.expression;
|
|
109
|
+
const isUse = ts.isPropertyAccessExpression(callee) && callee.name.text === 'use';
|
|
110
|
+
if (!isUse)
|
|
111
|
+
return undefined;
|
|
112
|
+
const [first] = call.arguments;
|
|
113
|
+
return first && ts.isStringLiteralLike(first) ? first.text : undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Every line where a value that originated in one of `packages` is used.
|
|
117
|
+
*
|
|
118
|
+
* `null` when the parser is not installed, which is the same contract the other
|
|
119
|
+
* structural readers keep: absent means "this question was not asked", and the caller
|
|
120
|
+
* falls back to text.
|
|
121
|
+
*/
|
|
122
|
+
async function readPackageValueUses(root, files, packages) {
|
|
123
|
+
const ts = await (0, loadTypeScript_1.loadTypeScript)();
|
|
124
|
+
if (!ts)
|
|
125
|
+
return null;
|
|
126
|
+
const wanted = new Set(packages);
|
|
127
|
+
const uses = [];
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
if (!READABLE.test(file))
|
|
130
|
+
continue;
|
|
131
|
+
const text = await (0, readTextFileSafe_1.readTextFileSafe)(root, file);
|
|
132
|
+
if (!text)
|
|
133
|
+
continue;
|
|
134
|
+
if (!packages.some((name) => text.includes(name)))
|
|
135
|
+
continue;
|
|
136
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
137
|
+
const roots = bindingsFor(ts, source, wanted);
|
|
138
|
+
if (roots.size === 0)
|
|
139
|
+
continue;
|
|
140
|
+
const names = new Set([...roots, ...derivedFrom(ts, source, roots)]);
|
|
141
|
+
const visit = (node) => {
|
|
142
|
+
if (ts.isIdentifier(node) && names.has(node.text)) {
|
|
143
|
+
/**
|
|
144
|
+
* A use, not a declaration. `const limiter = rateLimit(...)` names `limiter`
|
|
145
|
+
* on the left and uses `rateLimit` on the right; only the second is a place
|
|
146
|
+
* the protection is applied.
|
|
147
|
+
*/
|
|
148
|
+
const parent = node.parent;
|
|
149
|
+
const isDeclarationName = (parent && ts.isVariableDeclaration(parent) && parent.name === node)
|
|
150
|
+
|| (parent && ts.isImportSpecifier(parent))
|
|
151
|
+
|| (parent && ts.isImportClause(parent))
|
|
152
|
+
|| (parent && ts.isNamespaceImport(parent))
|
|
153
|
+
|| (parent && ts.isBindingElement(parent) && parent.name === node);
|
|
154
|
+
if (!isDeclarationName) {
|
|
155
|
+
uses.push({
|
|
156
|
+
file,
|
|
157
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
158
|
+
mountPath: mountPathOf(ts, node),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
ts.forEachChild(node, visit);
|
|
163
|
+
};
|
|
164
|
+
visit(source);
|
|
165
|
+
}
|
|
166
|
+
return uses;
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.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": {
|