@produtype/core 0.8.0 → 0.8.2
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.
|
@@ -57,7 +57,24 @@ async function detectAuth(ctx) {
|
|
|
57
57
|
], 30);
|
|
58
58
|
const twoFaSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/two[_-]?factor/i, /otp/i, /totp/i], 20);
|
|
59
59
|
const apiKeySignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/x-api-key/i, /apiKey/i, /API_KEY/, /token\s*scope/i], 20);
|
|
60
|
-
const passwordResetSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
|
|
60
|
+
const passwordResetSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
|
|
61
|
+
/forgot\s*password/i,
|
|
62
|
+
/password[_-]?reset/i,
|
|
63
|
+
/reset\s*token/i,
|
|
64
|
+
/**
|
|
65
|
+
* Django ships the whole flow — token, expiry, single use — behind one include.
|
|
66
|
+
* A report on a real school platform called password reset missing while
|
|
67
|
+
* `path("accounts/", include("django.contrib.auth.urls"))` sat in its urls.py:
|
|
68
|
+
* the words "password" and "reset" appear nowhere, because the framework
|
|
69
|
+
* supplies them.
|
|
70
|
+
*/
|
|
71
|
+
/django\.contrib\.auth\.urls/,
|
|
72
|
+
/PasswordReset(View|ConfirmView|DoneView|CompleteView)/,
|
|
73
|
+
/auth_views\.PasswordReset/,
|
|
74
|
+
// The same shape in other frameworks that hand you the flow rather than the words.
|
|
75
|
+
/Devise|devise_for/,
|
|
76
|
+
/Auth::routes\(/,
|
|
77
|
+
], 20);
|
|
61
78
|
const emailVerificationSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/verify\s*email/i, /email[_-]?verification/i, /confirm\s*email/i, /isEmailVerified/i], 20);
|
|
62
79
|
const sessionSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/session/i, /cookie/i, /jwt/i, /refresh\s*token/i, /httpOnly/i], 25);
|
|
63
80
|
const roleSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
|
|
@@ -83,6 +100,23 @@ async function detectAuth(ctx) {
|
|
|
83
100
|
/organizationId/i,
|
|
84
101
|
/tenantId/i,
|
|
85
102
|
/workspaceId/i,
|
|
103
|
+
/**
|
|
104
|
+
* Django checks ownership inside the view, not in middleware before it.
|
|
105
|
+
*
|
|
106
|
+
* A report said "no resource-level authorization signals detected" of a codebase
|
|
107
|
+
* whose view reads
|
|
108
|
+
* `if not request.user.is_staff and request.user.pk != user.pk: raise
|
|
109
|
+
* PermissionDenied`, with visibility helpers called in every other view. The
|
|
110
|
+
* patterns above are all Express and DRF shaped, so none of that was visible.
|
|
111
|
+
*/
|
|
112
|
+
/raise\s+PermissionDenied/,
|
|
113
|
+
/request\.user\.pk\s*!?==/,
|
|
114
|
+
/request\.user\.is_staff/,
|
|
115
|
+
/UserPassesTestMixin/,
|
|
116
|
+
/PermissionRequiredMixin/,
|
|
117
|
+
/@user_passes_test/,
|
|
118
|
+
/get_queryset\([^)]*\)[\s\S]{0,120}filter\([^)]*user/,
|
|
119
|
+
/\bis_visible_to\b/,
|
|
86
120
|
], 20);
|
|
87
121
|
/**
|
|
88
122
|
* Words that only mean tenancy, and words that usually mean something else.
|
|
@@ -44,7 +44,38 @@ async function detectUploads(ctx) {
|
|
|
44
44
|
routeSignals.push(...findExpressUploadsRoutes(text, file));
|
|
45
45
|
}
|
|
46
46
|
const source = ctx.files.source;
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Django media served to the public, which is a route and not a setting.
|
|
49
|
+
*
|
|
50
|
+
* This matched `MEDIA_URL` and `MEDIA_ROOT` — two lines in settings.py that say where
|
|
51
|
+
* uploaded files live on disk and under which prefix they *would* be served. Neither
|
|
52
|
+
* serves anything. Django exposes them only when a URL pattern says so, and the
|
|
53
|
+
* idiomatic one is wrapped in `if settings.DEBUG`.
|
|
54
|
+
*
|
|
55
|
+
* A real report on a real school platform called its uploads publicly exposed on the
|
|
56
|
+
* strength of those two lines, with no pattern serving media anywhere in the project.
|
|
57
|
+
*/
|
|
58
|
+
const djangoPublicSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [
|
|
59
|
+
/static\s*\(\s*settings\.MEDIA_URL/i,
|
|
60
|
+
/document_root\s*=/i,
|
|
61
|
+
/re_path\s*\(\s*r?['"][^'"]*media/i,
|
|
62
|
+
/url\s*\(\s*r?['"][^'"]*media/i,
|
|
63
|
+
], 15);
|
|
64
|
+
/**
|
|
65
|
+
* Django's own way of protecting a view: a decorator or a mixin, not middleware on a
|
|
66
|
+
* route. The Express-shaped route scan cannot see either, so a project whose upload
|
|
67
|
+
* view is `@login_required` read as having no protection at all.
|
|
68
|
+
*/
|
|
69
|
+
const djangoProtectionSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [
|
|
70
|
+
/@login_required/,
|
|
71
|
+
/LoginRequiredMixin/,
|
|
72
|
+
/PermissionRequiredMixin/,
|
|
73
|
+
/@user_passes_test/,
|
|
74
|
+
/@permission_required/,
|
|
75
|
+
], 15);
|
|
76
|
+
for (const m of djangoProtectionSignals) {
|
|
77
|
+
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
|
|
78
|
+
}
|
|
48
79
|
const validationSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/file-type/i, /mime/i, /content-type/i], 15);
|
|
49
80
|
const protectedRoutes = routeSignals.filter((r) => r.protected);
|
|
50
81
|
const unprotectedRoutes = routeSignals.filter((r) => !r.protected);
|
|
@@ -56,6 +87,7 @@ async function detectUploads(ctx) {
|
|
|
56
87
|
for (const m of validationSignals)
|
|
57
88
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
|
|
58
89
|
const publicExposure = unprotectedRoutes.length > 0 || djangoPublicSignals.length > 0;
|
|
90
|
+
const protectedSomehow = protectedRoutes.length > 0 || djangoProtectionSignals.length > 0;
|
|
59
91
|
return {
|
|
60
92
|
key: 'uploads.exposure',
|
|
61
93
|
present: uploadDeps.length > 0 || routeSignals.length > 0 || djangoPublicSignals.length > 0,
|
|
@@ -63,7 +95,7 @@ async function detectUploads(ctx) {
|
|
|
63
95
|
evidence,
|
|
64
96
|
details: {
|
|
65
97
|
publicExposure,
|
|
66
|
-
protectedUploads:
|
|
98
|
+
protectedUploads: protectedSomehow,
|
|
67
99
|
protectedUploadsSameRoute: protectedRoutes.length > 0,
|
|
68
100
|
unprotectedUploadRoutes: unprotectedRoutes.length,
|
|
69
101
|
validation: validationSignals.length > 0,
|
|
@@ -96,7 +96,20 @@ function deriveStatus(analysis, capability) {
|
|
|
96
96
|
return 'present';
|
|
97
97
|
if (loose)
|
|
98
98
|
return 'partial';
|
|
99
|
-
|
|
99
|
+
/**
|
|
100
|
+
* A cross-origin policy is only a question for something that answers
|
|
101
|
+
* cross-origin requests.
|
|
102
|
+
*
|
|
103
|
+
* A server-rendered monolith with no API, no CORS library installed and no
|
|
104
|
+
* cross-origin handling anywhere is not missing a policy — the browser's own
|
|
105
|
+
* default already refuses those requests, and the absence *is* the safe
|
|
106
|
+
* configuration. Reporting it as a critical gap rewards adding middleware that
|
|
107
|
+
* can only loosen what is currently closed.
|
|
108
|
+
*
|
|
109
|
+
* An independent review of a report on a Django school platform put it as
|
|
110
|
+
* "not applicable, and inverted". It was right.
|
|
111
|
+
*/
|
|
112
|
+
return servesCrossOrigin(analysis) ? 'missing' : 'not_applicable';
|
|
100
113
|
}
|
|
101
114
|
case 'security.rate-limit': {
|
|
102
115
|
return boolDetail(sec, 'rateLimit') ? 'present' : 'missing';
|
|
@@ -275,6 +288,22 @@ function evidenceFor(analysis, capability) {
|
|
|
275
288
|
.map((key) => detector(analysis, key))
|
|
276
289
|
.filter(Boolean);
|
|
277
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Whether anything here could receive a cross-origin request.
|
|
293
|
+
*
|
|
294
|
+
* An API surface, a framework built to serve one, or a CORS library someone installed
|
|
295
|
+
* on purpose. None of the three means the question does not arise.
|
|
296
|
+
*/
|
|
297
|
+
function servesCrossOrigin(analysis) {
|
|
298
|
+
// Somebody wrote cross-origin handling, however badly: the question plainly arises.
|
|
299
|
+
const sec = detector(analysis, 'security.core');
|
|
300
|
+
if (sec?.evidence.some((item) => /cors/i.test(String(item.value))))
|
|
301
|
+
return true;
|
|
302
|
+
// An API meant for other callers.
|
|
303
|
+
if (detector(analysis, 'auth.apiKeys')?.present)
|
|
304
|
+
return true;
|
|
305
|
+
return analysis.files.source.some((file) => /(^|\/)(api|routes?|controllers?|serializers?|graphql)(\/|\.)/i.test(file));
|
|
306
|
+
}
|
|
278
307
|
function evidenceQualityFor(detectors) {
|
|
279
308
|
if (detectors.some((detectorResult) => detectorResult.evidence.some((item) => item.type === 'file' && typeof item.line === 'number'))) {
|
|
280
309
|
return 'strong';
|
package/dist/rules/rules.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.rules = void 0;
|
|
4
|
+
const inferProductProfile_1 = require("../expectations/inferProductProfile");
|
|
4
5
|
function statusFromFlags(present, complete) {
|
|
5
6
|
if (!present)
|
|
6
7
|
return 'missing';
|
|
@@ -204,7 +205,17 @@ exports.rules = [
|
|
|
204
205
|
category: 'security',
|
|
205
206
|
status,
|
|
206
207
|
severity: sevForStatus(status, 'medium'),
|
|
207
|
-
|
|
208
|
+
/**
|
|
209
|
+
* The description used to branch on `hasHelmet` alone, so a project that is not
|
|
210
|
+
* an Express application at all — status `unknown` — still read "Helmet
|
|
211
|
+
* detected." A Django repository was told that, which is a contradiction inside
|
|
212
|
+
* one finding and exactly the kind of thing that costs a report its credibility.
|
|
213
|
+
*/
|
|
214
|
+
description: !isExpress
|
|
215
|
+
? `This check is about Express middleware; the backend here is ${analysis.stack.backend.join(', ') || 'not an Express application'}.`
|
|
216
|
+
: hasHelmet
|
|
217
|
+
? 'Helmet detected.'
|
|
218
|
+
: 'Helmet/security headers not detected for Express app.',
|
|
208
219
|
recommendation: 'Enable helmet() and review CSP/HSTS settings for your deployment model.',
|
|
209
220
|
evidence: det?.evidence ?? [],
|
|
210
221
|
});
|
|
@@ -409,7 +420,22 @@ exports.rules = [
|
|
|
409
420
|
// Tenant boundaries are a backend data-access concern: without a detected
|
|
410
421
|
// backend the B2B keyword hint alone (e.g. in a frontend client) is noise.
|
|
411
422
|
const backendDetected = analysis.stack.backend.length > 0;
|
|
412
|
-
|
|
423
|
+
/**
|
|
424
|
+
* And not when the product is not a business one.
|
|
425
|
+
*
|
|
426
|
+
* This fired on a school platform that the same report classified as a B2C app,
|
|
427
|
+
* so the document said "consumer application" in one place and "B2B/SaaS signals
|
|
428
|
+
* detected" in another. A reader cannot act on a report that contradicts itself,
|
|
429
|
+
* and of the two statements the profile is the one built from weighted evidence.
|
|
430
|
+
*/
|
|
431
|
+
const consumerProduct = ['b2c-app', 'client-app', 'game', 'mobile-app', 'static-site'].includes(String((0, inferProductProfile_1.inferProductProfile)(analysis).inferredProfile));
|
|
432
|
+
const status = !b2bHint || !backendDetected || consumerProduct
|
|
433
|
+
? 'unknown'
|
|
434
|
+
: missingTenantRisk
|
|
435
|
+
? 'missing'
|
|
436
|
+
: hasMembership
|
|
437
|
+
? 'passed'
|
|
438
|
+
: 'partial';
|
|
413
439
|
return mkFinding({
|
|
414
440
|
id: 'tenancy.b2b',
|
|
415
441
|
title: 'Tenant and organization boundaries',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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": {
|