@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
- const djangoPublicSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/MEDIA_ROOT/i, /MEDIA_URL/i], 15);
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: protectedRoutes.length > 0,
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
- return 'missing';
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
- function confidenceFor(status, profileMode, evidenceQuality) {
291
- if (profileMode !== 'observed-only' && profileMode !== 'auto' && status !== 'unknown') {
292
- return 'high';
293
- }
294
- if (profileMode === 'auto') {
295
- return evidenceQuality === 'strong' ? 'medium' : 'low';
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 severity = severityFor(cap, status, effectiveImportance);
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, args.requestedProfile, quality);
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
- notAssessed: categoryFindings.length === 0,
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 && entry.score >= 80 && entry.findingCount === 0)
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) {
@@ -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
- severity: args.severity,
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: confidenceFor(args.status, evidenceQuality),
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.7.1",
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": {