@produtype/core 0.42.0 → 0.44.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/absenceEvidence.d.ts +13 -0
- package/dist/analyzer/absenceEvidence.js +16 -0
- package/dist/analyzer/detectAuth.js +8 -8
- package/dist/analyzer/detectBilling.js +7 -6
- package/dist/analyzer/detectDeployment.js +2 -1
- package/dist/analyzer/detectDocker.js +2 -1
- package/dist/analyzer/detectErrorReporting.js +2 -1
- package/dist/analyzer/detectGame.js +3 -2
- package/dist/analyzer/detectJobs.js +2 -1
- package/dist/analyzer/detectMobile.js +15 -6
- package/dist/analyzer/detectNotifications.js +3 -2
- package/dist/analyzer/detectPackaging.js +6 -5
- package/dist/rules/rules.js +22 -3
- package/package.json +1 -1
|
@@ -13,3 +13,16 @@ import type { DetectorEvidence } from './types';
|
|
|
13
13
|
* would not. That is the difference between a claim and an assertion.
|
|
14
14
|
*/
|
|
15
15
|
export declare function searchedFor(what: string, terms: string[], claim?: string): DetectorEvidence[];
|
|
16
|
+
/**
|
|
17
|
+
* The evidence, or the search that came up empty — never neither.
|
|
18
|
+
*
|
|
19
|
+
* Measured across eighty-three repositories: 768 of 2101 findings carried "no direct
|
|
20
|
+
* evidence captured", and 180 of those were `high`. A reader is told at high severity
|
|
21
|
+
* that their product has no error reporting, no roles, no tenant isolation, and the
|
|
22
|
+
* only thing under it is a sentence that reads like an admission of not having looked.
|
|
23
|
+
*
|
|
24
|
+
* Every detector already knows what it searched for. This is the one line that makes
|
|
25
|
+
* it say so, so that a reader whose roles are called `capabilities` can see why they
|
|
26
|
+
* were missed and tell us we are wrong.
|
|
27
|
+
*/
|
|
28
|
+
export declare function evidenceOrSearch(evidence: DetectorEvidence[], what: string, terms: string[], claim?: string): DetectorEvidence[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.searchedFor = searchedFor;
|
|
4
|
+
exports.evidenceOrSearch = evidenceOrSearch;
|
|
4
5
|
/**
|
|
5
6
|
* What was looked for, when nothing was found.
|
|
6
7
|
*
|
|
@@ -23,3 +24,18 @@ function searchedFor(what, terms, claim) {
|
|
|
23
24
|
...(claim ? { claim } : {}),
|
|
24
25
|
}];
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The evidence, or the search that came up empty — never neither.
|
|
29
|
+
*
|
|
30
|
+
* Measured across eighty-three repositories: 768 of 2101 findings carried "no direct
|
|
31
|
+
* evidence captured", and 180 of those were `high`. A reader is told at high severity
|
|
32
|
+
* that their product has no error reporting, no roles, no tenant isolation, and the
|
|
33
|
+
* only thing under it is a sentence that reads like an admission of not having looked.
|
|
34
|
+
*
|
|
35
|
+
* Every detector already knows what it searched for. This is the one line that makes
|
|
36
|
+
* it say so, so that a reader whose roles are called `capabilities` can see why they
|
|
37
|
+
* were missed and tell us we are wrong.
|
|
38
|
+
*/
|
|
39
|
+
function evidenceOrSearch(evidence, what, terms, claim) {
|
|
40
|
+
return evidence.length > 0 ? evidence : searchedFor(what, terms, claim);
|
|
41
|
+
}
|
|
@@ -312,7 +312,7 @@ async function detectAuth(ctx) {
|
|
|
312
312
|
key: 'auth.core',
|
|
313
313
|
present: hasAuth,
|
|
314
314
|
complete: hasAuth && hasAuthz,
|
|
315
|
-
evidence: [...depEvidence(authDeps), ...snippetEvidence(routeSignals)],
|
|
315
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)([...depEvidence(authDeps), ...snippetEvidence(routeSignals)], 'a way for somebody to sign in', ['next-auth', 'passport', 'lucia', '@clerk/', '@supabase/auth', 'django.contrib.auth', 'devise', 'jsonwebtoken', 'a /login or /signin route', 'signIn(', 'authenticate(']),
|
|
316
316
|
details: {
|
|
317
317
|
hasAuth,
|
|
318
318
|
hasAuthz,
|
|
@@ -334,12 +334,12 @@ async function detectAuth(ctx) {
|
|
|
334
334
|
{
|
|
335
335
|
key: 'auth.2fa',
|
|
336
336
|
present: twoFaDeps.length > 0 || twoFaSignals.length > 0,
|
|
337
|
-
evidence: [...depEvidence(twoFaDeps), ...snippetEvidence(twoFaSignals)],
|
|
337
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)([...depEvidence(twoFaDeps), ...snippetEvidence(twoFaSignals)], 'a second factor', ['otplib', 'speakeasy', 'notp', 'pyotp', 'django-otp', 'totp', 'authenticator app', 'webauthn', '@simplewebauthn']),
|
|
338
338
|
},
|
|
339
339
|
{
|
|
340
340
|
key: 'auth.apiKeys',
|
|
341
341
|
present: apiKeySignals.length > 0,
|
|
342
|
-
evidence: snippetEvidence(apiKeySignals),
|
|
342
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(apiKeySignals), 'keys this product issues and checks', ['x-api-key', 'api_keys.find', 'api_keys.where', 'hashedApiKey', 'token scope']),
|
|
343
343
|
},
|
|
344
344
|
{
|
|
345
345
|
key: 'auth.passwordReset',
|
|
@@ -366,22 +366,22 @@ async function detectAuth(ctx) {
|
|
|
366
366
|
{
|
|
367
367
|
key: 'authz.roles',
|
|
368
368
|
present: roleSignals.length > 0,
|
|
369
|
-
evidence: snippetEvidence(roleSignals),
|
|
369
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(roleSignals), 'role checks', ['role ===', 'hasRole', 'isAdmin', 'user.role', 'roles.includes', '@Roles', 'role_required']),
|
|
370
370
|
},
|
|
371
371
|
{
|
|
372
372
|
key: 'authz.permissions',
|
|
373
373
|
present: permissionSignals.length > 0,
|
|
374
|
-
evidence: snippetEvidence(permissionSignals),
|
|
374
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(permissionSignals), 'permission checks', ['requirePermission', 'permission_classes', 'permissions.py', 'authorize(', 'can(']),
|
|
375
375
|
},
|
|
376
376
|
{
|
|
377
377
|
key: 'authz.resourceLevel',
|
|
378
378
|
present: resourceLevelSignals.length > 0 || permissionSignals.length > 0,
|
|
379
|
-
evidence: snippetEvidence(resourceLevelSignals),
|
|
379
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(resourceLevelSignals), 'a check that the row belongs to the caller', ['requirePermission', 'permission_classes', 'authorize(', 'canAccess(', 'hasAccessTo(', 'ownerId', 'createdBy', 'req.user.id', 'userId ===']),
|
|
380
380
|
},
|
|
381
381
|
{
|
|
382
382
|
key: 'tenancy.organization',
|
|
383
383
|
present: hasOrganization,
|
|
384
|
-
evidence: snippetEvidence(organizationSignals),
|
|
384
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(organizationSignals), 'a tenant of its own', ['organizationId', 'organization_id', 'tenantId', 'tenant_id', 'workspaceId', 'accountId']),
|
|
385
385
|
details: {
|
|
386
386
|
b2bHint,
|
|
387
387
|
missingTenantRisk: b2bHint && !hasOrganization,
|
|
@@ -390,7 +390,7 @@ async function detectAuth(ctx) {
|
|
|
390
390
|
{
|
|
391
391
|
key: 'tenancy.membership',
|
|
392
392
|
present: membershipSignals.length > 0,
|
|
393
|
-
evidence: snippetEvidence(membershipSignals),
|
|
393
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(membershipSignals), 'a membership joining a person to a tenant', ['membership', 'organizationMember', 'teamMember', 'workspaceMember', 'memberId with a tenant word beside it']),
|
|
394
394
|
},
|
|
395
395
|
];
|
|
396
396
|
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.detectBilling = detectBilling;
|
|
4
4
|
const detectContext_1 = require("./detectContext");
|
|
5
5
|
const textSearch_1 = require("../utils/textSearch");
|
|
6
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
6
7
|
function toEvidence(matches) {
|
|
7
8
|
return matches.map((m) => ({ type: 'snippet', value: m.snippet, file: m.file, line: m.line }));
|
|
8
9
|
}
|
|
@@ -120,7 +121,7 @@ async function detectBilling(ctx) {
|
|
|
120
121
|
{
|
|
121
122
|
key: 'billing.stripe',
|
|
122
123
|
present: hasStrongStripeSignal,
|
|
123
|
-
evidence,
|
|
124
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'a payment integration', ['stripe', '@stripe/stripe-js', 'checkout.sessions.create', 'paddle', 'lemonsqueezy', 'braintree', 'a route under /webhook/stripe']),
|
|
124
125
|
details: {
|
|
125
126
|
stripe: hasStrongStripeSignal,
|
|
126
127
|
},
|
|
@@ -128,25 +129,25 @@ async function detectBilling(ctx) {
|
|
|
128
129
|
{
|
|
129
130
|
key: 'billing.webhook.route',
|
|
130
131
|
present: webhookRouteHits.length > 0 || webhookPathHits.length > 0,
|
|
131
|
-
evidence: [
|
|
132
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)([
|
|
132
133
|
...toEvidence(webhookRouteHits),
|
|
133
134
|
...webhookPathHits.map((file) => ({ type: 'file', value: file, file })),
|
|
134
|
-
],
|
|
135
|
+
], 'a route the payment provider calls back', ['/webhook', '/webhooks/stripe', 'a file under a webhook directory']),
|
|
135
136
|
},
|
|
136
137
|
{
|
|
137
138
|
key: 'billing.webhook.rawBody',
|
|
138
139
|
present: rawBodyHits.length > 0,
|
|
139
|
-
evidence: toEvidence(rawBodyHits),
|
|
140
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(toEvidence(rawBodyHits), 'the unparsed body a signature is computed over', ['express.raw', 'bodyParser.raw', 'request.text()', 'rawBody', 'await req.arrayBuffer()']),
|
|
140
141
|
},
|
|
141
142
|
{
|
|
142
143
|
key: 'billing.webhook.secret',
|
|
143
144
|
present: secretHits.length > 0,
|
|
144
|
-
evidence: toEvidence(secretHits),
|
|
145
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(toEvidence(secretHits), 'the signing secret, read from configuration', ['STRIPE_WEBHOOK_SECRET', 'WEBHOOK_SIGNING_SECRET', 'whsec_']),
|
|
145
146
|
},
|
|
146
147
|
{
|
|
147
148
|
key: 'billing.webhook.signatureValidation',
|
|
148
149
|
present: signatureHits.length > 0,
|
|
149
|
-
evidence: toEvidence(signatureHits),
|
|
150
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(toEvidence(signatureHits), 'the signature being checked', ['constructEvent(', 'verifyHeader(', 'Webhook.constructEvent', 'stripe-signature']),
|
|
150
151
|
},
|
|
151
152
|
];
|
|
152
153
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.detectDeployment = detectDeployment;
|
|
4
4
|
const textSearch_1 = require("../utils/textSearch");
|
|
5
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
5
6
|
async function detectDeployment(ctx) {
|
|
6
7
|
const evidence = [];
|
|
7
8
|
/**
|
|
@@ -77,7 +78,7 @@ async function detectDeployment(ctx) {
|
|
|
77
78
|
key: 'deployment.readiness',
|
|
78
79
|
present: presentFiles.length > 0 || hits.length > 0,
|
|
79
80
|
complete: prodAware,
|
|
80
|
-
evidence,
|
|
81
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'anything that says how this is deployed', ['Dockerfile', 'docker-compose', '.github/workflows/', 'Procfile', 'fly.toml', 'render.yaml', 'NODE_ENV', 'RAILS_ENV', 'ASPNETCORE_ENVIRONMENT', 'a file naming production']),
|
|
81
82
|
details: {
|
|
82
83
|
dockerArtifacts: presentFiles.some((f) => /Dockerfile|compose/.test(f)),
|
|
83
84
|
ci: presentFiles.some((f) => f.startsWith('.github/workflows/')),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.detectDocker = detectDocker;
|
|
4
4
|
const readTextFileSafe_1 = require("../utils/readTextFileSafe");
|
|
5
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
5
6
|
async function detectDocker(ctx) {
|
|
6
7
|
const evidence = [];
|
|
7
8
|
const dockerfile = ctx.files.all.find((f) => /(^|\/)Dockerfile$/.test(f));
|
|
@@ -37,7 +38,7 @@ async function detectDocker(ctx) {
|
|
|
37
38
|
key: 'infra.docker',
|
|
38
39
|
present: Boolean(dockerfile || composeFile),
|
|
39
40
|
complete: hasHealthcheck,
|
|
40
|
-
evidence,
|
|
41
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'a container definition', ['Dockerfile', 'Containerfile', 'docker-compose.yml', 'compose.yaml']),
|
|
41
42
|
details: { dockerfile: Boolean(dockerfile), compose: Boolean(composeFile), hasHealthcheck, hasExpose, services },
|
|
42
43
|
};
|
|
43
44
|
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.detectErrorReporting = detectErrorReporting;
|
|
4
4
|
const detectContext_1 = require("./detectContext");
|
|
5
5
|
const textSearch_1 = require("../utils/textSearch");
|
|
6
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
6
7
|
/**
|
|
7
8
|
* Whether a crash reaches the people who can fix it.
|
|
8
9
|
*
|
|
@@ -53,7 +54,7 @@ async function detectErrorReporting(ctx) {
|
|
|
53
54
|
// A reporting service is wired up once and covers everything; a hand-rolled handler
|
|
54
55
|
// usually covers what its author remembered.
|
|
55
56
|
complete: deps.length > 0,
|
|
56
|
-
evidence,
|
|
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")']),
|
|
57
58
|
details: { services: deps, handlers: handlers.length },
|
|
58
59
|
};
|
|
59
60
|
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.detectGame = detectGame;
|
|
4
4
|
const detectContext_1 = require("./detectContext");
|
|
5
5
|
const textSearch_1 = require("../utils/textSearch");
|
|
6
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
6
7
|
/**
|
|
7
8
|
* Whether this project is a game.
|
|
8
9
|
*
|
|
@@ -162,7 +163,7 @@ async function detectStatePersistence(ctx) {
|
|
|
162
163
|
present: saves || durable,
|
|
163
164
|
// Client storage alone is the partial case: it saves, until it does not.
|
|
164
165
|
complete: durable,
|
|
165
|
-
evidence,
|
|
166
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'anywhere progress is written down', ['localStorage', 'sessionStorage', 'IndexedDB', 'a save route', 'schema.prisma', 'a .sql file', 'pg', 'mongoose', 'better-sqlite3']),
|
|
166
167
|
details: { durable, clientStorage: clientOnly.length > 0, saveRoutines: saveRoutes.length },
|
|
167
168
|
};
|
|
168
169
|
}
|
|
@@ -193,7 +194,7 @@ async function detectAssetDelivery(ctx) {
|
|
|
193
194
|
return {
|
|
194
195
|
key: 'app.assetDelivery',
|
|
195
196
|
present: hits.length > 0 || headerFiles.length > 0,
|
|
196
|
-
evidence,
|
|
197
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'anything that lets a browser keep the assets', ['express.static(..., maxAge)', 'Cache-Control: max-age', 'setHeaders:', 'assetPrefix:', 'immutable', '_headers', 'netlify.toml', 'vercel.json']),
|
|
197
198
|
details: { inCode: hits.length, headerFiles: headerFiles.length },
|
|
198
199
|
};
|
|
199
200
|
}
|
|
@@ -4,6 +4,7 @@ exports.detectJobs = detectJobs;
|
|
|
4
4
|
const detectContext_1 = require("./detectContext");
|
|
5
5
|
const textSearch_1 = require("../utils/textSearch");
|
|
6
6
|
const readTextFileSafe_1 = require("../utils/readTextFileSafe");
|
|
7
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
7
8
|
/**
|
|
8
9
|
* Work that happens outside a request.
|
|
9
10
|
*
|
|
@@ -124,7 +125,7 @@ async function detectJobs(ctx) {
|
|
|
124
125
|
// A file named `workers/` is only taken as proof alongside something declared in
|
|
125
126
|
// the code: the name is a convention, and conventions are borrowed.
|
|
126
127
|
present: declared,
|
|
127
|
-
evidence,
|
|
128
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'work that happens outside a request', ['bullmq', 'bull', 'agenda', 'bee-queue', 'celery', 'rq', 'sidekiq', 'resque', 'graphile-worker', 'a cron schedule', 'a worker entrypoint']),
|
|
128
129
|
details: {
|
|
129
130
|
nodeQueue: nodeDeps.length > 0,
|
|
130
131
|
pythonQueue: pyDeps.length > 0,
|
|
@@ -4,6 +4,7 @@ exports.detectMobile = detectMobile;
|
|
|
4
4
|
const detectContext_1 = require("./detectContext");
|
|
5
5
|
const readTextFileSafe_1 = require("../utils/readTextFileSafe");
|
|
6
6
|
const textSearch_1 = require("../utils/textSearch");
|
|
7
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
7
8
|
/**
|
|
8
9
|
* Whether this is an application that ships to somebody else's phone, and what that
|
|
9
10
|
* demands of it.
|
|
@@ -351,24 +352,32 @@ async function detectMobile(ctx) {
|
|
|
351
352
|
// Declared-and-explained, or asked at the moment of use. A manifest full of
|
|
352
353
|
// permissions with nothing next to them is the failing case, not the passing one.
|
|
353
354
|
present: permissionsExplained,
|
|
354
|
-
evidence: permissionEvidence,
|
|
355
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(permissionEvidence, 'a reason given for each permission asked for', ['NSCameraUsageDescription and the other NS*UsageDescription keys', 'a permission rationale beside uses-permission', 'requestPermissions(', 'Permission.request']),
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
key: 'mobile.credentialStorage',
|
|
359
|
+
present: credentialEvidence.length > 0,
|
|
360
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(credentialEvidence, 'a secure store for credentials on the device', ['react-native-keychain', 'expo-secure-store', 'flutter_secure_storage', 'KeychainAccess', 'kSecClass', 'EncryptedSharedPreferences', 'AndroidKeyStore', 'SecItemAdd']),
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
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']),
|
|
355
366
|
},
|
|
356
|
-
{ key: 'mobile.credentialStorage', present: credentialEvidence.length > 0, evidence: credentialEvidence },
|
|
357
|
-
{ key: 'mobile.offline', present: offlineDeps.length > 0, evidence: offlineEvidence },
|
|
358
367
|
{
|
|
359
368
|
key: 'mobile.forcedUpdate',
|
|
360
369
|
present: forcedUpdate.length > 0,
|
|
361
|
-
evidence: forcedUpdate.map((match) => ({
|
|
370
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(forcedUpdate.map((match) => ({
|
|
362
371
|
type: 'snippet',
|
|
363
372
|
value: match.snippet,
|
|
364
373
|
file: match.file,
|
|
365
374
|
line: match.line,
|
|
366
|
-
})),
|
|
375
|
+
})), 'a check that the installed version is still supported', ['minimumVersion', 'minSupportedVersion', 'forceUpdate', 'upgrade_required', 'in_app_update', 'AppUpdateManager']),
|
|
367
376
|
},
|
|
368
377
|
{
|
|
369
378
|
key: 'mobile.privacyDeclaration',
|
|
370
379
|
present: privacyFiles.length > 0,
|
|
371
|
-
evidence: privacyFiles.map((file) => ({ type: 'file', value: file, file })),
|
|
380
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(privacyFiles.map((file) => ({ type: 'file', value: file, file })), 'the privacy declaration the stores require', ['PrivacyInfo.xcprivacy', 'a data-safety declaration', 'privacy_policy', 'PRIVACY.md']),
|
|
372
381
|
},
|
|
373
382
|
];
|
|
374
383
|
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.detectEngagement = detectEngagement;
|
|
4
4
|
const detectContext_1 = require("./detectContext");
|
|
5
5
|
const textSearch_1 = require("../utils/textSearch");
|
|
6
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
6
7
|
/**
|
|
7
8
|
* Signals for the two things a consumer product needs that a backend service does not:
|
|
8
9
|
* a way to bring a stranger from signup to first use, and a way to reach them
|
|
@@ -50,7 +51,7 @@ async function detectNotifications(ctx) {
|
|
|
50
51
|
key: 'notifications.transactional',
|
|
51
52
|
present: capable || wired,
|
|
52
53
|
complete: capable && wired,
|
|
53
|
-
evidence,
|
|
54
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'a way to tell a user something happened', ['nodemailer', 'resend', '@sendgrid/mail', 'postmark', 'mailgun', 'django.core.mail', 'sendMail(', 'send_mail(', 'firebase-admin messaging', 'an email template file']),
|
|
54
55
|
details: {
|
|
55
56
|
emailDependency: emailDeps.length > 0,
|
|
56
57
|
pushDependency: pushDeps.length > 0,
|
|
@@ -72,7 +73,7 @@ async function detectOnboarding(ctx) {
|
|
|
72
73
|
key: 'onboarding.flow',
|
|
73
74
|
present: strong || hits.length > 0,
|
|
74
75
|
complete: strong,
|
|
75
|
-
evidence,
|
|
76
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'anything that takes a new user through a first run', ['onboarding', 'getting-started', 'welcome', 'firstRun', 'setup wizard', 'a file named for onboarding']),
|
|
76
77
|
details: { onboardingFiles: files.length, onboardingSignals: hits.length },
|
|
77
78
|
};
|
|
78
79
|
}
|
|
@@ -5,6 +5,7 @@ exports.detectPackaging = detectPackaging;
|
|
|
5
5
|
const readTextFileSafe_1 = require("../utils/readTextFileSafe");
|
|
6
6
|
const detectContext_1 = require("./detectContext");
|
|
7
7
|
const readTextFileSafe_2 = require("../utils/readTextFileSafe");
|
|
8
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
8
9
|
/**
|
|
9
10
|
* Whether a project is fit to be installed and depended on by someone else.
|
|
10
11
|
*
|
|
@@ -212,7 +213,7 @@ async function detectPackaging(ctx) {
|
|
|
212
213
|
// answer, and a reader who has one does not need the other to use the code.
|
|
213
214
|
present: licenseFiles.length > 0 || declaredLicense,
|
|
214
215
|
complete: licenseFiles.length > 0 && declaredLicense,
|
|
215
|
-
evidence: licenseEvidence,
|
|
216
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(licenseEvidence, 'a licence', ['LICENSE', 'LICENCE', 'COPYING', 'a license field in package.json', 'a license classifier in pyproject.toml']),
|
|
216
217
|
details: { files: licenseFiles.length, declared: declaredLicense },
|
|
217
218
|
},
|
|
218
219
|
{
|
|
@@ -234,25 +235,25 @@ async function detectPackaging(ctx) {
|
|
|
234
235
|
key: 'docs.readme',
|
|
235
236
|
present: readmeFiles.length > 0,
|
|
236
237
|
complete: readmeLength > 400,
|
|
237
|
-
evidence: readmeFiles.map((file) => ({ type: 'file', value: `${file} (${readmeLength} characters)`, file })),
|
|
238
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(readmeFiles.map((file) => ({ type: 'file', value: `${file} (${readmeLength} characters)`, file })), 'a readme', ['README', 'README.md', 'README.rst', 'readme.txt']),
|
|
238
239
|
details: { length: readmeLength },
|
|
239
240
|
},
|
|
240
241
|
{
|
|
241
242
|
key: 'quality.tests',
|
|
242
243
|
present: testFiles.length > 0,
|
|
243
244
|
complete: testFiles.length > 0 && (hasTestScript || hasPyTestRunner),
|
|
244
|
-
evidence: [
|
|
245
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)([
|
|
245
246
|
...testFiles.slice(0, 3).map((file) => ({ type: 'file', value: file, file })),
|
|
246
247
|
...(hasTestScript ? [{ type: 'note', value: 'an "npm test" script' }] : []),
|
|
247
248
|
...(hasPyTestRunner ? [{ type: 'note', value: 'a Python test runner' }] : []),
|
|
248
|
-
],
|
|
249
|
+
], 'tests', ['a path under test/ or tests/', '*.test.*', '*.spec.*', 'test_*.py', '*_test.go', 'an "npm test" script', 'pytest', 'tox']),
|
|
249
250
|
details: { files: testFiles.length, runner: hasTestScript || hasPyTestRunner },
|
|
250
251
|
},
|
|
251
252
|
{
|
|
252
253
|
key: 'quality.ci',
|
|
253
254
|
present: ciFiles.length > 0,
|
|
254
255
|
complete: ciFiles.length > 0 && testFiles.length > 0,
|
|
255
|
-
evidence: ciFiles.map((file) => ({ type: 'file', value: file, file })),
|
|
256
|
+
evidence: (0, absenceEvidence_1.evidenceOrSearch)(ciFiles.map((file) => ({ type: 'file', value: file, file })), 'anything that runs the tests without being asked', ['.github/workflows/', '.gitlab-ci.yml', '.circleci/config.yml', 'azure-pipelines.yml', 'Jenkinsfile', '.travis.yml']),
|
|
256
257
|
details: { files: ciFiles.length },
|
|
257
258
|
},
|
|
258
259
|
];
|
package/dist/rules/rules.js
CHANGED
|
@@ -263,7 +263,18 @@ exports.rules = [
|
|
|
263
263
|
? `Hardcoded fallback secrets detected (${weakTypes || 'unknown'} key context).`
|
|
264
264
|
: 'No weak fallback secret patterns detected.',
|
|
265
265
|
recommendation: 'Require strong secrets through environment variables with strict startup validation.',
|
|
266
|
-
|
|
266
|
+
/**
|
|
267
|
+
* A check that passes because it found nothing has to say what it looked for.
|
|
268
|
+
*
|
|
269
|
+
* This was the most repeated line in the product: 175 reports said "no weak
|
|
270
|
+
* fallback secret patterns detected" with nothing underneath, which tells the
|
|
271
|
+
* reader neither how hard it looked nor whether their own placeholder would
|
|
272
|
+
* have been caught. A negative result is only worth something when the search
|
|
273
|
+
* behind it is visible.
|
|
274
|
+
*/
|
|
275
|
+
evidence: weakEvidence.length > 0
|
|
276
|
+
? weakEvidence
|
|
277
|
+
: (0, absenceEvidence_1.searchedFor)('hardcoded fallback secrets', ['changeme', 'your-secret', 'dev-secret', 'local-secret', 'change-in-production', 'test123', 'a secret assigned a literal beside jwt_secret, secret_key or session_secret']),
|
|
267
278
|
});
|
|
268
279
|
},
|
|
269
280
|
},
|
|
@@ -438,7 +449,11 @@ exports.rules = [
|
|
|
438
449
|
? 'Django settings contain DEBUG=True.'
|
|
439
450
|
: 'No DEBUG=True signal found.',
|
|
440
451
|
recommendation: 'Set DEBUG=False for non-local environments and enforce via environment variables.',
|
|
441
|
-
evidence: !isDjango
|
|
452
|
+
evidence: !isDjango
|
|
453
|
+
? [{ type: 'note', value: 'Django stack not detected' }]
|
|
454
|
+
: evidenceForClaim(sec?.evidence, 'django-debug').length > 0
|
|
455
|
+
? evidenceForClaim(sec?.evidence, 'django-debug')
|
|
456
|
+
: (0, absenceEvidence_1.searchedFor)('DEBUG left on', ['a line reading DEBUG = True in the settings Django actually loads, outside any if settings.DEBUG block']),
|
|
442
457
|
});
|
|
443
458
|
},
|
|
444
459
|
},
|
|
@@ -464,7 +479,11 @@ exports.rules = [
|
|
|
464
479
|
? 'Secure cookie settings appear configured.'
|
|
465
480
|
: 'SESSION/CSRF secure cookie flags are weak.',
|
|
466
481
|
recommendation: 'Enable SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE in production.',
|
|
467
|
-
evidence: !isDjango
|
|
482
|
+
evidence: !isDjango
|
|
483
|
+
? [{ type: 'note', value: 'Django stack not detected' }]
|
|
484
|
+
: evidenceForClaim(sec?.evidence, 'django-cookies').length > 0
|
|
485
|
+
? evidenceForClaim(sec?.evidence, 'django-cookies')
|
|
486
|
+
: (0, absenceEvidence_1.searchedFor)('cookies left insecure', ['SESSION_COOKIE_SECURE = False', 'CSRF_COOKIE_SECURE = False']),
|
|
468
487
|
});
|
|
469
488
|
},
|
|
470
489
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.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": {
|