@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, ['winston', 'pino', 'morgan', 'bunyan']);
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. Sign-in is the endpoint the limit exists for.
159
+ * page that has none.
145
160
  *
146
- * The same file, which is as far as this reaches honestly. Where a project splits
147
- * the limiter from the login the answer becomes "found, not shown to cover sign-in",
148
- * which is true and which the reader can dismiss in two seconds if they know better.
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
- * A prefix rule was written for this and removed. TranscribeAI protects its login
151
- * with `app.use('/api/', limiter)` above `app.use('/api/auth', authRoutes)`, and
152
- * matching the limiter's mount path against the auth router's looked like the right
153
- * generalisation — but the mount line says `limiter`, not `rateLimit`, so no rate
154
- * limit signal is ever on it and the rule never fired on the one case it was written
155
- * for. Following the variable would work and is a third layer of guessing on top of
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
- const rateLimitNearAuth = rateLimitSignals.some((match) => authSurfaceFiles.has(match.file));
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.60.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": {