@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 CHANGED
@@ -1,6 +1,17 @@
1
- import type { Control, ComplianceScore, ScoreFile, FrameworkName } from "@greenarmor/ges-core";
2
- export declare function scoreControls(controls: Control[]): number;
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[]): ScoreFile;
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
- export function scoreControls(controls) {
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
- const passed = controls.filter(c => c.status === "pass" || c.status === "not-applicable").length;
5
- return Math.round((passed / controls.length) * 100);
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 total = fwControls.length;
12
- const passed = fwControls.filter(c => c.status === "pass").length;
13
- const failed = fwControls.filter(c => c.status === "fail").length;
14
- const warning = fwControls.filter(c => c.status === "warning").length;
15
- const notApplicable = fwControls.filter(c => c.status === "not-applicable").length;
16
- const score = total > 0 ? Math.round(((passed + notApplicable) / total) * 100) : 0;
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
- total_controls: total,
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
- const total = scores.reduce((sum, s) => sum + s.score, 0);
35
- return Math.round(total / scores.length);
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
- const overall = computeOverallScore(frameworkScores);
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
- lines.push(` ${fw} ${dots} ${data.score}%`);
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, "&amp;")
210
+ .replace(/</g, "&lt;")
211
+ .replace(/>/g, "&gt;")
212
+ .replace(/"/g, "&quot;");
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("&#10;");
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})&#10;${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 = `![GESF Compliance](${badgeSvgPath})`;
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.4",
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.3.4"
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
- "@types/node": "^22.0.0"
20
+ "vitest": "^4.1.8"
20
21
  },
21
22
  "scripts": {
22
23
  "build": "tsc",
23
24
  "clean": "rm -rf dist tsconfig.tsbuildinfo",
24
- "test": "echo \"no tests yet\""
25
+ "test": "vitest run"
25
26
  }
26
27
  }