@greenarmor/ges-scoring-engine 0.3.4 → 0.4.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/index.d.ts +14 -3
- package/dist/index.js +236 -19
- package/dist/index.test.d.ts +1 -0
- package/package.json +5 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import type { Control, ComplianceScore, ScoreFile, FrameworkName } from "@greenarmor/ges-core";
|
|
2
|
-
|
|
1
|
+
import type { Control, ComplianceScore, ComplianceGrade, ScoreFile, FrameworkName, AuditImpact, SeverityLevel } from "@greenarmor/ges-core";
|
|
2
|
+
declare const SEVERITY_WEIGHTS: Record<SeverityLevel, number>;
|
|
3
|
+
declare const STATUS_CREDIT: Record<string, number>;
|
|
4
|
+
declare const SEVERITY_PENALTY: Record<SeverityLevel, number>;
|
|
5
|
+
declare function computeGrade(score: number): ComplianceGrade;
|
|
3
6
|
export declare function scoreByFramework(controls: Control[], frameworks: FrameworkName[]): Record<string, ComplianceScore>;
|
|
7
|
+
export declare function computeAuditImpact(findings: {
|
|
8
|
+
severity: string;
|
|
9
|
+
}[]): AuditImpact;
|
|
4
10
|
export declare function computeOverallScore(frameworkScores: Record<string, ComplianceScore>): number;
|
|
5
|
-
export declare function generateScoreFile(controls: Control[], frameworks: FrameworkName[]
|
|
11
|
+
export declare function generateScoreFile(controls: Control[], frameworks: FrameworkName[], findings?: {
|
|
12
|
+
severity: string;
|
|
13
|
+
}[]): ScoreFile;
|
|
6
14
|
export declare function formatScoreOutput(score: ScoreFile): string;
|
|
15
|
+
export { SEVERITY_WEIGHTS, STATUS_CREDIT, SEVERITY_PENALTY, computeGrade };
|
|
16
|
+
export declare function generateBadgeSvg(score: ScoreFile): string;
|
|
17
|
+
export declare function injectBadgeIntoReadme(readmeContent: string, badgeSvgPath: string): string;
|
package/dist/index.js
CHANGED
|
@@ -1,59 +1,276 @@
|
|
|
1
|
-
|
|
1
|
+
const SEVERITY_WEIGHTS = {
|
|
2
|
+
critical: 10,
|
|
3
|
+
high: 7,
|
|
4
|
+
medium: 4,
|
|
5
|
+
low: 1,
|
|
6
|
+
};
|
|
7
|
+
const STATUS_CREDIT = {
|
|
8
|
+
pass: 1.0,
|
|
9
|
+
warning: 0.5,
|
|
10
|
+
fail: 0,
|
|
11
|
+
"not-implemented": 0,
|
|
12
|
+
"not-applicable": 1.0,
|
|
13
|
+
};
|
|
14
|
+
const SEVERITY_PENALTY = {
|
|
15
|
+
critical: 12,
|
|
16
|
+
high: 7,
|
|
17
|
+
medium: 4,
|
|
18
|
+
low: 1,
|
|
19
|
+
};
|
|
20
|
+
function computeGrade(score) {
|
|
21
|
+
if (score >= 90)
|
|
22
|
+
return "A";
|
|
23
|
+
if (score >= 80)
|
|
24
|
+
return "B";
|
|
25
|
+
if (score >= 65)
|
|
26
|
+
return "C";
|
|
27
|
+
if (score >= 50)
|
|
28
|
+
return "D";
|
|
29
|
+
return "F";
|
|
30
|
+
}
|
|
31
|
+
function emptySeverityBucket() {
|
|
32
|
+
return { total: 0, passed: 0, failed: 0, warning: 0, not_implemented: 0 };
|
|
33
|
+
}
|
|
34
|
+
function buildSeverityBreakdown(controls) {
|
|
35
|
+
const breakdown = {
|
|
36
|
+
critical: emptySeverityBucket(),
|
|
37
|
+
high: emptySeverityBucket(),
|
|
38
|
+
medium: emptySeverityBucket(),
|
|
39
|
+
low: emptySeverityBucket(),
|
|
40
|
+
};
|
|
41
|
+
for (const c of controls) {
|
|
42
|
+
const bucket = breakdown[c.severity];
|
|
43
|
+
bucket.total++;
|
|
44
|
+
if (c.status === "pass" || c.status === "not-applicable") {
|
|
45
|
+
bucket.passed++;
|
|
46
|
+
}
|
|
47
|
+
else if (c.status === "warning") {
|
|
48
|
+
bucket.warning++;
|
|
49
|
+
}
|
|
50
|
+
else if (c.status === "fail") {
|
|
51
|
+
bucket.failed++;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
bucket.not_implemented++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return breakdown;
|
|
58
|
+
}
|
|
59
|
+
function computeWeightedScore(controls) {
|
|
2
60
|
if (controls.length === 0)
|
|
3
|
-
return 0;
|
|
4
|
-
|
|
5
|
-
|
|
61
|
+
return { score: 0, maxPossible: 0 };
|
|
62
|
+
let earned = 0;
|
|
63
|
+
let maxPossible = 0;
|
|
64
|
+
for (const c of controls) {
|
|
65
|
+
const weight = SEVERITY_WEIGHTS[c.severity];
|
|
66
|
+
const credit = STATUS_CREDIT[c.status] ?? 0;
|
|
67
|
+
earned += weight * credit;
|
|
68
|
+
maxPossible += weight;
|
|
69
|
+
}
|
|
70
|
+
const score = maxPossible > 0 ? Math.round((earned / maxPossible) * 100) : 0;
|
|
71
|
+
return { score, maxPossible };
|
|
72
|
+
}
|
|
73
|
+
function countCriticalFailures(controls) {
|
|
74
|
+
return controls.filter((c) => c.severity === "critical" && (c.status === "fail" || c.status === "not-implemented")).length;
|
|
6
75
|
}
|
|
7
76
|
export function scoreByFramework(controls, frameworks) {
|
|
8
77
|
const result = {};
|
|
9
78
|
for (const fw of frameworks) {
|
|
10
|
-
const fwControls = controls.filter(c => c.framework === fw);
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
79
|
+
const fwControls = controls.filter((c) => c.framework === fw);
|
|
80
|
+
const { score, maxPossible } = computeWeightedScore(fwControls);
|
|
81
|
+
const breakdown = buildSeverityBreakdown(fwControls);
|
|
82
|
+
const passed = fwControls.filter((c) => c.status === "pass" || c.status === "not-applicable").length;
|
|
83
|
+
const failed = fwControls.filter((c) => c.status === "fail").length;
|
|
84
|
+
const warning = fwControls.filter((c) => c.status === "warning").length;
|
|
85
|
+
const notApplicable = fwControls.filter((c) => c.status === "not-applicable").length;
|
|
86
|
+
const notImplemented = fwControls.filter((c) => c.status === "not-implemented").length;
|
|
87
|
+
const criticalFailures = countCriticalFailures(fwControls);
|
|
88
|
+
let adjustedScore = score;
|
|
89
|
+
if (criticalFailures > 0 && adjustedScore > 0) {
|
|
90
|
+
const cap = Math.max(0, 75 - criticalFailures * 8);
|
|
91
|
+
adjustedScore = Math.min(adjustedScore, cap);
|
|
92
|
+
}
|
|
17
93
|
result[fw] = {
|
|
18
94
|
framework: fw,
|
|
19
|
-
score,
|
|
20
|
-
|
|
95
|
+
score: adjustedScore,
|
|
96
|
+
grade: computeGrade(adjustedScore),
|
|
97
|
+
total_controls: fwControls.length,
|
|
21
98
|
passed_controls: passed,
|
|
22
99
|
failed_controls: failed,
|
|
23
100
|
warning_controls: warning,
|
|
24
101
|
not_applicable: notApplicable,
|
|
102
|
+
not_implemented: notImplemented,
|
|
103
|
+
severity_breakdown: breakdown,
|
|
104
|
+
critical_failures: criticalFailures,
|
|
105
|
+
max_possible_score: maxPossible,
|
|
25
106
|
evaluated_at: new Date().toISOString(),
|
|
26
107
|
};
|
|
27
108
|
}
|
|
28
109
|
return result;
|
|
29
110
|
}
|
|
111
|
+
export function computeAuditImpact(findings) {
|
|
112
|
+
const critical = findings.filter((f) => f.severity === "critical").length;
|
|
113
|
+
const high = findings.filter((f) => f.severity === "high").length;
|
|
114
|
+
const medium = findings.filter((f) => f.severity === "medium").length;
|
|
115
|
+
const low = findings.filter((f) => f.severity === "low").length;
|
|
116
|
+
const totalDeduction = Math.min(100, critical * SEVERITY_PENALTY.critical +
|
|
117
|
+
high * SEVERITY_PENALTY.high +
|
|
118
|
+
medium * SEVERITY_PENALTY.medium +
|
|
119
|
+
low * SEVERITY_PENALTY.low);
|
|
120
|
+
return {
|
|
121
|
+
total_deduction: totalDeduction,
|
|
122
|
+
critical_findings: critical,
|
|
123
|
+
high_findings: high,
|
|
124
|
+
medium_findings: medium,
|
|
125
|
+
low_findings: low,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
30
128
|
export function computeOverallScore(frameworkScores) {
|
|
31
129
|
const scores = Object.values(frameworkScores);
|
|
32
130
|
if (scores.length === 0)
|
|
33
131
|
return 0;
|
|
34
|
-
|
|
35
|
-
|
|
132
|
+
let totalWeight = 0;
|
|
133
|
+
let weightedSum = 0;
|
|
134
|
+
for (const s of scores) {
|
|
135
|
+
const weight = Math.max(1, s.total_controls);
|
|
136
|
+
weightedSum += s.score * weight;
|
|
137
|
+
totalWeight += weight;
|
|
138
|
+
}
|
|
139
|
+
return totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 0;
|
|
36
140
|
}
|
|
37
|
-
export function generateScoreFile(controls, frameworks) {
|
|
141
|
+
export function generateScoreFile(controls, frameworks, findings) {
|
|
38
142
|
const frameworkScores = scoreByFramework(controls, frameworks);
|
|
39
|
-
|
|
143
|
+
let overall = computeOverallScore(frameworkScores);
|
|
144
|
+
let auditImpact;
|
|
145
|
+
if (findings && findings.length > 0) {
|
|
146
|
+
auditImpact = computeAuditImpact(findings);
|
|
147
|
+
overall = Math.max(0, overall - auditImpact.total_deduction);
|
|
148
|
+
}
|
|
40
149
|
return {
|
|
41
150
|
overall,
|
|
151
|
+
overall_grade: computeGrade(overall),
|
|
42
152
|
frameworks: frameworkScores,
|
|
153
|
+
audit_impact: auditImpact,
|
|
43
154
|
evaluated_at: new Date().toISOString(),
|
|
44
155
|
};
|
|
45
156
|
}
|
|
46
157
|
export function formatScoreOutput(score) {
|
|
47
158
|
const lines = [];
|
|
48
159
|
lines.push("");
|
|
160
|
+
lines.push(" ╔══════════════════════════════════════════════╗");
|
|
161
|
+
lines.push(" ║ COMPLIANCE SCORE REPORT ║");
|
|
162
|
+
lines.push(" ╚══════════════════════════════════════════════╝");
|
|
163
|
+
lines.push("");
|
|
49
164
|
for (const [fw, data] of Object.entries(score.frameworks)) {
|
|
50
165
|
const padding = Math.max(1, 20 - fw.length);
|
|
51
166
|
const dots = ".".repeat(padding);
|
|
52
|
-
|
|
167
|
+
const gradeTag = `[${data.grade}]`;
|
|
168
|
+
lines.push(` ${fw} ${dots} ${String(data.score).padStart(3)}% ${gradeTag}`);
|
|
169
|
+
if (data.critical_failures > 0) {
|
|
170
|
+
lines.push(` ⚠ ${data.critical_failures} critical control(s) failed`);
|
|
171
|
+
}
|
|
172
|
+
const sb = data.severity_breakdown;
|
|
173
|
+
const parts = [];
|
|
174
|
+
if (sb.critical.total > 0)
|
|
175
|
+
parts.push(`${sb.critical.passed}/${sb.critical.total} critical`);
|
|
176
|
+
if (sb.high.total > 0)
|
|
177
|
+
parts.push(`${sb.high.passed}/${sb.high.total} high`);
|
|
178
|
+
if (sb.medium.total > 0)
|
|
179
|
+
parts.push(`${sb.medium.passed}/${sb.medium.total} medium`);
|
|
180
|
+
if (sb.low.total > 0)
|
|
181
|
+
parts.push(`${sb.low.passed}/${sb.low.total} low`);
|
|
182
|
+
if (parts.length > 0)
|
|
183
|
+
lines.push(` ${parts.join(" · ")}`);
|
|
53
184
|
}
|
|
185
|
+
lines.push(" ──────────────────────────────────────────────");
|
|
54
186
|
const overallPadding = Math.max(1, 20 - "Overall".length);
|
|
55
187
|
const overallDots = ".".repeat(overallPadding);
|
|
56
|
-
lines.push(` Overall ${overallDots} ${score.overall}
|
|
188
|
+
lines.push(` Overall ${overallDots} ${String(score.overall).padStart(3)}% [${score.overall_grade}]`);
|
|
189
|
+
if (score.audit_impact) {
|
|
190
|
+
const ai = score.audit_impact;
|
|
191
|
+
lines.push("");
|
|
192
|
+
lines.push(" Audit Findings Impact:");
|
|
193
|
+
lines.push(` Critical: ${ai.critical_findings} · High: ${ai.high_findings} · Medium: ${ai.medium_findings} · Low: ${ai.low_findings}`);
|
|
194
|
+
lines.push(` Score deduction: -${ai.total_deduction}%`);
|
|
195
|
+
}
|
|
57
196
|
lines.push("");
|
|
58
197
|
return lines.join("\n");
|
|
59
198
|
}
|
|
199
|
+
export { SEVERITY_WEIGHTS, STATUS_CREDIT, SEVERITY_PENALTY, computeGrade };
|
|
200
|
+
const GRADE_COLORS = {
|
|
201
|
+
A: "#2ea44f",
|
|
202
|
+
B: "#84b6eb",
|
|
203
|
+
C: "#e3b341",
|
|
204
|
+
D: "#d29922",
|
|
205
|
+
F: "#cf222e",
|
|
206
|
+
};
|
|
207
|
+
function escapeXml(s) {
|
|
208
|
+
return s
|
|
209
|
+
.replace(/&/g, "&")
|
|
210
|
+
.replace(/</g, "<")
|
|
211
|
+
.replace(/>/g, ">")
|
|
212
|
+
.replace(/"/g, """);
|
|
213
|
+
}
|
|
214
|
+
function measureTextWidth(text) {
|
|
215
|
+
let w = 0;
|
|
216
|
+
for (const ch of text) {
|
|
217
|
+
if (ch >= "0" && ch <= "9")
|
|
218
|
+
w += 7;
|
|
219
|
+
else if (ch === " ")
|
|
220
|
+
w += 4;
|
|
221
|
+
else if (ch === "%")
|
|
222
|
+
w += 9;
|
|
223
|
+
else
|
|
224
|
+
w += 7.5;
|
|
225
|
+
}
|
|
226
|
+
return Math.ceil(w);
|
|
227
|
+
}
|
|
228
|
+
export function generateBadgeSvg(score) {
|
|
229
|
+
const scoreText = `${score.overall}%`;
|
|
230
|
+
const grade = score.overall_grade ?? computeGrade(score.overall);
|
|
231
|
+
const color = GRADE_COLORS[grade];
|
|
232
|
+
const leftText = "compliance";
|
|
233
|
+
const rightText = `${scoreText} (${grade})`;
|
|
234
|
+
const leftWidth = measureTextWidth(leftText) + 20;
|
|
235
|
+
const rightWidth = measureTextWidth(rightText) + 20;
|
|
236
|
+
const totalWidth = leftWidth + rightWidth;
|
|
237
|
+
const height = 20;
|
|
238
|
+
const fwLines = Object.entries(score.frameworks)
|
|
239
|
+
.map(([fw, data]) => `${fw}: ${data.score}% (${data.grade ?? computeGrade(data.score)})`)
|
|
240
|
+
.join(" ");
|
|
241
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="Compliance: ${scoreText} Grade ${grade}">
|
|
242
|
+
<title>Compliance Score: ${scoreText} (Grade ${grade}) ${fwLines}</title>
|
|
243
|
+
<linearGradient id="s" x2="0" y2="100%">
|
|
244
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
245
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
246
|
+
</linearGradient>
|
|
247
|
+
<clipPath id="r">
|
|
248
|
+
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
|
|
249
|
+
</clipPath>
|
|
250
|
+
<g clip-path="url(#r)">
|
|
251
|
+
<rect width="${leftWidth}" height="${height}" fill="#555"/>
|
|
252
|
+
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${color}"/>
|
|
253
|
+
<rect width="${totalWidth}" height="${height}" fill="url(#s)"/>
|
|
254
|
+
</g>
|
|
255
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
|
|
256
|
+
<text x="${Math.round(leftWidth / 2 * 10)}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(leftWidth - 12) * 10}" lengthAdjust="spacing">${escapeXml(leftText)}</text>
|
|
257
|
+
<text x="${Math.round(leftWidth / 2 * 10)}" y="140" transform="scale(.1)" textLength="${(leftWidth - 12) * 10}" lengthAdjust="spacing">${escapeXml(leftText)}</text>
|
|
258
|
+
<text x="${Math.round((leftWidth + rightWidth / 2) * 10)}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(rightWidth - 12) * 10}" lengthAdjust="spacing">${escapeXml(rightText)}</text>
|
|
259
|
+
<text x="${Math.round((leftWidth + rightWidth / 2) * 10)}" y="140" transform="scale(.1)" textLength="${(rightWidth - 12) * 10}" lengthAdjust="spacing">${escapeXml(rightText)}</text>
|
|
260
|
+
</g>
|
|
261
|
+
</svg>`;
|
|
262
|
+
}
|
|
263
|
+
export function injectBadgeIntoReadme(readmeContent, badgeSvgPath) {
|
|
264
|
+
const badgeLine = ``;
|
|
265
|
+
const existingPattern = /!\[GESF Compliance\]\([^)]*\)/;
|
|
266
|
+
if (existingPattern.test(readmeContent)) {
|
|
267
|
+
return readmeContent.replace(existingPattern, badgeLine);
|
|
268
|
+
}
|
|
269
|
+
const headingMatch = readmeContent.match(/^#\s+.+$/m);
|
|
270
|
+
if (headingMatch && headingMatch.index !== undefined) {
|
|
271
|
+
const afterHeading = headingMatch.index + headingMatch[0].length;
|
|
272
|
+
const insertion = `\n\n${badgeLine}`;
|
|
273
|
+
return readmeContent.slice(0, afterHeading) + insertion + readmeContent.slice(afterHeading);
|
|
274
|
+
}
|
|
275
|
+
return badgeLine + "\n\n" + readmeContent;
|
|
276
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@greenarmor/ges-scoring-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "GESF Scoring Engine - Compliance scoring across frameworks",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -12,15 +12,16 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@greenarmor/ges-core": "0.
|
|
15
|
+
"@greenarmor/ges-core": "0.4.0"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
18
19
|
"typescript": "^6.0.0",
|
|
19
|
-
"
|
|
20
|
+
"vitest": "^4.1.8"
|
|
20
21
|
},
|
|
21
22
|
"scripts": {
|
|
22
23
|
"build": "tsc",
|
|
23
24
|
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
24
|
-
"test": "
|
|
25
|
+
"test": "vitest run"
|
|
25
26
|
}
|
|
26
27
|
}
|