@produtype/core 0.72.0 → 0.74.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/analyzeProject.js +2 -0
- package/dist/analyzer/detectAuth.js +11 -0
- package/dist/analyzer/detectEnv.js +14 -0
- package/dist/analyzer/detectSecurity.js +11 -0
- package/dist/analyzer/readingDepth.d.ts +27 -2
- package/dist/analyzer/readingDepth.js +61 -6
- package/dist/analyzer/structural/loadTypeScript.d.ts +23 -0
- package/dist/analyzer/structural/loadTypeScript.js +29 -0
- package/dist/analyzer/structural/ownershipChecks.d.ts +8 -0
- package/dist/analyzer/structural/ownershipChecks.js +20 -0
- package/dist/analyzer/structural/secretArguments.d.ts +14 -0
- package/dist/analyzer/structural/secretArguments.js +26 -0
- package/dist/analyzer/types.d.ts +27 -0
- package/dist/report/buildReport.js +2 -1
- package/dist/report/markdownReport.js +2 -2
- package/dist/report/types.d.ts +8 -0
- package/dist/rules/rules.js +45 -8
- package/package.json +1 -1
|
@@ -65,6 +65,7 @@ const detectNotifications_1 = require("./detectNotifications");
|
|
|
65
65
|
const detectDeployment_1 = require("./detectDeployment");
|
|
66
66
|
const detectStack_1 = require("./detectStack");
|
|
67
67
|
const promises_1 = require("node:fs/promises");
|
|
68
|
+
const loadTypeScript_1 = require("./structural/loadTypeScript");
|
|
68
69
|
const packageJsonSchema = zod_1.z
|
|
69
70
|
.object({
|
|
70
71
|
name: zod_1.z.string().optional(),
|
|
@@ -841,6 +842,7 @@ async function analyzeProject(projectPath) {
|
|
|
841
842
|
return {
|
|
842
843
|
projectPath: root,
|
|
843
844
|
scannedAt: new Date().toISOString(),
|
|
845
|
+
parsedStructure: await (0, loadTypeScript_1.typeScriptIsAvailable)(),
|
|
844
846
|
stack: (0, detectStack_1.buildStackInfo)({
|
|
845
847
|
frontend: frontend.frameworks,
|
|
846
848
|
backend: backend.frameworks,
|
|
@@ -8,6 +8,7 @@ const absenceEvidence_1 = require("./absenceEvidence");
|
|
|
8
8
|
const fileNames_1 = require("./fileNames");
|
|
9
9
|
const valuesFromPackage_1 = require("./structural/valuesFromPackage");
|
|
10
10
|
const ownershipChecks_1 = require("./structural/ownershipChecks");
|
|
11
|
+
const readingDepth_1 = require("./readingDepth");
|
|
11
12
|
/**
|
|
12
13
|
* In a product that talks to a model, `role` usually means who is speaking.
|
|
13
14
|
*
|
|
@@ -376,6 +377,7 @@ async function detectAuth(ctx) {
|
|
|
376
377
|
* structure and needs no vocabulary at all.
|
|
377
378
|
*/
|
|
378
379
|
const structuralOwnership = await (0, ownershipChecks_1.readOwnershipChecks)(ctx.root, sourceFiles);
|
|
380
|
+
const ownershipUnasked = (0, readingDepth_1.wentUnasked)(structuralOwnership, sourceFiles) && (await (0, ownershipChecks_1.anyFileImportsExpress)(ctx.root, sourceFiles));
|
|
379
381
|
const hasAuth = authDeps.length > 0 || routeSignals.length > 0;
|
|
380
382
|
const hasAuthz = permissionSignals.length > 0 || roleSignals.length > 0;
|
|
381
383
|
const b2bHint = b2bSignals.length > 0;
|
|
@@ -451,6 +453,15 @@ async function detectAuth(ctx) {
|
|
|
451
453
|
// Route-level permission checks no longer stand in for per-record ones: with the
|
|
452
454
|
// needles above narrowed, this clause could only reintroduce what they removed.
|
|
453
455
|
present: resourceLevelSignals.length > 0 || (structuralOwnership ?? []).length > 0,
|
|
456
|
+
/**
|
|
457
|
+
* The capability with no package to anchor on, read entirely from structure.
|
|
458
|
+
*
|
|
459
|
+
* Without the optional compiler the Express route walk does not happen, and the
|
|
460
|
+
* only thing left is the vocabulary this detector was written to stop relying on.
|
|
461
|
+
* `proprieta-in-italiano` turns from `passed` into a false `missing` — a
|
|
462
|
+
* recommendation to add a check the code already has.
|
|
463
|
+
*/
|
|
464
|
+
unanswered: ownershipUnasked && resourceLevelSignals.length === 0,
|
|
454
465
|
evidence: (0, absenceEvidence_1.evidenceOrSearch)([
|
|
455
466
|
...snippetEvidence(resourceLevelSignals),
|
|
456
467
|
...(structuralOwnership ?? []).slice(0, 6).map((check) => ({
|
|
@@ -4,6 +4,7 @@ exports.detectEnv = detectEnv;
|
|
|
4
4
|
const textSearch_1 = require("../utils/textSearch");
|
|
5
5
|
const lookupTables_1 = require("./structural/lookupTables");
|
|
6
6
|
const secretArguments_1 = require("./structural/secretArguments");
|
|
7
|
+
const readingDepth_1 = require("./readingDepth");
|
|
7
8
|
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;
|
|
8
9
|
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;
|
|
9
10
|
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;
|
|
@@ -139,6 +140,14 @@ async function detectEnv(ctx) {
|
|
|
139
140
|
weakSecretEvidence.push({ type: 'snippet', value: `${hit.snippet} — reaches ${hit.sink}`, file: hit.file, line: hit.line });
|
|
140
141
|
weakSecretByType.unknown.push({ type: 'snippet', value: `${hit.snippet} — reaches ${hit.sink}`, file: hit.file, line: hit.line });
|
|
141
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Whether the sink side of the question went unasked.
|
|
145
|
+
*
|
|
146
|
+
* Only the reader that *finds* secrets counts here. `lookupTables` going missing makes
|
|
147
|
+
* this detector noisier, not blinder, and a noisy finding is one a reader can see and
|
|
148
|
+
* argue with — silence is not.
|
|
149
|
+
*/
|
|
150
|
+
const secretSinksUnasked = (0, readingDepth_1.wentUnasked)(secretArguments, sourceFiles) && (await (0, secretArguments_1.anyFileReachesASecretSink)(ctx.root, sourceFiles));
|
|
142
151
|
const weakHits = fallbackHits.filter((m) => WEAK_SECRET_VALUE_RE.test(m.snippet)
|
|
143
152
|
&& SECRET_ASSIGNMENT_CONTEXT_RE.test(m.snippet)
|
|
144
153
|
&& !namesItself(m.snippet)
|
|
@@ -167,26 +176,31 @@ async function detectEnv(ctx) {
|
|
|
167
176
|
{
|
|
168
177
|
key: 'env.secretFallback.jwt',
|
|
169
178
|
present: weakSecretByType.jwt.length > 0,
|
|
179
|
+
unanswered: secretSinksUnasked && weakSecretByType.jwt.length === 0,
|
|
170
180
|
evidence: weakSecretByType.jwt,
|
|
171
181
|
},
|
|
172
182
|
{
|
|
173
183
|
key: 'env.secretFallback.session',
|
|
174
184
|
present: weakSecretByType.session.length > 0,
|
|
185
|
+
unanswered: secretSinksUnasked && weakSecretByType.session.length === 0,
|
|
175
186
|
evidence: weakSecretByType.session,
|
|
176
187
|
},
|
|
177
188
|
{
|
|
178
189
|
key: 'env.secretFallback.app',
|
|
179
190
|
present: weakSecretByType.app.length > 0,
|
|
191
|
+
unanswered: secretSinksUnasked && weakSecretByType.app.length === 0,
|
|
180
192
|
evidence: weakSecretByType.app,
|
|
181
193
|
},
|
|
182
194
|
{
|
|
183
195
|
key: 'env.secretFallback.apiKey',
|
|
184
196
|
present: weakSecretByType.apiKey.length > 0,
|
|
197
|
+
unanswered: secretSinksUnasked && weakSecretByType.apiKey.length === 0,
|
|
185
198
|
evidence: weakSecretByType.apiKey,
|
|
186
199
|
},
|
|
187
200
|
{
|
|
188
201
|
key: 'env.secretFallback.unknown',
|
|
189
202
|
present: weakSecretByType.unknown.length > 0,
|
|
203
|
+
unanswered: secretSinksUnasked && weakSecretByType.unknown.length === 0,
|
|
190
204
|
evidence: weakSecretByType.unknown,
|
|
191
205
|
details: { weakSecretEvidence },
|
|
192
206
|
},
|
|
@@ -6,6 +6,7 @@ const readTextFileSafe_1 = require("../utils/readTextFileSafe");
|
|
|
6
6
|
const textSearch_1 = require("../utils/textSearch");
|
|
7
7
|
const absenceEvidence_1 = require("./absenceEvidence");
|
|
8
8
|
const valuesFromPackage_1 = require("./structural/valuesFromPackage");
|
|
9
|
+
const readingDepth_1 = require("./readingDepth");
|
|
9
10
|
const developmentOnly_1 = require("./developmentOnly");
|
|
10
11
|
/** Lines that decide which origins may call this server. */
|
|
11
12
|
const ORIGIN_HANDLING = [/Access-Control-Allow-Origin/i, /ALLOWED_ORIGINS/, /allowedOrigins/i];
|
|
@@ -371,6 +372,16 @@ async function detectSecurity(ctx) {
|
|
|
371
372
|
helmet,
|
|
372
373
|
rateLimit,
|
|
373
374
|
rateLimitNearAuth,
|
|
375
|
+
/**
|
|
376
|
+
* Whether coverage could be established at all.
|
|
377
|
+
*
|
|
378
|
+
* Whether a limiter reaches the login is read from the value the package
|
|
379
|
+
* produces and the paths it is mounted on — a structural question. Without the
|
|
380
|
+
* optional compiler `mountedPaths` is empty and three fixtures that do throttle
|
|
381
|
+
* their sign-in drop from `passed` to `partial`, told that nothing shows the
|
|
382
|
+
* coverage they have.
|
|
383
|
+
*/
|
|
384
|
+
rateLimitCoverageUnasked: (0, readingDepth_1.wentUnasked)(boundLimiterUses, source) && !rateLimitNearAuth,
|
|
374
385
|
corsLoose: corsLoose.length > 0,
|
|
375
386
|
corsStrict: corsStrict.length > 0,
|
|
376
387
|
webhookSignature: webhookSig.length > 0,
|
|
@@ -34,12 +34,37 @@ export interface LanguageReading {
|
|
|
34
34
|
export declare function readingDepths(sourceFiles: string[], unreadable: Array<{
|
|
35
35
|
language: string;
|
|
36
36
|
files: number;
|
|
37
|
-
}
|
|
37
|
+
}>, parserAvailable: boolean): LanguageReading[];
|
|
38
38
|
/**
|
|
39
39
|
* The sentence a reader needs, or nothing.
|
|
40
40
|
*
|
|
41
41
|
* Silent where everything was parsed: a report that congratulates itself on reading
|
|
42
42
|
* properly is noise. It speaks when some of the reading was shallower than the rest,
|
|
43
43
|
* which is the case that misleads.
|
|
44
|
+
*
|
|
45
|
+
* `parserAvailable` separates two shallow readings that look identical in the list and
|
|
46
|
+
* are not. Go is searched because nothing here will ever parse Go, and a reader can do
|
|
47
|
+
* nothing about it. JavaScript is searched only when the optional compiler is missing,
|
|
48
|
+
* and installing it changes the answer — on the fixture corpus, seven repositories of a
|
|
49
|
+
* hundred and thirty-three answer differently. Telling a reader to install a package is
|
|
50
|
+
* worth a clause; telling them their Go is read by keyword is worth a different one.
|
|
51
|
+
*/
|
|
52
|
+
export declare function describeReadingDepth(readings: LanguageReading[], parserAvailable: boolean): string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a parser would have had anything to say about this repository.
|
|
55
|
+
*
|
|
56
|
+
* A detector that loses its structural reader has lost nothing on a Go or Python
|
|
57
|
+
* project — no parser here was ever going to read those — so the missing compiler is
|
|
58
|
+
* only worth reporting where it would have changed the answer. Same list as the depths
|
|
59
|
+
* above, for the same reason: adding a language to the structural layer must move both
|
|
60
|
+
* claims in one commit.
|
|
61
|
+
*/
|
|
62
|
+
export declare function parserCouldHaveRead(sourceFiles: string[]): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* The question was asked and answered, or it was never asked.
|
|
65
|
+
*
|
|
66
|
+
* The structural readers already distinguish the two — `null` for "no parser", `[]` for
|
|
67
|
+
* "parsed, found nothing" — and every consumer flattened it with `?? []`. This puts the
|
|
68
|
+
* distinction back where a detector can act on it.
|
|
44
69
|
*/
|
|
45
|
-
export declare function
|
|
70
|
+
export declare function wentUnasked(readerResult: unknown | null, sourceFiles: string[]): boolean;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.readingDepths = readingDepths;
|
|
4
4
|
exports.describeReadingDepth = describeReadingDepth;
|
|
5
|
+
exports.parserCouldHaveRead = parserCouldHaveRead;
|
|
6
|
+
exports.wentUnasked = wentUnasked;
|
|
5
7
|
const catalogue_1 = require("./catalogue");
|
|
6
8
|
/**
|
|
7
9
|
* The extensions a parser reads today.
|
|
@@ -11,10 +13,25 @@ const catalogue_1 = require("./catalogue");
|
|
|
11
13
|
* within a week, which is the failure this file exists to stop somebody else making.
|
|
12
14
|
*/
|
|
13
15
|
const PARSED_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The catalogue labels those extensions carry, so the missing-compiler clause fires for
|
|
18
|
+
* the languages it can actually do something about and stays quiet on a Go repository.
|
|
19
|
+
*/
|
|
20
|
+
const PARSED_EXTENSIONS_LANGUAGES = new Set(catalogue_1.LANGUAGES.filter(({ extensions }) => ['a.ts', 'a.tsx', 'a.js', 'a.jsx', 'a.mjs', 'a.cjs'].some((name) => extensions.test(name))).map(({ label }) => label));
|
|
21
|
+
/**
|
|
22
|
+
* Languages whose files are read as text but never parsed.
|
|
23
|
+
*
|
|
24
|
+
* The extension says a parser *could* read this file; `parserAvailable` says one
|
|
25
|
+
* actually did. The optional TypeScript peer is absent on any machine that installed
|
|
26
|
+
* this package without it — `npx prodkit` against a repository is the ordinary case —
|
|
27
|
+
* and until this argument existed the report claimed `parsed` there just the same, on
|
|
28
|
+
* the strength of the file name.
|
|
29
|
+
*/
|
|
30
|
+
function depthFor(files, parserAvailable) {
|
|
16
31
|
if (files.length === 0)
|
|
17
32
|
return 'skipped';
|
|
33
|
+
if (!parserAvailable)
|
|
34
|
+
return 'searched';
|
|
18
35
|
return files.every((file) => PARSED_EXTENSIONS.test(file)) ? 'parsed' : 'searched';
|
|
19
36
|
}
|
|
20
37
|
/**
|
|
@@ -24,13 +41,13 @@ function depthFor(files) {
|
|
|
24
41
|
* directory was not read because it was not the project's, which is a different fact and
|
|
25
42
|
* one the reader does not need here.
|
|
26
43
|
*/
|
|
27
|
-
function readingDepths(sourceFiles, unreadable) {
|
|
44
|
+
function readingDepths(sourceFiles, unreadable, parserAvailable) {
|
|
28
45
|
const readings = [];
|
|
29
46
|
for (const { label, extensions } of catalogue_1.LANGUAGES) {
|
|
30
47
|
const files = sourceFiles.filter((file) => extensions.test(file));
|
|
31
48
|
if (files.length === 0)
|
|
32
49
|
continue;
|
|
33
|
-
readings.push({ language: label, files: files.length, depth: depthFor(files) });
|
|
50
|
+
readings.push({ language: label, files: files.length, depth: depthFor(files, parserAvailable) });
|
|
34
51
|
}
|
|
35
52
|
for (const entry of unreadable) {
|
|
36
53
|
readings.push({ language: entry.language, files: entry.files, depth: 'skipped' });
|
|
@@ -43,8 +60,15 @@ function readingDepths(sourceFiles, unreadable) {
|
|
|
43
60
|
* Silent where everything was parsed: a report that congratulates itself on reading
|
|
44
61
|
* properly is noise. It speaks when some of the reading was shallower than the rest,
|
|
45
62
|
* which is the case that misleads.
|
|
63
|
+
*
|
|
64
|
+
* `parserAvailable` separates two shallow readings that look identical in the list and
|
|
65
|
+
* are not. Go is searched because nothing here will ever parse Go, and a reader can do
|
|
66
|
+
* nothing about it. JavaScript is searched only when the optional compiler is missing,
|
|
67
|
+
* and installing it changes the answer — on the fixture corpus, seven repositories of a
|
|
68
|
+
* hundred and thirty-three answer differently. Telling a reader to install a package is
|
|
69
|
+
* worth a clause; telling them their Go is read by keyword is worth a different one.
|
|
46
70
|
*/
|
|
47
|
-
function describeReadingDepth(readings) {
|
|
71
|
+
function describeReadingDepth(readings, parserAvailable) {
|
|
48
72
|
const searched = readings.filter((entry) => entry.depth === 'searched');
|
|
49
73
|
const skipped = readings.filter((entry) => entry.depth === 'skipped');
|
|
50
74
|
if (searched.length === 0 && skipped.length === 0)
|
|
@@ -56,5 +80,36 @@ function describeReadingDepth(readings) {
|
|
|
56
80
|
if (skipped.length > 0) {
|
|
57
81
|
parts.push(`${skipped.map((entry) => `${entry.language} (${entry.files} files)`).join(', ')} not read at all`);
|
|
58
82
|
}
|
|
59
|
-
|
|
83
|
+
const missingParser = !parserAvailable && readings.some((entry) => PARSED_EXTENSIONS_LANGUAGES.has(entry.language));
|
|
84
|
+
return [
|
|
85
|
+
`How this repository was read: ${parts.join('; ')}.`,
|
|
86
|
+
'A keyword can appear in a comment, a test fixture or a variable name, so findings in those languages rest on weaker evidence than the ones this analyzer parsed.',
|
|
87
|
+
...(missingParser
|
|
88
|
+
? [
|
|
89
|
+
'The optional `typescript` peer dependency is not installed, so no JavaScript or TypeScript was parsed here either: every structural question — which literal reaches a signing call, whether a comparison guards a route or picks a label — went unasked. Install it alongside this package and re-run to get those answers.',
|
|
90
|
+
]
|
|
91
|
+
: []),
|
|
92
|
+
].join(' ');
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether a parser would have had anything to say about this repository.
|
|
96
|
+
*
|
|
97
|
+
* A detector that loses its structural reader has lost nothing on a Go or Python
|
|
98
|
+
* project — no parser here was ever going to read those — so the missing compiler is
|
|
99
|
+
* only worth reporting where it would have changed the answer. Same list as the depths
|
|
100
|
+
* above, for the same reason: adding a language to the structural layer must move both
|
|
101
|
+
* claims in one commit.
|
|
102
|
+
*/
|
|
103
|
+
function parserCouldHaveRead(sourceFiles) {
|
|
104
|
+
return sourceFiles.some((file) => PARSED_EXTENSIONS.test(file));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The question was asked and answered, or it was never asked.
|
|
108
|
+
*
|
|
109
|
+
* The structural readers already distinguish the two — `null` for "no parser", `[]` for
|
|
110
|
+
* "parsed, found nothing" — and every consumer flattened it with `?? []`. This puts the
|
|
111
|
+
* distinction back where a detector can act on it.
|
|
112
|
+
*/
|
|
113
|
+
function wentUnasked(readerResult, sourceFiles) {
|
|
114
|
+
return readerResult === null && parserCouldHaveRead(sourceFiles);
|
|
60
115
|
}
|
|
@@ -2,3 +2,26 @@ import type * as TypeScriptApi from 'typescript';
|
|
|
2
2
|
export declare function loadTypeScript(): Promise<typeof TypeScriptApi | null>;
|
|
3
3
|
/** For tests that need to observe both paths without reinstalling anything. */
|
|
4
4
|
export declare function resetTypeScriptCache(): void;
|
|
5
|
+
/**
|
|
6
|
+
* Pretend the compiler is not installed, for tests of what this analyzer says when it
|
|
7
|
+
* cannot read structure.
|
|
8
|
+
*
|
|
9
|
+
* That path is the ordinary one for anybody running `npx prodkit` against their own
|
|
10
|
+
* repository, and it went untested for as long as it existed because the test machine
|
|
11
|
+
* always has the compiler. Mocking the module specifier does not work here: the import
|
|
12
|
+
* is dynamic and resolves before the mock registry answers, so the first analysis in a
|
|
13
|
+
* file quietly reads the real compiler. Seeding the same cache the loader reads is the
|
|
14
|
+
* one seam that behaves identically to the real absence.
|
|
15
|
+
*/
|
|
16
|
+
export declare function pretendTypeScriptIsMissing(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the parser is there, for the report rather than for a reader.
|
|
19
|
+
*
|
|
20
|
+
* `readingDepths` printed `parsed` for every `.ts` and `.js` file on the strength of the
|
|
21
|
+
* extension alone, and said nothing when everything was parsed — so on a machine without
|
|
22
|
+
* the optional compiler the report was silent in exactly the case it needed to speak.
|
|
23
|
+
* Measured on the fixture corpus: seven of a hundred and thirty-three repositories
|
|
24
|
+
* answer differently with the compiler hidden, and one of them reports a hardcoded
|
|
25
|
+
* signing secret as `passed`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function typeScriptIsAvailable(): Promise<boolean>;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.loadTypeScript = loadTypeScript;
|
|
4
4
|
exports.resetTypeScriptCache = resetTypeScriptCache;
|
|
5
|
+
exports.pretendTypeScriptIsMissing = pretendTypeScriptIsMissing;
|
|
6
|
+
exports.typeScriptIsAvailable = typeScriptIsAvailable;
|
|
5
7
|
/**
|
|
6
8
|
* The TypeScript compiler, loaded when something needs to read structure rather than text.
|
|
7
9
|
*
|
|
@@ -39,3 +41,30 @@ async function loadTypeScript() {
|
|
|
39
41
|
function resetTypeScriptCache() {
|
|
40
42
|
cached = undefined;
|
|
41
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Pretend the compiler is not installed, for tests of what this analyzer says when it
|
|
46
|
+
* cannot read structure.
|
|
47
|
+
*
|
|
48
|
+
* That path is the ordinary one for anybody running `npx prodkit` against their own
|
|
49
|
+
* repository, and it went untested for as long as it existed because the test machine
|
|
50
|
+
* always has the compiler. Mocking the module specifier does not work here: the import
|
|
51
|
+
* is dynamic and resolves before the mock registry answers, so the first analysis in a
|
|
52
|
+
* file quietly reads the real compiler. Seeding the same cache the loader reads is the
|
|
53
|
+
* one seam that behaves identically to the real absence.
|
|
54
|
+
*/
|
|
55
|
+
function pretendTypeScriptIsMissing() {
|
|
56
|
+
cached = null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether the parser is there, for the report rather than for a reader.
|
|
60
|
+
*
|
|
61
|
+
* `readingDepths` printed `parsed` for every `.ts` and `.js` file on the strength of the
|
|
62
|
+
* extension alone, and said nothing when everything was parsed — so on a machine without
|
|
63
|
+
* the optional compiler the report was silent in exactly the case it needed to speak.
|
|
64
|
+
* Measured on the fixture corpus: seven of a hundred and thirty-three repositories
|
|
65
|
+
* answer differently with the compiler hidden, and one of them reports a hardcoded
|
|
66
|
+
* signing secret as `passed`.
|
|
67
|
+
*/
|
|
68
|
+
async function typeScriptIsAvailable() {
|
|
69
|
+
return (await loadTypeScript()) !== null;
|
|
70
|
+
}
|
|
@@ -4,3 +4,11 @@ export interface OwnershipCheck {
|
|
|
4
4
|
snippet: string;
|
|
5
5
|
}
|
|
6
6
|
export declare function readOwnershipChecks(root: string, files: string[]): Promise<OwnershipCheck[] | null>;
|
|
7
|
+
/**
|
|
8
|
+
* Whether this reader would have had routes to walk.
|
|
9
|
+
*
|
|
10
|
+
* Same reasoning as the secret sinks: a repository that never imports Express has no
|
|
11
|
+
* route handler whose shape this reader could have read, so its absence changes no
|
|
12
|
+
* answer there and the report should not call the question unasked.
|
|
13
|
+
*/
|
|
14
|
+
export declare function anyFileImportsExpress(root: string, files: string[]): Promise<boolean>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.readOwnershipChecks = readOwnershipChecks;
|
|
4
|
+
exports.anyFileImportsExpress = anyFileImportsExpress;
|
|
4
5
|
const readTextFileSafe_1 = require("../../utils/readTextFileSafe");
|
|
5
6
|
const loadTypeScript_1 = require("./loadTypeScript");
|
|
6
7
|
/**
|
|
@@ -153,3 +154,22 @@ async function readOwnershipChecks(root, files) {
|
|
|
153
154
|
}
|
|
154
155
|
return checks;
|
|
155
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Whether this reader would have had routes to walk.
|
|
159
|
+
*
|
|
160
|
+
* Same reasoning as the secret sinks: a repository that never imports Express has no
|
|
161
|
+
* route handler whose shape this reader could have read, so its absence changes no
|
|
162
|
+
* answer there and the report should not call the question unasked.
|
|
163
|
+
*/
|
|
164
|
+
async function anyFileImportsExpress(root, files) {
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
if (!READABLE.test(file))
|
|
167
|
+
continue;
|
|
168
|
+
const text = await (0, readTextFileSafe_1.readTextFileSafe)(root, file);
|
|
169
|
+
if (!text)
|
|
170
|
+
continue;
|
|
171
|
+
if (/(from\s+['"]express['"]|require\(\s*['"]express['"]\s*\))/.test(text))
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
@@ -6,3 +6,17 @@ export interface HardcodedSecretArgument {
|
|
|
6
6
|
sink: string;
|
|
7
7
|
}
|
|
8
8
|
export declare function readHardcodedSecretArguments(root: string, files: string[]): Promise<HardcodedSecretArgument[] | null>;
|
|
9
|
+
/**
|
|
10
|
+
* Whether this reader would have had anything to read.
|
|
11
|
+
*
|
|
12
|
+
* `unanswered` has to mean "the answer depends on the reader that did not run", not
|
|
13
|
+
* "a reader did not run". The first version marked every JavaScript repository in the
|
|
14
|
+
* corpus — 79 of 133 — as not assessed for weak secrets, including the ones where a
|
|
15
|
+
* plain `const JWT_SECRET = 'changeme'` is found by name and the sink-following reader
|
|
16
|
+
* would have added nothing.
|
|
17
|
+
*
|
|
18
|
+
* The reader's own first act is this test: a file that never mentions a signing or
|
|
19
|
+
* ciphering package has no sink to follow. Where no file does, the missing compiler
|
|
20
|
+
* costs nothing and the text answer stands on its own.
|
|
21
|
+
*/
|
|
22
|
+
export declare function anyFileReachesASecretSink(root: string, files: string[]): Promise<boolean>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.readHardcodedSecretArguments = readHardcodedSecretArguments;
|
|
4
|
+
exports.anyFileReachesASecretSink = anyFileReachesASecretSink;
|
|
4
5
|
const readTextFileSafe_1 = require("../../utils/readTextFileSafe");
|
|
5
6
|
const loadTypeScript_1 = require("./loadTypeScript");
|
|
6
7
|
/**
|
|
@@ -169,3 +170,28 @@ async function readHardcodedSecretArguments(root, files) {
|
|
|
169
170
|
}
|
|
170
171
|
return found;
|
|
171
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Whether this reader would have had anything to read.
|
|
175
|
+
*
|
|
176
|
+
* `unanswered` has to mean "the answer depends on the reader that did not run", not
|
|
177
|
+
* "a reader did not run". The first version marked every JavaScript repository in the
|
|
178
|
+
* corpus — 79 of 133 — as not assessed for weak secrets, including the ones where a
|
|
179
|
+
* plain `const JWT_SECRET = 'changeme'` is found by name and the sink-following reader
|
|
180
|
+
* would have added nothing.
|
|
181
|
+
*
|
|
182
|
+
* The reader's own first act is this test: a file that never mentions a signing or
|
|
183
|
+
* ciphering package has no sink to follow. Where no file does, the missing compiler
|
|
184
|
+
* costs nothing and the text answer stands on its own.
|
|
185
|
+
*/
|
|
186
|
+
async function anyFileReachesASecretSink(root, files) {
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
if (!READABLE.test(file))
|
|
189
|
+
continue;
|
|
190
|
+
const text = await (0, readTextFileSafe_1.readTextFileSafe)(root, file);
|
|
191
|
+
if (!text)
|
|
192
|
+
continue;
|
|
193
|
+
if (SECRET_SINKS.some((sink) => text.includes(sink.package.replace('node:', ''))))
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
package/dist/analyzer/types.d.ts
CHANGED
|
@@ -74,6 +74,25 @@ export interface DetectorResult {
|
|
|
74
74
|
/** Stable feature key, e.g. 'security.helmet' */
|
|
75
75
|
key: string;
|
|
76
76
|
present: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* The question behind this signal could not be asked.
|
|
79
|
+
*
|
|
80
|
+
* `present: false` has carried two meanings that a reader would never confuse: "I
|
|
81
|
+
* looked and it is not there", and "I could not look". The second happens whenever a
|
|
82
|
+
* signal rests on the optional TypeScript compiler and the compiler is absent — the
|
|
83
|
+
* ordinary case for `npx prodkit` against somebody else's repository.
|
|
84
|
+
*
|
|
85
|
+
* Measured by hiding `node_modules/typescript` and re-running the fixture corpus: 7
|
|
86
|
+
* of 133 repositories answer differently, and `segreto-in-italiano` reports a
|
|
87
|
+
* hardcoded signing secret as `passed` rather than `missing`. A verdict of "fine" is
|
|
88
|
+
* the one direction blindness must never produce.
|
|
89
|
+
*
|
|
90
|
+
* Set it only where the missing reader can change the answer, and only alongside
|
|
91
|
+
* `present: false`: a signal that found the thing found it, whatever else went
|
|
92
|
+
* unasked. Consumers turn it into `unknown`, which already means "not assessed"
|
|
93
|
+
* everywhere downstream — the plan skips it and the score leaves it out.
|
|
94
|
+
*/
|
|
95
|
+
unanswered?: boolean;
|
|
77
96
|
/** When present is true: was the implementation complete? */
|
|
78
97
|
complete?: boolean;
|
|
79
98
|
evidence: DetectorEvidence[];
|
|
@@ -117,4 +136,12 @@ export interface ProjectAnalysis {
|
|
|
117
136
|
workspaceStacks: WorkspaceStack[];
|
|
118
137
|
files: ProjectFiles;
|
|
119
138
|
detectors: Record<string, DetectorResult>;
|
|
139
|
+
/**
|
|
140
|
+
* Whether the optional TypeScript compiler was there while the detectors ran.
|
|
141
|
+
*
|
|
142
|
+
* Carried on the analysis rather than asked for at report time, because it is a fact
|
|
143
|
+
* about this reading and not about the machine printing it: the report's claim of how
|
|
144
|
+
* deeply it read has to match what the detectors were actually able to do.
|
|
145
|
+
*/
|
|
146
|
+
parsedStructure: boolean;
|
|
120
147
|
}
|
|
@@ -373,7 +373,8 @@ function buildReport(analysis, options) {
|
|
|
373
373
|
* its severity. A keyword match in Go and a parsed guard in TypeScript were being
|
|
374
374
|
* presented with the same confidence.
|
|
375
375
|
*/
|
|
376
|
-
readingDepth: (0, readingDepth_1.readingDepths)(analysis.files.source, analysis.files.unreadable),
|
|
376
|
+
readingDepth: (0, readingDepth_1.readingDepths)(analysis.files.source, analysis.files.unreadable, analysis.parsedStructure),
|
|
377
|
+
parsedStructure: analysis.parsedStructure,
|
|
377
378
|
detectors: detectorDiagnostics(analysis),
|
|
378
379
|
selectedProfile: requestedProfile,
|
|
379
380
|
inferredProfile: productProfile?.inferredProfile,
|
|
@@ -274,8 +274,8 @@ function renderMarkdown(report) {
|
|
|
274
274
|
* confidence, and nothing in the report distinguished them. Silent when everything
|
|
275
275
|
* was parsed: a report congratulating itself on reading properly is noise.
|
|
276
276
|
*/
|
|
277
|
-
...((0, readingDepth_1.describeReadingDepth)(report.diagnostics.readingDepth)
|
|
278
|
-
? [`- ${(0, readingDepth_1.describeReadingDepth)(report.diagnostics.readingDepth)}`]
|
|
277
|
+
...((0, readingDepth_1.describeReadingDepth)(report.diagnostics.readingDepth, report.diagnostics.parsedStructure)
|
|
278
|
+
? [`- ${(0, readingDepth_1.describeReadingDepth)(report.diagnostics.readingDepth, report.diagnostics.parsedStructure)}`]
|
|
279
279
|
: []),
|
|
280
280
|
`- Expectation mode: ${report.diagnostics.expectationMode}`,
|
|
281
281
|
`- ProdKit version: ${report.diagnostics.prodkitVersion}`,
|
package/dist/report/types.d.ts
CHANGED
|
@@ -74,6 +74,14 @@ export interface ReportDiagnostics {
|
|
|
74
74
|
* fact, and the reader is entitled to know which one they have.
|
|
75
75
|
*/
|
|
76
76
|
readingDepth: LanguageReading[];
|
|
77
|
+
/**
|
|
78
|
+
* Whether the optional TypeScript compiler was loaded for this reading.
|
|
79
|
+
*
|
|
80
|
+
* Two readings with the same `readingDepth` list are not the same reading: JavaScript
|
|
81
|
+
* shows as `searched` only when the compiler was missing, and that is the one a reader
|
|
82
|
+
* can fix.
|
|
83
|
+
*/
|
|
84
|
+
parsedStructure: boolean;
|
|
77
85
|
detectors: Array<{
|
|
78
86
|
id: string;
|
|
79
87
|
status: 'completed' | 'skipped';
|
package/dist/rules/rules.js
CHANGED
|
@@ -252,7 +252,20 @@ exports.rules = [
|
|
|
252
252
|
app?.present ? 'app' : null,
|
|
253
253
|
apiKey?.present ? 'apiKey' : null,
|
|
254
254
|
].filter(Boolean).join(', ');
|
|
255
|
-
|
|
255
|
+
/**
|
|
256
|
+
* A pass this reading did not earn.
|
|
257
|
+
*
|
|
258
|
+
* `jwt.sign(payload, 'cambiami')` is found by following the literal into the
|
|
259
|
+
* signing call, which needs the optional TypeScript compiler. Without it the
|
|
260
|
+
* search comes back empty and this rule said "No weak fallback secret patterns
|
|
261
|
+
* detected" — the analyzer's most confident sentence, produced by not looking.
|
|
262
|
+
* Measured on `segreto-in-italiano`: `missing` becomes `passed`, 47 becomes 62.
|
|
263
|
+
*
|
|
264
|
+
* Finding one is still finding one, so `weak` wins: blindness can only ever turn
|
|
265
|
+
* a clean verdict into no verdict.
|
|
266
|
+
*/
|
|
267
|
+
const unanswered = [jwt, session, app, apiKey, unknown].some((d) => d?.unanswered);
|
|
268
|
+
const status = weak ? 'missing' : unanswered ? 'unknown' : 'passed';
|
|
256
269
|
return mkFinding({
|
|
257
270
|
id: 'security.weak-secret',
|
|
258
271
|
title: 'Weak/fallback secret values',
|
|
@@ -261,7 +274,9 @@ exports.rules = [
|
|
|
261
274
|
severity: weak ? 'critical' : 'info',
|
|
262
275
|
description: weak
|
|
263
276
|
? `Hardcoded fallback secrets detected (${weakTypes || 'unknown'} key context).`
|
|
264
|
-
:
|
|
277
|
+
: unanswered
|
|
278
|
+
? 'Not assessed: finding a literal secret where the name gives nothing away means following it into the call that signs with it, and the optional `typescript` peer dependency is not installed. Install it and re-run.'
|
|
279
|
+
: 'No weak fallback secret patterns detected.',
|
|
265
280
|
recommendation: 'Require strong secrets through environment variables with strict startup validation.',
|
|
266
281
|
/**
|
|
267
282
|
* A check that passes because it found nothing has to say what it looked for.
|
|
@@ -367,9 +382,18 @@ exports.rules = [
|
|
|
367
382
|
* surface", and now it only says that when something shows it.
|
|
368
383
|
*/
|
|
369
384
|
const nearAuth = Boolean(sec?.details?.rateLimitNearAuth);
|
|
385
|
+
/**
|
|
386
|
+
* `partial` is a verdict, and blindness has not earned one.
|
|
387
|
+
*
|
|
388
|
+
* Coverage is read from the value the limiter package produces and the prefixes
|
|
389
|
+
* it is mounted on, so without the optional compiler the answer is empty rather
|
|
390
|
+
* than negative. Three fixtures that do throttle their login were told nothing
|
|
391
|
+
* showed it.
|
|
392
|
+
*/
|
|
393
|
+
const coverageUnasked = Boolean(sec?.details?.rateLimitCoverageUnasked);
|
|
370
394
|
const status = !isExpress || !hasAuth
|
|
371
395
|
? 'unknown'
|
|
372
|
-
: nearAuth ? 'passed' : hasRate ? 'partial' : 'missing';
|
|
396
|
+
: nearAuth ? 'passed' : coverageUnasked && hasRate ? 'unknown' : hasRate ? 'partial' : 'missing';
|
|
373
397
|
return mkFinding({
|
|
374
398
|
id: 'security.rate-limit-auth',
|
|
375
399
|
title: 'Authentication rate limiting',
|
|
@@ -388,9 +412,11 @@ exports.rules = [
|
|
|
388
412
|
? 'Rate limiting is in place somewhere, but nothing here shows it covering sign-in.'
|
|
389
413
|
: status === 'missing'
|
|
390
414
|
? 'No auth-focused rate limiting detected.'
|
|
391
|
-
:
|
|
392
|
-
? '
|
|
393
|
-
:
|
|
415
|
+
: coverageUnasked && hasRate
|
|
416
|
+
? 'Not assessed: this project throttles something, and whether it reaches the login is read from where the limiter is mounted — which needs the optional `typescript` peer dependency. Install it and re-run.'
|
|
417
|
+
: !hasAuth
|
|
418
|
+
? 'Nothing here authenticates anybody, so there is no login surface to throttle.'
|
|
419
|
+
: 'This check reads Express middleware, and this project does not use it — any throttling it has is somewhere this cannot see.',
|
|
394
420
|
recommendation: 'Apply express-rate-limit (or equivalent) to login/register/password reset endpoints.',
|
|
395
421
|
/**
|
|
396
422
|
* The claim is about rate limiting, so the evidence is about rate limiting.
|
|
@@ -586,13 +612,22 @@ exports.rules = [
|
|
|
586
612
|
// A question about who may act on which record needs a system that has records
|
|
587
613
|
// and callers. The auth detector alone was not enough of a gate: it answers from
|
|
588
614
|
// strings, and a package that searches for `requireAuth` contains it.
|
|
615
|
+
/**
|
|
616
|
+
* Per-record authorization is read from the shape of a route handler, so the
|
|
617
|
+
* optional compiler is the whole reading. Without it `proprieta-in-italiano`
|
|
618
|
+
* turns from `passed` into a false `missing`, and the report recommends adding
|
|
619
|
+
* a check the code already makes.
|
|
620
|
+
*/
|
|
621
|
+
const ownershipUnasked = Boolean(resourceLevel?.unanswered) && !hasPermissions && !hasRoles;
|
|
589
622
|
const status = !hasAuth || !hasUserFacingSurface(analysis)
|
|
590
623
|
? 'unknown'
|
|
591
624
|
: hasPermissions || hasResourceLevel
|
|
592
625
|
? 'passed'
|
|
593
626
|
: hasRoles
|
|
594
627
|
? 'partial'
|
|
595
|
-
:
|
|
628
|
+
: ownershipUnasked
|
|
629
|
+
? 'unknown'
|
|
630
|
+
: 'missing';
|
|
596
631
|
return mkFinding({
|
|
597
632
|
id: 'authz.resource-level',
|
|
598
633
|
title: 'Authorization depth',
|
|
@@ -607,7 +642,9 @@ exports.rules = [
|
|
|
607
642
|
? 'No resource-level authorization signals detected.'
|
|
608
643
|
: !hasAuth
|
|
609
644
|
? 'Nothing here authenticates anybody, so there are no callers to authorize.'
|
|
610
|
-
:
|
|
645
|
+
: ownershipUnasked
|
|
646
|
+
? 'Not assessed: a check that compares the record to the caller is recognised by the shape of the route handler, and the optional `typescript` peer dependency is not installed. Install it and re-run.'
|
|
647
|
+
: 'Nothing in this repository serves requests, so there are no records to guard.',
|
|
611
648
|
recommendation: 'Add policy/resource-level checks beyond coarse role gates.',
|
|
612
649
|
evidence: [...(roles?.evidence ?? []), ...(permissions?.evidence ?? []), ...(resourceLevel?.evidence ?? [])],
|
|
613
650
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.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": {
|