@produtype/core 0.73.0 → 0.75.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 +19 -0
- package/dist/analyzer/detectAuth.js +11 -0
- package/dist/analyzer/detectEnv.js +14 -0
- package/dist/analyzer/detectErrorReporting.js +68 -2
- package/dist/analyzer/detectMobile.js +29 -2
- package/dist/analyzer/detectSecurity.js +11 -0
- package/dist/analyzer/readingDepth.d.ts +18 -0
- package/dist/analyzer/readingDepth.js +24 -0
- package/dist/analyzer/structural/loadTypeScript.d.ts +12 -0
- package/dist/analyzer/structural/loadTypeScript.js +15 -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 +19 -0
- package/dist/rules/rules.js +45 -8
- package/package.json +1 -1
|
@@ -651,6 +651,25 @@ async function analyzeProject(projectPath) {
|
|
|
651
651
|
swiftDeps.push(match[1].toLowerCase());
|
|
652
652
|
}
|
|
653
653
|
}
|
|
654
|
+
/**
|
|
655
|
+
* Package.resolved, which is where a Swift project's dependencies are actually
|
|
656
|
+
* legible.
|
|
657
|
+
*
|
|
658
|
+
* `Package.swift` is read above, and on a real application it usually declares the
|
|
659
|
+
* modules of that package rather than the whole tree: WordPress-iOS resolves 40-odd
|
|
660
|
+
* packages and `sentry-cocoa` — its crash reporter — appears only here. The lockfile
|
|
661
|
+
* is data rather than source, it names every transitive dependency, and it is
|
|
662
|
+
* committed by convention, which makes it the better of the two to read.
|
|
663
|
+
*
|
|
664
|
+
* `identity` rather than `location`, because that is the name SPM itself uses and
|
|
665
|
+
* the one that survives a repository moving host.
|
|
666
|
+
*/
|
|
667
|
+
for (const file of ownManifests.filter((f) => /(^|\/)Package\.resolved$/.test(f))) {
|
|
668
|
+
const raw = (await (0, readTextFileSafe_1.readTextFileSafe)(root, file)) ?? '';
|
|
669
|
+
for (const match of raw.matchAll(/"identity"\s*:\s*"([^"]+)"/g)) {
|
|
670
|
+
swiftDeps.push(match[1].toLowerCase());
|
|
671
|
+
}
|
|
672
|
+
}
|
|
654
673
|
/**
|
|
655
674
|
* .csproj, which is XML rather than JSON or one-entry-per-line.
|
|
656
675
|
*
|
|
@@ -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
|
},
|
|
@@ -29,9 +29,74 @@ const REPORTER_DEPS = [
|
|
|
29
29
|
'trackjs',
|
|
30
30
|
];
|
|
31
31
|
const REPORTER_PY_DEPS = ['sentry-sdk', 'rollbar', 'bugsnag'];
|
|
32
|
+
/**
|
|
33
|
+
* The same capability, in the ecosystems where the browser does not exist.
|
|
34
|
+
*
|
|
35
|
+
* This detector was written for code running in a tab and then made `required` for the
|
|
36
|
+
* mobile-app profile, where none of what it reads can occur: an iOS application has no
|
|
37
|
+
* `window.onerror` and no npm dependency. Three real repositories measured — DuckDuckGo
|
|
38
|
+
* iOS, thunderbird-android, WordPress-iOS — and all three were told at `high` that a
|
|
39
|
+
* crash on someone's phone reaches nobody. A check whose answer cannot vary is not a
|
|
40
|
+
* measure.
|
|
41
|
+
*
|
|
42
|
+
* Coordinates rather than words, matched as substrings by the helpers: `io.sentry` also
|
|
43
|
+
* covers `io.sentry:sentry-android`, and `sentry-cocoa` is how SPM names the package
|
|
44
|
+
* whatever the product is called.
|
|
45
|
+
*/
|
|
46
|
+
const REPORTER_GRADLE_DEPS = [
|
|
47
|
+
'io.sentry',
|
|
48
|
+
'com.google.firebase:firebase-crashlytics',
|
|
49
|
+
'com.bugsnag',
|
|
50
|
+
'com.microsoft.appcenter:appcenter-crashes',
|
|
51
|
+
'ch.acra:acra',
|
|
52
|
+
'io.embrace',
|
|
53
|
+
'com.datadoghq:dd-sdk-android',
|
|
54
|
+
'com.instabug',
|
|
55
|
+
];
|
|
56
|
+
const REPORTER_SWIFT_DEPS = [
|
|
57
|
+
'sentry-cocoa',
|
|
58
|
+
'sentry-cocoa-spm',
|
|
59
|
+
'firebase-ios-sdk',
|
|
60
|
+
'firebasecrashlytics',
|
|
61
|
+
'bugsnag-cocoa',
|
|
62
|
+
'appcenter-sdk-apple',
|
|
63
|
+
'instabug',
|
|
64
|
+
'embrace-apple-sdk',
|
|
65
|
+
];
|
|
66
|
+
const REPORTER_DART_DEPS = ['sentry_flutter', 'firebase_crashlytics', 'bugsnag_flutter'];
|
|
67
|
+
/**
|
|
68
|
+
* Handlers the platform defines, which no author names.
|
|
69
|
+
*
|
|
70
|
+
* `NSSetUncaughtExceptionHandler` is Foundation, `Thread.setDefaultUncaughtExceptionHandler`
|
|
71
|
+
* is the JVM, `FlutterError.onError` is Flutter. Each is the one place its platform
|
|
72
|
+
* lets a program learn that it is about to die, so a call to it is the capability
|
|
73
|
+
* itself rather than a word that tends to accompany it — thunderbird-android installs
|
|
74
|
+
* one and was reported as having nothing.
|
|
75
|
+
*
|
|
76
|
+
* `SentrySDK.start` and `FirebaseCrashlytics` are here as well as in the dependency
|
|
77
|
+
* lists: a repository can use a reporter whose manifest is in another repository, and
|
|
78
|
+
* the call is still a call.
|
|
79
|
+
*/
|
|
80
|
+
const PLATFORM_CRASH_HANDLERS = [
|
|
81
|
+
/NSSetUncaughtExceptionHandler\s*\(/,
|
|
82
|
+
// `(` or `{`: Kotlin writes the handler as a trailing lambda, which is how both real
|
|
83
|
+
// Android applications measured install theirs.
|
|
84
|
+
/setDefaultUncaughtExceptionHandler\s*[({]/,
|
|
85
|
+
/FlutterError\s*\.\s*onError\s*=/,
|
|
86
|
+
/PlatformDispatcher\s*\.\s*instance\s*\.\s*onError\s*=/,
|
|
87
|
+
/SentrySDK\s*\.\s*start\s*\(/,
|
|
88
|
+
/Sentry\s*\.\s*init\s*\(/,
|
|
89
|
+
/FirebaseCrashlytics|Crashlytics\s*\.\s*crashlytics\s*\(/,
|
|
90
|
+
];
|
|
32
91
|
async function detectErrorReporting(ctx) {
|
|
33
92
|
const evidence = [];
|
|
34
|
-
const deps = [
|
|
93
|
+
const deps = [
|
|
94
|
+
...(0, detectContext_1.hasAnyDep)(ctx, REPORTER_DEPS),
|
|
95
|
+
...(0, detectContext_1.hasAnyPyDep)(ctx, REPORTER_PY_DEPS),
|
|
96
|
+
...(0, detectContext_1.hasAnyGradleDep)(ctx, REPORTER_GRADLE_DEPS),
|
|
97
|
+
...(0, detectContext_1.hasAnySwiftDep)(ctx, REPORTER_SWIFT_DEPS),
|
|
98
|
+
...(0, detectContext_1.hasAnyDartDep)(ctx, REPORTER_DART_DEPS),
|
|
99
|
+
];
|
|
35
100
|
for (const dep of deps)
|
|
36
101
|
evidence.push({ type: 'dependency', value: dep });
|
|
37
102
|
/**
|
|
@@ -44,6 +109,7 @@ async function detectErrorReporting(ctx) {
|
|
|
44
109
|
/window\.addEventListener\s*\(\s*['"]unhandledrejection['"]/,
|
|
45
110
|
/window\.onerror\s*=/,
|
|
46
111
|
/componentDidCatch\s*\([^)]*\)\s*\{[^}]*(fetch|report|log)/s,
|
|
112
|
+
...PLATFORM_CRASH_HANDLERS,
|
|
47
113
|
], 10);
|
|
48
114
|
for (const hit of handlers) {
|
|
49
115
|
evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line });
|
|
@@ -54,7 +120,7 @@ async function detectErrorReporting(ctx) {
|
|
|
54
120
|
// A reporting service is wired up once and covers everything; a hand-rolled handler
|
|
55
121
|
// usually covers what its author remembered.
|
|
56
122
|
complete: deps.length > 0,
|
|
57
|
-
evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'somewhere a crash in the browser is sent', ['@sentry/browser', '@sentry/react', '@bugsnag/js', 'rollbar', 'logrocket', '@datadog/browser-rum', 'sentry-sdk', 'window.onerror', 'addEventListener("error")', 'addEventListener("unhandledrejection")']),
|
|
123
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'somewhere a crash in the browser is sent', ['@sentry/browser', '@sentry/react', '@bugsnag/js', 'rollbar', 'logrocket', '@datadog/browser-rum', 'sentry-sdk', 'window.onerror', 'addEventListener("error")', 'addEventListener("unhandledrejection")', 'NSSetUncaughtExceptionHandler', 'Thread.setDefaultUncaughtExceptionHandler', 'FlutterError.onError', 'sentry-cocoa', 'io.sentry', 'firebase-crashlytics']),
|
|
58
124
|
details: { services: deps, handlers: handlers.length },
|
|
59
125
|
};
|
|
60
126
|
}
|
|
@@ -82,6 +82,26 @@ const OFFLINE_SWIFT = [
|
|
|
82
82
|
'realm/realm-cocoa',
|
|
83
83
|
'realmswift',
|
|
84
84
|
];
|
|
85
|
+
/**
|
|
86
|
+
* Local storage the platform itself provides, which no dependency list will hold.
|
|
87
|
+
*
|
|
88
|
+
* Every entry in the lists above is a third-party database, and the two most widely
|
|
89
|
+
* used stores on these platforms ship with the operating system: Core Data on Apple's,
|
|
90
|
+
* `SQLiteDatabase` on Android's. Measured on two real applications whose whole purpose
|
|
91
|
+
* is working without a network — WordPress-iOS keeps its posts in Core Data,
|
|
92
|
+
* thunderbird-android keeps its mail in SQLite — and both were told at `high` that they
|
|
93
|
+
* store nothing locally.
|
|
94
|
+
*
|
|
95
|
+
* These are types the platform defines, not names an author picked: `NSManagedObjectContext`
|
|
96
|
+
* belongs to Core Data and appears nowhere else, and `getWritableDatabase()` is the one
|
|
97
|
+
* way an Android application opens its own database.
|
|
98
|
+
*/
|
|
99
|
+
const PLATFORM_LOCAL_STORES = [
|
|
100
|
+
/NSPersistentContainer|NSManagedObjectContext|NSPersistentStoreCoordinator/,
|
|
101
|
+
/ModelContainer\s*\(|@Model\b/,
|
|
102
|
+
/SQLiteOpenHelper|getWritableDatabase\s*\(|getReadableDatabase\s*\(/,
|
|
103
|
+
/android\.database\.sqlite\.SQLiteDatabase/,
|
|
104
|
+
];
|
|
85
105
|
/** Dependencies that put a secret somewhere the operating system protects. */
|
|
86
106
|
const SECURE_STORAGE_DEPS = [
|
|
87
107
|
'flutter_secure_storage',
|
|
@@ -315,8 +335,15 @@ async function detectMobile(ctx) {
|
|
|
315
335
|
* connection is gone is a different thing from one that shows a spinner forever.
|
|
316
336
|
*/
|
|
317
337
|
const connectivityChecks = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/Connectivity\(\)/, /connectivity_plus/, /NetInfo\./, /navigator\.onLine/, /NWPathMonitor/, /isReachable/], 3);
|
|
338
|
+
const platformStores = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, PLATFORM_LOCAL_STORES, 3);
|
|
318
339
|
const offlineEvidence = [
|
|
319
340
|
...offlineDeps.map((dep) => ({ type: 'dependency', value: dep })),
|
|
341
|
+
...platformStores.map((match) => ({
|
|
342
|
+
type: 'snippet',
|
|
343
|
+
value: match.snippet,
|
|
344
|
+
file: match.file,
|
|
345
|
+
line: match.line,
|
|
346
|
+
})),
|
|
320
347
|
...connectivityChecks.map((match) => ({
|
|
321
348
|
type: 'snippet',
|
|
322
349
|
value: match.snippet,
|
|
@@ -361,8 +388,8 @@ async function detectMobile(ctx) {
|
|
|
361
388
|
},
|
|
362
389
|
{
|
|
363
390
|
key: 'mobile.offline',
|
|
364
|
-
present: offlineDeps.length > 0,
|
|
365
|
-
evidence: (0, absenceEvidence_1.evidenceOrSearch)(offlineEvidence, 'a local database the app can read with no network', ['sqflite', 'drift', 'hive', 'isar', 'objectbox', 'realm', 'androidx.room', 'sqldelight', 'grdb.swift', 'sqlite.swift']),
|
|
391
|
+
present: offlineDeps.length > 0 || platformStores.length > 0,
|
|
392
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(offlineEvidence, 'a local database the app can read with no network', ['sqflite', 'drift', 'hive', 'isar', 'objectbox', 'realm', 'androidx.room', 'sqldelight', 'grdb.swift', 'sqlite.swift', 'NSManagedObjectContext', 'SQLiteOpenHelper', 'getWritableDatabase(']),
|
|
366
393
|
},
|
|
367
394
|
{
|
|
368
395
|
key: 'mobile.forcedUpdate',
|
|
@@ -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,
|
|
@@ -50,3 +50,21 @@ export declare function readingDepths(sourceFiles: string[], unreadable: Array<{
|
|
|
50
50
|
* worth a clause; telling them their Go is read by keyword is worth a different one.
|
|
51
51
|
*/
|
|
52
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.
|
|
69
|
+
*/
|
|
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.
|
|
@@ -89,3 +91,25 @@ function describeReadingDepth(readings, parserAvailable) {
|
|
|
89
91
|
: []),
|
|
90
92
|
].join(' ');
|
|
91
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);
|
|
115
|
+
}
|
|
@@ -2,6 +2,18 @@ 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;
|
|
5
17
|
/**
|
|
6
18
|
* Whether the parser is there, for the report rather than for a reader.
|
|
7
19
|
*
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.loadTypeScript = loadTypeScript;
|
|
4
4
|
exports.resetTypeScriptCache = resetTypeScriptCache;
|
|
5
|
+
exports.pretendTypeScriptIsMissing = pretendTypeScriptIsMissing;
|
|
5
6
|
exports.typeScriptIsAvailable = typeScriptIsAvailable;
|
|
6
7
|
/**
|
|
7
8
|
* The TypeScript compiler, loaded when something needs to read structure rather than text.
|
|
@@ -40,6 +41,20 @@ async function loadTypeScript() {
|
|
|
40
41
|
function resetTypeScriptCache() {
|
|
41
42
|
cached = undefined;
|
|
42
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
|
+
}
|
|
43
58
|
/**
|
|
44
59
|
* Whether the parser is there, for the report rather than for a reader.
|
|
45
60
|
*
|
|
@@ -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[];
|
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.75.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": {
|