@produtype/core 0.45.0 → 0.47.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/report/buildReport.js +11 -2
- package/dist/report/categoryScores.d.ts +8 -1
- package/dist/report/categoryScores.js +4 -3
- package/dist/report/complianceMapping.d.ts +20 -0
- package/dist/report/complianceMapping.js +34 -5
- package/dist/report/executiveSummary.d.ts +1 -1
- package/dist/report/executiveSummary.js +1 -1
- package/dist/report/markdownReport.js +35 -11
- package/dist/report/types.d.ts +2 -1
- package/package.json +1 -1
|
@@ -287,6 +287,15 @@ function buildReport(analysis, options) {
|
|
|
287
287
|
const maturityLevel = overallScore === null
|
|
288
288
|
? 'inconclusive'
|
|
289
289
|
: (0, score_1.computeMaturity)(overallScore, coverage, openCriticals);
|
|
290
|
+
/**
|
|
291
|
+
* The observed half goes with it.
|
|
292
|
+
*
|
|
293
|
+
* "Overall score: not scored" printed directly above "Observed score: 100/100" —
|
|
294
|
+
* the report refusing to characterise a repository and then praising it in the next
|
|
295
|
+
* line. The observed number is the same arithmetic over the same three checks: if
|
|
296
|
+
* it cannot support a reading it cannot support half of one either.
|
|
297
|
+
*/
|
|
298
|
+
const reportedObservedScore = inconclusive ? null : observedScore;
|
|
290
299
|
const findingsByCategory = Object.fromEntries(types_1.CATEGORIES.map((c) => [c, []]));
|
|
291
300
|
for (const f of findings)
|
|
292
301
|
findingsByCategory[f.category].push(f);
|
|
@@ -334,7 +343,7 @@ function buildReport(analysis, options) {
|
|
|
334
343
|
categoryScores,
|
|
335
344
|
profile: productProfile,
|
|
336
345
|
maturity: maturityLevel,
|
|
337
|
-
observedScore,
|
|
346
|
+
observedScore: reportedObservedScore,
|
|
338
347
|
overallScore,
|
|
339
348
|
inconclusive,
|
|
340
349
|
inconclusiveReasons,
|
|
@@ -365,7 +374,7 @@ function buildReport(analysis, options) {
|
|
|
365
374
|
return {
|
|
366
375
|
projectPath: analysis.projectPath,
|
|
367
376
|
generatedAt: new Date().toISOString(),
|
|
368
|
-
observedScore,
|
|
377
|
+
observedScore: reportedObservedScore,
|
|
369
378
|
expectedCapabilityScore: expectationScore,
|
|
370
379
|
overallScore,
|
|
371
380
|
maturityLevel,
|
|
@@ -2,7 +2,14 @@ import type { Category, Finding } from './types';
|
|
|
2
2
|
export interface CategoryScore {
|
|
3
3
|
category: Category;
|
|
4
4
|
/** 0-100. 100 means nothing actionable was found in this category. */
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `null` where nothing in the analysis touched this category.
|
|
7
|
+
*
|
|
8
|
+
* `notAssessed` has been beside it for a while and the number went on saying 100.
|
|
9
|
+
* On a report with no overall score at all, twelve categories still read 100 of 100
|
|
10
|
+
* — a repository the analyzer could not read, presented as perfect in twelve areas.
|
|
11
|
+
*/
|
|
12
|
+
score: number | null;
|
|
6
13
|
findingCount: number;
|
|
7
14
|
/** Checks that ran and found what they were looking for. */
|
|
8
15
|
verifiedCount: number;
|
|
@@ -103,9 +103,10 @@ function buildCategoryScores(findings) {
|
|
|
103
103
|
*/
|
|
104
104
|
const worstOpen = actionable.reduce((worst, finding) => (SEVERITY_WEIGHT[finding.severity] > SEVERITY_WEIGHT[worst] ? finding.severity : worst), 'info');
|
|
105
105
|
const ceiling = SEVERITY_CEILING[worstOpen];
|
|
106
|
+
const notAssessed = categoryFindings.every((finding) => finding.status === 'unknown');
|
|
106
107
|
return {
|
|
107
108
|
category,
|
|
108
|
-
score: Math.max(0, Math.min(100, proportional, ceiling)),
|
|
109
|
+
score: notAssessed ? null : Math.max(0, Math.min(100, proportional, ceiling)),
|
|
109
110
|
findingCount: actionable.length,
|
|
110
111
|
/** Checks in this category that ran and found what they were looking for. */
|
|
111
112
|
verifiedCount: assessed.filter((finding) => finding.status === 'passed').length,
|
|
@@ -122,7 +123,7 @@ function buildCategoryScores(findings) {
|
|
|
122
123
|
* A Django school platform with no payments anywhere was reported as having
|
|
123
124
|
* "no issues found in taking payments", as a strength.
|
|
124
125
|
*/
|
|
125
|
-
notAssessed
|
|
126
|
+
notAssessed,
|
|
126
127
|
};
|
|
127
128
|
});
|
|
128
129
|
}
|
|
@@ -130,6 +131,6 @@ function buildCategoryScores(findings) {
|
|
|
130
131
|
function weakestCategories(scores, limit = 3) {
|
|
131
132
|
return [...scores]
|
|
132
133
|
.filter((entry) => !entry.notAssessed && entry.findingCount > 0)
|
|
133
|
-
.sort((left, right) => left.score - right.score || right.criticalCount - left.criticalCount)
|
|
134
|
+
.sort((left, right) => (left.score ?? 100) - (right.score ?? 100) || right.criticalCount - left.criticalCount)
|
|
134
135
|
.slice(0, limit);
|
|
135
136
|
}
|
|
@@ -9,8 +9,28 @@ export interface ComplianceReference {
|
|
|
9
9
|
export interface ComplianceObligation extends ComplianceReference {
|
|
10
10
|
/** Findings that leave this obligation unmet. */
|
|
11
11
|
findingIds: string[];
|
|
12
|
+
/**
|
|
13
|
+
* `unknown` where every check behind this obligation came back `unknown`.
|
|
14
|
+
*
|
|
15
|
+
* This was a boolean, and `met` was the default: an obligation whose only supporting
|
|
16
|
+
* check could not reach a verdict was reported as satisfied. Measured across
|
|
17
|
+
* seventy-eight repositories, 84 of 163 obligations marked met rested on not one
|
|
18
|
+
* passing check — most of them OWASP A01, broken access control, declared met
|
|
19
|
+
* because a single check said it did not know.
|
|
20
|
+
*
|
|
21
|
+
* The comment on the builder already stated the principle and the code applied it
|
|
22
|
+
* only halfway: it omitted obligations that nothing mapped to, and marked met the
|
|
23
|
+
* ones that mapped to silence.
|
|
24
|
+
*/
|
|
25
|
+
status: 'met' | 'unmet' | 'unknown';
|
|
26
|
+
/** @deprecated Read `status`. True only where `status` is `met`. */
|
|
12
27
|
met: boolean;
|
|
13
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Exported so a test can ask the same question the builder asks, rather than keeping
|
|
31
|
+
* a second copy of the prefix table that would drift from this one.
|
|
32
|
+
*/
|
|
33
|
+
export declare function referencesFor(finding: Finding): ComplianceReference[];
|
|
14
34
|
/**
|
|
15
35
|
* Returns every obligation touched by the analysis, marking as unmet those with at
|
|
16
36
|
* least one actionable finding attached. An obligation nothing mapped to is omitted
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.referencesFor = referencesFor;
|
|
3
4
|
exports.buildComplianceMapping = buildComplianceMapping;
|
|
4
5
|
/**
|
|
5
6
|
* Maps findings onto the obligations a reader has to answer to.
|
|
@@ -82,6 +83,10 @@ const RULES = [
|
|
|
82
83
|
references: [{ framework: 'owasp-top-10', reference: 'A08', title: 'Software and data integrity failures' }],
|
|
83
84
|
},
|
|
84
85
|
];
|
|
86
|
+
/**
|
|
87
|
+
* Exported so a test can ask the same question the builder asks, rather than keeping
|
|
88
|
+
* a second copy of the prefix table that would drift from this one.
|
|
89
|
+
*/
|
|
85
90
|
function referencesFor(finding) {
|
|
86
91
|
for (const rule of RULES) {
|
|
87
92
|
if (finding.id.startsWith(rule.prefix))
|
|
@@ -108,15 +113,39 @@ function buildComplianceMapping(findings) {
|
|
|
108
113
|
obligations.set(key, {
|
|
109
114
|
...reference,
|
|
110
115
|
findingIds: actionable ? [finding.id] : [],
|
|
111
|
-
|
|
116
|
+
status: 'unknown',
|
|
117
|
+
met: false,
|
|
118
|
+
verified: finding.status === 'passed',
|
|
112
119
|
});
|
|
113
120
|
continue;
|
|
114
121
|
}
|
|
115
|
-
if (actionable)
|
|
122
|
+
if (actionable)
|
|
116
123
|
existing.findingIds.push(finding.id);
|
|
117
|
-
|
|
118
|
-
|
|
124
|
+
if (finding.status === 'passed')
|
|
125
|
+
existing.verified = true;
|
|
119
126
|
}
|
|
120
127
|
}
|
|
121
|
-
|
|
128
|
+
/**
|
|
129
|
+
* An obligation is met when something was checked and found right.
|
|
130
|
+
*
|
|
131
|
+
* Three outcomes, because the reader needs to tell them apart before quoting any of
|
|
132
|
+
* this to an auditor: something is wrong here, something was verified here, and
|
|
133
|
+
* nothing here reached a verdict.
|
|
134
|
+
*/
|
|
135
|
+
for (const obligation of obligations.values()) {
|
|
136
|
+
obligation.status = obligation.findingIds.length > 0
|
|
137
|
+
? 'unmet'
|
|
138
|
+
: obligation.verified ? 'met' : 'unknown';
|
|
139
|
+
obligation.met = obligation.status === 'met';
|
|
140
|
+
}
|
|
141
|
+
return [...obligations.values()]
|
|
142
|
+
.map((obligation) => ({
|
|
143
|
+
framework: obligation.framework,
|
|
144
|
+
reference: obligation.reference,
|
|
145
|
+
title: obligation.title,
|
|
146
|
+
findingIds: obligation.findingIds,
|
|
147
|
+
status: obligation.status,
|
|
148
|
+
met: obligation.met,
|
|
149
|
+
}))
|
|
150
|
+
.sort((left, right) => left.framework.localeCompare(right.framework) || left.reference.localeCompare(right.reference));
|
|
122
151
|
}
|
|
@@ -20,7 +20,7 @@ export declare function buildExecutiveSummary(args: {
|
|
|
20
20
|
categoryScores: CategoryScore[];
|
|
21
21
|
profile: ProductExpectationResult | undefined;
|
|
22
22
|
maturity: MaturityLevel;
|
|
23
|
-
observedScore: number;
|
|
23
|
+
observedScore: number | null;
|
|
24
24
|
overallScore: number | null;
|
|
25
25
|
inconclusive: boolean;
|
|
26
26
|
/** Why, when the report could not form a reading. Named in the verdict. */
|
|
@@ -158,7 +158,7 @@ function buildStrengths(categoryScores, findings) {
|
|
|
158
158
|
const passedCategories = new Set(passed.map((finding) => finding.category));
|
|
159
159
|
const strongCategories = categoryScores
|
|
160
160
|
.filter((entry) => !entry.notAssessed
|
|
161
|
-
&& entry.score >= 80
|
|
161
|
+
&& (entry.score ?? 0) >= 80
|
|
162
162
|
&& entry.findingCount === 0
|
|
163
163
|
&& passedCategories.has(entry.category));
|
|
164
164
|
/**
|
|
@@ -40,8 +40,10 @@ function profileSummary(report) {
|
|
|
40
40
|
`- Selected profile: ${profile.selectedProfile}`,
|
|
41
41
|
`- Profile: ${profile.profileTitle}`,
|
|
42
42
|
`- Description: ${profile.profileDescription}`,
|
|
43
|
-
`- Observed score: ${report.observedScore}/100`,
|
|
44
|
-
|
|
43
|
+
report.observedScore === null ? '- Observed score: not scored' : `- Observed score: ${report.observedScore}/100`,
|
|
44
|
+
report.expectedCapabilityScore === undefined
|
|
45
|
+
? '- Expected capability score: not measured'
|
|
46
|
+
: `- Expected capability score: ${report.expectedCapabilityScore}/100`,
|
|
45
47
|
report.overallScore === null ? '- Final score: not scored' : `- Final score: ${report.overallScore}/100`,
|
|
46
48
|
'- Capability summary:',
|
|
47
49
|
`- Required: ${requiredPresent} present / ${requiredMissing} missing / ${requiredPartial} partial`,
|
|
@@ -134,25 +136,47 @@ function categoryScoreSection(report) {
|
|
|
134
136
|
*/
|
|
135
137
|
'| Area | Score | Verified | Open findings | Critical |',
|
|
136
138
|
'| --- | --- | --- | --- | --- |',
|
|
137
|
-
...assessed.map((entry) => `| ${entry.category} | ${entry.score}/100 | ${entry.verifiedCount} of ${entry.assessedCount} | ${entry.findingCount} | ${entry.criticalCount} |`),
|
|
139
|
+
...assessed.map((entry) => `| ${entry.category} | ${entry.score === null ? 'not scored' : `${entry.score}/100`} | ${entry.verifiedCount} of ${entry.assessedCount} | ${entry.findingCount} | ${entry.criticalCount} |`),
|
|
138
140
|
'',
|
|
139
141
|
];
|
|
140
142
|
}
|
|
141
143
|
function complianceSection(report) {
|
|
142
144
|
if (report.compliance.length === 0)
|
|
143
145
|
return [];
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Exposure and silence, listed apart.
|
|
148
|
+
*
|
|
149
|
+
* This filtered on `!met`, which was two different things wearing one label: an
|
|
150
|
+
* obligation with findings against it, and an obligation whose every check came
|
|
151
|
+
* back unknown. The second was printed under "Compliance Exposure" with a findings
|
|
152
|
+
* count of zero — a reader would quote that to an auditor as a gap when it is the
|
|
153
|
+
* absence of an answer.
|
|
154
|
+
*/
|
|
155
|
+
const unmet = report.compliance.filter((obligation) => obligation.status === 'unmet');
|
|
156
|
+
const unassessed = report.compliance.filter((obligation) => obligation.status === 'unknown');
|
|
157
|
+
if (unmet.length === 0 && unassessed.length === 0)
|
|
146
158
|
return [];
|
|
147
159
|
return [
|
|
148
160
|
'## Compliance Exposure',
|
|
149
161
|
'',
|
|
150
162
|
'_Advisory mapping, not a compliance certification. Obligations nothing mapped to are omitted rather than reported as met._',
|
|
151
163
|
'',
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
...(unmet.length > 0
|
|
165
|
+
? [
|
|
166
|
+
'| Framework | Reference | Obligation | Findings |',
|
|
167
|
+
'| --- | --- | --- | --- |',
|
|
168
|
+
...unmet.map((obligation) => `| ${obligation.framework} | ${obligation.reference} | ${obligation.title} | ${obligation.findingIds.length} |`),
|
|
169
|
+
'',
|
|
170
|
+
]
|
|
171
|
+
: ['No obligation in this mapping has a finding against it.', '']),
|
|
172
|
+
...(unassessed.length > 0
|
|
173
|
+
? [
|
|
174
|
+
`Not assessed — every check behind these came back unknown, so this report says nothing either way about ${unassessed.length === 1 ? 'it' : 'them'}:`,
|
|
175
|
+
'',
|
|
176
|
+
...unassessed.map((obligation) => `- ${obligation.framework} ${obligation.reference}: ${obligation.title}`),
|
|
177
|
+
'',
|
|
178
|
+
]
|
|
179
|
+
: []),
|
|
156
180
|
];
|
|
157
181
|
}
|
|
158
182
|
function renderMarkdown(report) {
|
|
@@ -222,7 +246,7 @@ function renderMarkdown(report) {
|
|
|
222
246
|
'## Score',
|
|
223
247
|
'',
|
|
224
248
|
report.overallScore === null ? '- Overall score: not scored' : `- Overall score: ${report.overallScore}/100`,
|
|
225
|
-
`- Observed score: ${report.observedScore}/100`,
|
|
249
|
+
report.observedScore === null ? '- Observed score: not scored' : `- Observed score: ${report.observedScore}/100`,
|
|
226
250
|
...(report.expectedCapabilityScore !== undefined ? [`- Expected capability score: ${report.expectedCapabilityScore}/100`] : []),
|
|
227
251
|
`- Maturity level: ${report.maturityLevel}${report.inconclusive ? ' (inconclusive)' : ''}`,
|
|
228
252
|
/**
|
|
@@ -238,7 +262,7 @@ function renderMarkdown(report) {
|
|
|
238
262
|
: []),
|
|
239
263
|
...(report.inconclusive
|
|
240
264
|
? [
|
|
241
|
-
'- Assessment: INCONCLUSIVE —
|
|
265
|
+
'- Assessment: INCONCLUSIVE — this report has no score, for these reasons:',
|
|
242
266
|
...report.inconclusiveReasons.map((r) => ` - ${r}`),
|
|
243
267
|
]
|
|
244
268
|
: []),
|
package/dist/report/types.d.ts
CHANGED
|
@@ -88,7 +88,8 @@ export interface ReportDiagnostics {
|
|
|
88
88
|
export interface ProductionReadinessReport {
|
|
89
89
|
projectPath: string;
|
|
90
90
|
generatedAt: string;
|
|
91
|
-
|
|
91
|
+
/** `null` where the reading is inconclusive, for the same reason `overallScore` is. */
|
|
92
|
+
observedScore: number | null;
|
|
92
93
|
expectedCapabilityScore?: number;
|
|
93
94
|
/**
|
|
94
95
|
* `null` where the reading is inconclusive, which is the same rule this product
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.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": {
|