@produtype/core 0.7.1 → 0.8.1
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.
|
@@ -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';
|
|
@@ -287,19 +316,51 @@ function evidenceQualityFor(detectors) {
|
|
|
287
316
|
}
|
|
288
317
|
return 'weak';
|
|
289
318
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
319
|
+
/**
|
|
320
|
+
* How sure the analyzer is about *this* finding.
|
|
321
|
+
*
|
|
322
|
+
* It used to fold in how the profile was chosen: on `auto` — which is what the hosted
|
|
323
|
+
* product uses by default — every finding came out low or medium however strong its
|
|
324
|
+
* evidence. Two different uncertainties were being multiplied into one number, and the
|
|
325
|
+
* profile's own uncertainty is already reported separately as `inferenceConfidence`.
|
|
326
|
+
*
|
|
327
|
+
* An independent review of a real report put it plainly: eleven findings of
|
|
328
|
+
* thirty-two carried "no direct evidence captured" at low confidence and weak
|
|
329
|
+
* evidence, and still arrived as critical or high.
|
|
330
|
+
*/
|
|
331
|
+
function confidenceFor(status, evidenceQuality) {
|
|
332
|
+
// Nothing was determined, so there is nothing to be confident about.
|
|
333
|
+
if (status === 'unknown')
|
|
334
|
+
return 'low';
|
|
297
335
|
if (evidenceQuality === 'strong')
|
|
298
336
|
return 'high';
|
|
299
337
|
if (evidenceQuality === 'medium')
|
|
300
338
|
return 'medium';
|
|
301
339
|
return 'low';
|
|
302
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Severity says how bad it would be if true. Confidence says whether it is.
|
|
343
|
+
*
|
|
344
|
+
* A reader treats `critical` as "stop and fix this", and spending that word on a claim
|
|
345
|
+
* the analyzer itself is unsure of is how a tool becomes a checklist nobody trusts.
|
|
346
|
+
* The claim stays, at the weight the evidence supports.
|
|
347
|
+
*/
|
|
348
|
+
function severityForConfidence(severity, confidence) {
|
|
349
|
+
if (confidence !== 'low')
|
|
350
|
+
return severity;
|
|
351
|
+
/**
|
|
352
|
+
* `critical` only. Not a ceiling at `medium`, which was the first attempt and was
|
|
353
|
+
* wrong for a reason worth writing down: a missing capability cannot carry direct
|
|
354
|
+
* evidence — there is no line to point at for something that is not there — so every
|
|
355
|
+
* absence reads as low confidence, and capping them all at `medium` would leave the
|
|
356
|
+
* report unable to say anything is serious.
|
|
357
|
+
*
|
|
358
|
+
* What it can stop doing is shouting. `critical` is read as "stop and fix this
|
|
359
|
+
* before anything else", and the analyzer should not spend that word on a claim it
|
|
360
|
+
* could not evidence.
|
|
361
|
+
*/
|
|
362
|
+
return severity === 'critical' ? 'high' : severity;
|
|
363
|
+
}
|
|
303
364
|
/**
|
|
304
365
|
* Which capabilities each declaration makes required.
|
|
305
366
|
*
|
|
@@ -390,10 +451,11 @@ function evaluateExpectedCapabilities(args) {
|
|
|
390
451
|
effectiveImportance = 'not_applicable';
|
|
391
452
|
}
|
|
392
453
|
const findingId = toFindingId(cap, effectiveImportance);
|
|
393
|
-
const
|
|
454
|
+
const claimedSeverity = severityFor(cap, status, effectiveImportance);
|
|
394
455
|
const detectorEvidence = evidenceFor(args.analysis, cap).flatMap((d) => d.evidence);
|
|
395
456
|
const quality = evidenceQualityFor(evidenceFor(args.analysis, cap));
|
|
396
|
-
const confidence = confidenceFor(status,
|
|
457
|
+
const confidence = confidenceFor(status, quality);
|
|
458
|
+
const severity = severityForConfidence(claimedSeverity, confidence);
|
|
397
459
|
const evaluation = {
|
|
398
460
|
capabilityId: cap.id,
|
|
399
461
|
title: cap.title,
|
|
@@ -52,7 +52,16 @@ function buildCategoryScores(findings) {
|
|
|
52
52
|
findingCount: actionable.length,
|
|
53
53
|
criticalCount: actionable.filter((finding) => finding.severity === 'critical').length,
|
|
54
54
|
highCount: actionable.filter((finding) => finding.severity === 'high').length,
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* "I don't know" is not an assessment.
|
|
57
|
+
*
|
|
58
|
+
* This was `categoryFindings.length === 0`, so a single `info` finding with
|
|
59
|
+
* status `unknown` — the analyzer recording that it could not tell — made the
|
|
60
|
+
* category count as assessed, with zero actionable findings and a score of 100.
|
|
61
|
+
* A Django school platform with no payments anywhere was reported as having
|
|
62
|
+
* "no issues found in taking payments", as a strength.
|
|
63
|
+
*/
|
|
64
|
+
notAssessed: categoryFindings.every((finding) => finding.status === 'unknown'),
|
|
56
65
|
};
|
|
57
66
|
});
|
|
58
67
|
}
|
|
@@ -83,8 +83,19 @@ function buildVerdict(args) {
|
|
|
83
83
|
}
|
|
84
84
|
function buildStrengths(categoryScores, findings) {
|
|
85
85
|
const passed = findings.filter((finding) => finding.status === 'passed');
|
|
86
|
+
/**
|
|
87
|
+
* A strength is something verified, not something absent.
|
|
88
|
+
*
|
|
89
|
+
* Filtering on "assessed, scoring well, nothing open" let a category qualify on the
|
|
90
|
+
* strength of having nothing to say about it. Requiring a passed check means the
|
|
91
|
+
* report only calls something a strength when it watched it work.
|
|
92
|
+
*/
|
|
93
|
+
const passedCategories = new Set(passed.map((finding) => finding.category));
|
|
86
94
|
const strongCategories = categoryScores
|
|
87
|
-
.filter((entry) => !entry.notAssessed
|
|
95
|
+
.filter((entry) => !entry.notAssessed
|
|
96
|
+
&& entry.score >= 80
|
|
97
|
+
&& entry.findingCount === 0
|
|
98
|
+
&& passedCategories.has(entry.category))
|
|
88
99
|
.map((entry) => CATEGORY_LABEL[entry.category]);
|
|
89
100
|
const strengths = strongCategories.slice(0, 3).map((label) => `no issues found in ${label}`);
|
|
90
101
|
if (strengths.length === 0 && passed.length > 0) {
|
package/dist/rules/rules.js
CHANGED
|
@@ -46,6 +46,23 @@ function evidenceQualityFor(evidence) {
|
|
|
46
46
|
}
|
|
47
47
|
return 'weak';
|
|
48
48
|
}
|
|
49
|
+
/** Severity is capped by how sure the analyzer is. See the note where it is used. */
|
|
50
|
+
function severityForConfidence(severity, confidence) {
|
|
51
|
+
if (confidence !== 'low')
|
|
52
|
+
return severity;
|
|
53
|
+
/**
|
|
54
|
+
* `critical` only. Not a ceiling at `medium`, which was the first attempt and was
|
|
55
|
+
* wrong for a reason worth writing down: a missing capability cannot carry direct
|
|
56
|
+
* evidence — there is no line to point at for something that is not there — so every
|
|
57
|
+
* absence reads as low confidence, and capping them all at `medium` would leave the
|
|
58
|
+
* report unable to say anything is serious.
|
|
59
|
+
*
|
|
60
|
+
* What it can stop doing is shouting. `critical` is read as "stop and fix this
|
|
61
|
+
* before anything else", and the analyzer should not spend that word on a claim it
|
|
62
|
+
* could not evidence.
|
|
63
|
+
*/
|
|
64
|
+
return severity === 'critical' ? 'high' : severity;
|
|
65
|
+
}
|
|
49
66
|
function confidenceFor(status, evidenceQuality) {
|
|
50
67
|
if (status === 'passed' && evidenceQuality === 'strong') {
|
|
51
68
|
return 'high';
|
|
@@ -60,16 +77,24 @@ function confidenceFor(status, evidenceQuality) {
|
|
|
60
77
|
}
|
|
61
78
|
function mkFinding(args) {
|
|
62
79
|
const evidenceQuality = evidenceQualityFor(args.evidence);
|
|
80
|
+
const confidence = confidenceFor(args.status, evidenceQuality);
|
|
63
81
|
return {
|
|
64
82
|
id: args.id,
|
|
65
83
|
title: args.title,
|
|
66
84
|
category: args.category,
|
|
67
85
|
status: args.status,
|
|
68
|
-
|
|
86
|
+
/**
|
|
87
|
+
* The same rule the expectations follow: severity says how bad it would be if true,
|
|
88
|
+
* confidence says whether it is, and a reader treats `critical` as "stop and fix
|
|
89
|
+
* this". Spending that word on something the analyzer is unsure of is how a report
|
|
90
|
+
* stops being believed — and it was doing it on both sides of the report, not only
|
|
91
|
+
* in the expectation half.
|
|
92
|
+
*/
|
|
93
|
+
severity: severityForConfidence(args.severity, confidence),
|
|
69
94
|
description: args.description,
|
|
70
95
|
recommendation: args.recommendation,
|
|
71
96
|
evidence: detectorEvidence(args.evidence),
|
|
72
|
-
confidence
|
|
97
|
+
confidence,
|
|
73
98
|
evidenceQuality,
|
|
74
99
|
};
|
|
75
100
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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": {
|