@greenarmor/ges-web-dashboard 1.1.7 → 1.2.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/template.js CHANGED
@@ -8,17 +8,16 @@ function gradeColor(grade) {
8
8
  default: return "#6b7280";
9
9
  }
10
10
  }
11
- function severityBadge(severity) {
11
+ function severityColor(severity) {
12
12
  const colors = {
13
13
  critical: "#ef4444",
14
14
  high: "#f97316",
15
15
  medium: "#eab308",
16
16
  low: "#3b82f6",
17
17
  };
18
- const color = colors[severity] || "#6b7280";
19
- return `<span style="background:${color};color:white;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${severity.toUpperCase()}</span>`;
18
+ return colors[severity] || "#6b7280";
20
19
  }
21
- function statusBadge(status) {
20
+ function statusColor(status) {
22
21
  const colors = {
23
22
  pass: "#22c55e",
24
23
  fail: "#ef4444",
@@ -26,6 +25,9 @@ function statusBadge(status) {
26
25
  "not-implemented": "#6b7280",
27
26
  "not-applicable": "#9ca3af",
28
27
  };
28
+ return colors[status] || "#6b7280";
29
+ }
30
+ function statusLabel(status) {
29
31
  const labels = {
30
32
  pass: "PASS",
31
33
  fail: "FAIL",
@@ -33,32 +35,7 @@ function statusBadge(status) {
33
35
  "not-implemented": "NOT IMPL",
34
36
  "not-applicable": "N/A",
35
37
  };
36
- const color = colors[status] || "#6b7280";
37
- const label = labels[status] || status.toUpperCase();
38
- return `<span style="background:${color};color:white;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${label}</span>`;
39
- }
40
- function scoreBar(score) {
41
- const color = score >= 80 ? "#22c55e" : score >= 60 ? "#eab308" : score >= 40 ? "#f97316" : "#ef4444";
42
- return `<div style="width:100%;background:#e5e7eb;border-radius:4px;height:8px;margin-top:4px;">
43
- <div style="width:${score}%;background:${color};height:8px;border-radius:4px;"></div>
44
- </div>`;
45
- }
46
- function donutSvg(passed, total) {
47
- const r = 54;
48
- const cx = 70;
49
- const cy = 70;
50
- const circumference = 2 * Math.PI * r;
51
- const pct = total > 0 ? passed / total : 0;
52
- const offset = circumference * (1 - pct);
53
- const color = pct >= 0.8 ? "#22c55e" : pct >= 0.6 ? "#eab308" : pct >= 0.4 ? "#f97316" : "#ef4444";
54
- return `<svg width="140" height="140" viewBox="0 0 140 140">
55
- <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/>
56
- <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="12"
57
- stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"
58
- stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
59
- <text x="${cx}" y="${cy - 4}" text-anchor="middle" font-size="28" font-weight="700" fill="#1f2937">${Math.round(pct * 100)}%</text>
60
- <text x="${cx}" y="${cy + 16}" text-anchor="middle" font-size="11" fill="#6b7280">${passed}/${total} passed</text>
61
- </svg>`;
38
+ return labels[status] || status.toUpperCase();
62
39
  }
63
40
  export function renderDashboard(data) {
64
41
  const score = data.score;
@@ -67,6 +44,7 @@ export function renderDashboard(data) {
67
44
  const frameworks = score?.frameworks || {};
68
45
  const findings = data.findings;
69
46
  const controls = data.controls;
47
+ const packs = data.packs;
70
48
  const findingsBySeverity = {
71
49
  critical: findings.filter(f => f.severity === "critical").length,
72
50
  high: findings.filter(f => f.severity === "high").length,
@@ -82,6 +60,52 @@ export function renderDashboard(data) {
82
60
  };
83
61
  const frameworkKeys = Object.keys(frameworks);
84
62
  const missingControls = controls.filter(c => c.status !== "pass" && c.status !== "not-applicable");
63
+ const findingsByPackId = {};
64
+ for (const f of findings) {
65
+ for (const cid of f.controlIds) {
66
+ const pack = packs.find(p => {
67
+ const pControls = getAllControlsForPack(p.id, controls);
68
+ return pControls.some(c => c.id === cid);
69
+ });
70
+ if (pack) {
71
+ if (!findingsByPackId[pack.id])
72
+ findingsByPackId[pack.id] = [];
73
+ if (!findingsByPackId[pack.id].includes(f))
74
+ findingsByPackId[pack.id].push(f);
75
+ }
76
+ }
77
+ }
78
+ function getAllControlsForPack(packId, allControls) {
79
+ return allControls.filter(c => {
80
+ const idUpper = c.id.toUpperCase();
81
+ const packPrefix = packId.toUpperCase().replace(/-/g, "");
82
+ if (packId === "gdpr")
83
+ return idUpper.startsWith("GDPR-");
84
+ if (packId === "owasp")
85
+ return idUpper.startsWith("OWASP-");
86
+ if (packId === "cis")
87
+ return idUpper.startsWith("CIS-");
88
+ if (packId === "nist")
89
+ return idUpper.startsWith("NIST-");
90
+ if (packId === "ai")
91
+ return idUpper.startsWith("AI-");
92
+ if (packId === "blockchain")
93
+ return idUpper.startsWith("BC-");
94
+ if (packId === "government")
95
+ return idUpper.startsWith("GOV-");
96
+ if (packId === "iso27001")
97
+ return idUpper.startsWith("ISO27K-");
98
+ if (packId === "iso27701")
99
+ return idUpper.startsWith("ISO277-");
100
+ if (packId === "hipaa")
101
+ return idUpper.startsWith("HIPAA-");
102
+ return false;
103
+ });
104
+ }
105
+ const packJson = JSON.stringify(packs.map(p => ({
106
+ ...p,
107
+ _controls: undefined,
108
+ })));
85
109
  return `<!DOCTYPE html>
86
110
  <html lang="en">
87
111
  <head>
@@ -91,177 +115,901 @@ export function renderDashboard(data) {
91
115
  <style>
92
116
  * { margin: 0; padding: 0; box-sizing: border-box; }
93
117
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; background: #f3f4f6; color: #1f2937; line-height: 1.6; }
94
- .header { background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%); color: white; padding: 24px 32px; }
118
+ .header { background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%); color: white; padding: 24px 32px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
95
119
  .header h1 { font-size: 24px; font-weight: 700; }
96
120
  .header .subtitle { font-size: 14px; opacity: 0.9; margin-top: 4px; }
97
- .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
121
+ .nav-tabs { display: flex; gap: 4px; }
122
+ .nav-tab { background: rgba(255,255,255,0.15); color: white; border: none; padding: 8px 18px; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 13px; font-weight: 600; transition: background 0.2s; }
123
+ .nav-tab:hover { background: rgba(255,255,255,0.25); }
124
+ .nav-tab.active { background: #f3f4f6; color: #0f766e; }
125
+ .container { max-width: 1400px; margin: 0 auto; padding: 24px; }
126
+ .page { display: none; }
127
+ .page.active { display: block; }
98
128
  .grid { display: grid; gap: 20px; }
99
129
  .grid-2 { grid-template-columns: 1fr 1fr; }
100
130
  .grid-3 { grid-template-columns: repeat(3, 1fr); }
101
131
  .grid-4 { grid-template-columns: repeat(4, 1fr); }
102
- @media (max-width: 768px) { .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; } }
132
+ @media (max-width: 900px) { .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; } }
103
133
  .card { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
104
134
  .card-title { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; margin-bottom: 12px; }
105
135
  .big-number { font-size: 42px; font-weight: 700; }
106
- .flex-center { display: flex; align-items: center; justify-content: center; }
107
136
  .stat { text-align: center; }
108
137
  .stat .num { font-size: 32px; font-weight: 700; }
109
138
  .stat .label { font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }
110
139
  table { width: 100%; border-collapse: collapse; font-size: 13px; }
111
140
  th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #e5e7eb; font-weight: 600; color: #6b7280; font-size: 11px; text-transform: uppercase; }
112
- td { padding: 10px 8px; border-bottom: 1px solid #f3f4f6; }
141
+ td { padding: 10px 8px; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
113
142
  tr:hover td { background: #f9fafb; }
114
- .badge-row { display: flex; gap: 6px; flex-wrap: wrap; }
115
143
  .framework-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #f3f4f6; }
116
144
  .framework-row:last-child { border-bottom: none; }
117
145
  .framework-name { font-size: 14px; font-weight: 600; min-width: 140px; }
118
146
  .footer { text-align: center; padding: 24px; color: #9ca3af; font-size: 12px; }
119
- a { color: #0f766e; text-decoration: none; }
120
- a:hover { text-decoration: underline; }
147
+ a, .link { color: #0f766e; text-decoration: none; cursor: pointer; }
148
+ a:hover, .link:hover { text-decoration: underline; }
121
149
  .pct-text { font-size: 13px; font-weight: 600; min-width: 50px; text-align: right; }
122
150
  .bar-container { flex: 1; margin: 0 12px; }
123
151
  .tag { display: inline-block; background: #e0f2fe; color: #0369a1; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; margin: 2px; }
152
+ .badge { display: inline-block; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
153
+ .badge-sev { min-width: 60px; text-align: center; }
154
+ .badge-status { min-width: 70px; text-align: center; }
155
+
156
+ .score-bar-wrap { width: 100%; background: #e5e7eb; border-radius: 4px; height: 8px; margin-top: 4px; }
157
+ .score-bar-fill { height: 8px; border-radius: 4px; transition: width 0.4s; }
158
+
159
+ .pack-card { cursor: pointer; border: 2px solid transparent; transition: border-color 0.2s, box-shadow 0.2s; }
160
+ .pack-card:hover { border-color: #14b8a6; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
161
+ .pack-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
162
+ .pack-name { font-size: 16px; font-weight: 700; color: #1f2937; }
163
+ .pack-score { font-size: 24px; font-weight: 700; }
164
+ .pack-desc { font-size: 13px; color: #6b7280; margin-bottom: 12px; line-height: 1.5; }
165
+ .pack-stats { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: #4b5563; }
166
+ .pack-stats span { display: flex; align-items: center; gap: 4px; }
167
+
168
+ .detail-back { display: inline-flex; align-items: center; gap: 6px; color: #0f766e; font-size: 14px; font-weight: 600; cursor: pointer; margin-bottom: 16px; }
169
+ .detail-back:hover { text-decoration: underline; }
170
+ .detail-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; }
171
+ .detail-title { font-size: 22px; font-weight: 700; }
172
+ .detail-meta { font-size: 13px; color: #6b7280; }
173
+
174
+ .control-row { cursor: pointer; }
175
+ .control-row:hover td { background: #ecfdf5; }
176
+
177
+ .findings-trace { margin-top: 8px; }
178
+ .trace-item { background: #fef2f2; border-left: 3px solid #ef4444; padding: 10px 14px; border-radius: 0 6px 6px 0; margin-bottom: 8px; font-size: 13px; }
179
+ .trace-item.high { background: #fff7ed; border-left-color: #f97316; }
180
+ .trace-item.medium { background: #fefce8; border-left-color: #eab308; }
181
+ .trace-item.low { background: #eff6ff; border-left-color: #3b82f6; }
182
+ .trace-title { font-weight: 600; margin-bottom: 4px; }
183
+ .trace-detail { color: #6b7280; font-size: 12px; }
184
+ .trace-fix { background: #f0fdf4; border-left: 3px solid #22c55e; padding: 10px 14px; border-radius: 0 6px 6px 0; margin: 6px 0 12px 6px; font-size: 13px; }
185
+ .trace-fix-label { font-weight: 600; color: #166534; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
186
+
187
+ .guidance-box { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 16px; margin-top: 12px; }
188
+ .guidance-box h4 { font-size: 13px; color: #166534; margin-bottom: 8px; }
189
+ .guidance-box p { font-size: 13px; color: #374151; line-height: 1.6; }
190
+
191
+ .check-list { list-style: none; padding: 0; }
192
+ .check-list li { padding: 6px 0; border-bottom: 1px solid #f3f4f6; display: flex; align-items: center; gap: 8px; font-size: 13px; }
193
+ .check-list li:last-child { border-bottom: none; }
194
+ .check-icon { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: white; font-size: 10px; font-weight: 700; }
195
+
196
+ .breadcrumb { font-size: 13px; color: #6b7280; margin-bottom: 16px; }
197
+ .breadcrumb span { color: #0f766e; cursor: pointer; }
198
+ .breadcrumb span:hover { text-decoration: underline; }
199
+
200
+ .fix-detail-card { background: white; border-radius: 12px; padding: 0; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 16px; overflow: hidden; border: 1px solid #e5e7eb; }
201
+ .fix-detail-header { display: flex; align-items: center; gap: 12px; padding: 16px 20px; cursor: pointer; }
202
+ .fix-detail-header:hover { background: #f9fafb; }
203
+ .fix-detail-header.critical { border-left: 4px solid #ef4444; }
204
+ .fix-detail-header.high { border-left: 4px solid #f97316; }
205
+ .fix-detail-header.medium { border-left: 4px solid #eab308; }
206
+ .fix-detail-header.low { border-left: 4px solid #3b82f6; }
207
+ .fix-detail-num { font-size: 24px; font-weight: 700; min-width: 40px; text-align: center; }
208
+ .fix-detail-info { flex: 1; }
209
+ .fix-detail-title { font-size: 15px; font-weight: 700; color: #1f2937; }
210
+ .fix-detail-meta { font-size: 12px; color: #6b7280; margin-top: 3px; }
211
+ .fix-detail-badges { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
212
+ .fix-detail-body { display: none; padding: 0 20px 20px; border-top: 1px solid #f3f4f6; }
213
+ .fix-detail-body.open { display: block; }
214
+ .fix-section { margin-top: 16px; }
215
+ .fix-section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: #374151; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; }
216
+ .fix-finding-item { background: #fef2f2; border-left: 3px solid #ef4444; padding: 10px 14px; border-radius: 0 6px 6px 0; margin-bottom: 8px; }
217
+ .fix-finding-item.high { background: #fff7ed; border-left-color: #f97316; }
218
+ .fix-finding-item.medium { background: #fefce8; border-left-color: #eab308; }
219
+ .fix-finding-item.low { background: #eff6ff; border-left-color: #3b82f6; }
220
+ .fix-guidance-box { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 14px 16px; font-size: 13px; color: #374151; line-height: 1.7; }
221
+ .fix-guidance-box strong { color: #166534; }
222
+ .fix-checks-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
223
+ @media (max-width: 768px) { .fix-checks-grid { grid-template-columns: 1fr; } }
224
+ .fix-check-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; font-size: 13px; background: #f9fafb; }
225
+ .fix-check-pass { background: #f0fdf4; }
226
+ .fix-check-fail { background: #fef2f2; }
227
+ .fix-check-icon { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: white; font-size: 10px; font-weight: 700; flex-shrink: 0; }
228
+ .fix-toggle { font-size: 12px; color: #0f766e; cursor: pointer; font-weight: 600; }
229
+ .fix-toggle:hover { text-decoration: underline; }
230
+ .fix-evidence { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 12px; font-family: monospace; font-size: 11px; color: #4b5563; margin-top: 4px; white-space: pre-wrap; word-break: break-all; }
231
+
232
+ .tab-bar { display: flex; gap: 2px; border-bottom: 2px solid #e5e7eb; margin-bottom: 16px; }
233
+ .tab-btn { padding: 8px 16px; font-size: 13px; font-weight: 600; color: #6b7280; background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; margin-bottom: -2px; transition: color 0.2s, border-color 0.2s; }
234
+ .tab-btn:hover { color: #0f766e; }
235
+ .tab-btn.active { color: #0f766e; border-bottom-color: #0f766e; }
236
+ .tab-panel { display: none; }
237
+ .tab-panel.active { display: block; }
238
+
239
+ .empty-state { text-align: center; padding: 40px 20px; color: #9ca3af; }
240
+ .empty-state .icon { font-size: 48px; margin-bottom: 12px; }
241
+ .empty-state .msg { font-size: 15px; font-weight: 600; }
242
+ .empty-state .sub { font-size: 13px; margin-top: 4px; }
243
+
244
+ @media (max-width: 768px) {
245
+ .header { padding: 16px; }
246
+ .container { padding: 16px; }
247
+ .nav-tabs { width: 100%; overflow-x: auto; }
248
+ }
124
249
  </style>
125
250
  </head>
126
251
  <body>
127
252
 
128
253
  <div class="header">
129
- <h1>GESF Compliance Dashboard</h1>
130
- <div class="subtitle">${escapeHtml(data.projectName)} | ${escapeHtml(data.projectType)} | GESF v${escapeHtml(data.gesfVersion)}</div>
254
+ <div>
255
+ <h1>GESF Compliance Dashboard</h1>
256
+ <div class="subtitle">${escapeHtml(data.projectName)} | ${escapeHtml(data.projectType)} | GESF v${escapeHtml(data.gesfVersion)}</div>
257
+ </div>
258
+ <div class="nav-tabs">
259
+ <button class="nav-tab active" onclick="showPage('overview')">Overview</button>
260
+ <button class="nav-tab" onclick="showPage('packs')">Policy Packs</button>
261
+ <button class="nav-tab" onclick="showPage('fixes')">Fixes Detail</button>
262
+ <button class="nav-tab" onclick="showPage('findings')">Findings</button>
263
+ <button class="nav-tab" onclick="showPage('traceability')">Traceability</button>
264
+ </div>
131
265
  </div>
132
266
 
133
267
  <div class="container">
134
- <div class="grid">
135
268
 
136
- <div class="grid grid-3">
137
- <div class="card stat">
138
- ${donutSvg(score ? Object.values(frameworks).reduce((n, f) => n + f.passed_controls, 0) : 0, controls.length || 1)}
139
- <div class="label">Overall Compliance</div>
140
- </div>
141
- <div class="card">
142
- <div class="card-title">Overall Score</div>
143
- <div class="big-number" style="color:${gradeColor(overallGrade)};">${overall}%</div>
144
- <div style="margin-top:4px;"><span style="background:${gradeColor(overallGrade)};color:white;padding:4px 16px;border-radius:6px;font-weight:700;">Grade: ${overallGrade}</span></div>
145
- ${scoreBar(overall)}
146
- </div>
147
- <div class="card">
148
- <div class="card-title">Security Findings</div>
149
- <div class="big-number" style="color:${findings.length > 0 ? '#ef4444' : '#22c55e'};">${findings.length}</div>
150
- <div style="margin-top:8px;font-size:13px;color:#6b7280;">
151
- <span style="color:#ef4444;font-weight:600;">${findingsBySeverity.critical} critical</span> |
152
- <span style="color:#f97316;font-weight:600;">${findingsBySeverity.high} high</span> |
153
- <span style="color:#eab308;font-weight:600;">${findingsBySeverity.medium} medium</span> |
154
- <span style="color:#3b82f6;font-weight:600;">${findingsBySeverity.low} low</span>
269
+ <div id="page-overview" class="page active">
270
+ <div class="grid">
271
+ <div class="grid grid-3">
272
+ <div class="card stat">
273
+ ${donutSvg(score ? Object.values(frameworks).reduce((n, f) => n + f.passed_controls, 0) : 0, controls.length || 1)}
274
+ <div class="label">Overall Compliance</div>
275
+ </div>
276
+ <div class="card">
277
+ <div class="card-title">Overall Score</div>
278
+ <div class="big-number" style="color:${gradeColor(overallGrade)};">${overall}%</div>
279
+ <div style="margin-top:4px;"><span class="badge" style="background:${gradeColor(overallGrade)};padding:4px 16px;border-radius:6px;font-size:14px;">Grade: ${overallGrade}</span></div>
280
+ ${scoreBarHtml(overall)}
281
+ </div>
282
+ <div class="card">
283
+ <div class="card-title">Security Findings</div>
284
+ <div class="big-number" style="color:${findings.length > 0 ? '#ef4444' : '#22c55e'};">${findings.length}</div>
285
+ <div style="margin-top:8px;font-size:13px;color:#6b7280;">
286
+ <span style="color:#ef4444;font-weight:600;">${findingsBySeverity.critical} critical</span> |
287
+ <span style="color:#f97316;font-weight:600;">${findingsBySeverity.high} high</span> |
288
+ <span style="color:#eab308;font-weight:600;">${findingsBySeverity.medium} medium</span> |
289
+ <span style="color:#3b82f6;font-weight:600;">${findingsBySeverity.low} low</span>
290
+ </div>
155
291
  </div>
156
292
  </div>
157
- </div>
158
-
159
- <div class="grid grid-2">
160
293
 
161
- <div class="card">
162
- <div class="card-title">Framework Scores</div>
163
- ${frameworkKeys.length > 0 ? frameworkKeys.map(fw => {
294
+ <div class="grid grid-2">
295
+ <div class="card">
296
+ <div class="card-title">Framework Scores</div>
297
+ ${frameworkKeys.length > 0 ? frameworkKeys.map(fw => {
164
298
  const f = frameworks[fw];
165
299
  const pct = f.score;
166
300
  const color = pct >= 80 ? '#22c55e' : pct >= 60 ? '#eab308' : pct >= 40 ? '#f97316' : '#ef4444';
167
301
  return `<div class="framework-row">
168
- <div class="framework-name">${escapeHtml(fw)}</div>
169
- <div class="bar-container">${scoreBar(pct)}</div>
170
- <div class="pct-text" style="color:${color};">${pct}% (${f.grade})</div>
171
- </div>`;
172
- }).join('') : '<div style="padding:20px;text-align:center;color:#9ca3af;">No framework data. Run "ges score".</div>'}
302
+ <div class="framework-name">${escapeHtml(fw)}</div>
303
+ <div class="bar-container">${scoreBarHtml(pct)}</div>
304
+ <div class="pct-text" style="color:${color};">${pct}% (${f.grade})</div>
305
+ </div>`;
306
+ }).join('') : '<div class="empty-state"><div class="msg">No framework data</div><div class="sub">Run "ges score" to generate scores</div></div>'}
307
+ </div>
308
+
309
+ <div class="card">
310
+ <div class="card-title">Control Status Breakdown</div>
311
+ <div class="grid grid-3" style="gap:12px;">
312
+ <div class="stat"><div class="num" style="color:#22c55e;">${controlsByStatus.pass}</div><div class="label">Pass</div></div>
313
+ <div class="stat"><div class="num" style="color:#ef4444;">${controlsByStatus.fail}</div><div class="label">Fail</div></div>
314
+ <div class="stat"><div class="num" style="color:#eab308;">${controlsByStatus.warning}</div><div class="label">Warning</div></div>
315
+ <div class="stat"><div class="num" style="color:#6b7280;">${controlsByStatus["not-implemented"]}</div><div class="label">Not Impl</div></div>
316
+ <div class="stat"><div class="num" style="color:#9ca3af;">${controlsByStatus["not-applicable"]}</div><div class="label">N/A</div></div>
317
+ <div class="stat"><div class="num">${controls.length}</div><div class="label">Total</div></div>
318
+ </div>
319
+ </div>
320
+ </div>
321
+
322
+ <div class="grid grid-2">
323
+ <div class="card">
324
+ <div class="card-title">Security Findings Detail (${findings.length})</div>
325
+ ${findings.length > 0 ? `<table>
326
+ <thead><tr><th>Severity</th><th>Rule</th><th>File</th><th>Issue</th><th>Controls</th></tr></thead>
327
+ <tbody>
328
+ ${findings.slice(0, 20).map(f => `<tr>
329
+ <td><span class="badge badge-sev" style="background:${severityColor(f.severity)}">${f.severity.toUpperCase()}</span></td>
330
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.ruleId)}</td>
331
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</td>
332
+ <td>${escapeHtml(f.title)}</td>
333
+ <td>${f.controlIds.length > 0 ? f.controlIds.map(cid => `<span class="tag" style="cursor:pointer;" onclick="showControlDetail('${escapeHtml(cid)}')">${escapeHtml(cid)}</span>`).join(' ') : '<span style="color:#9ca3af;">-</span>'}</td>
334
+ </tr>`).join('')}
335
+ </tbody>
336
+ </table>
337
+ ${findings.length > 20 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">... and ${findings.length - 20} more findings</div>` : ''}` : '<div class="empty-state"><div class="icon">&#10003;</div><div class="msg" style="color:#22c55e;">No security findings</div><div class="sub">Project is clean</div></div>'}
338
+ </div>
339
+
340
+ <div class="card">
341
+ <div class="card-title">Missing Controls (${missingControls.length})</div>
342
+ ${missingControls.length > 0 ? `<table>
343
+ <thead><tr><th>ID</th><th>Name</th><th>Severity</th><th>Status</th><th>Findings</th></tr></thead>
344
+ <tbody>
345
+ ${missingControls.slice(0, 20).map(c => {
346
+ const ctrlFindings = findings.filter(f => f.controlIds.includes(c.id));
347
+ return `<tr class="control-row" onclick="showControlDetail('${escapeHtml(c.id)}')">
348
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(c.id)}</td>
349
+ <td>${escapeHtml(c.name)}</td>
350
+ <td><span class="badge badge-sev" style="background:${severityColor(c.severity)}">${c.severity.toUpperCase()}</span></td>
351
+ <td><span class="badge badge-status" style="background:${statusColor(c.status)}">${statusLabel(c.status)}</span></td>
352
+ <td>${ctrlFindings.length > 0 ? `<span style="color:#ef4444;font-weight:600;">${ctrlFindings.length}</span>` : '<span style="color:#9ca3af;">0</span>'}</td>
353
+ </tr>`;
354
+ }).join('')}
355
+ </tbody>
356
+ </table>
357
+ ${missingControls.length > 20 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">... and ${missingControls.length - 20} more</div>` : ''}` : '<div class="empty-state"><div class="icon">&#10003;</div><div class="msg" style="color:#22c55e;">All controls passing</div></div>'}
358
+ </div>
173
359
  </div>
174
360
 
175
361
  <div class="card">
176
- <div class="card-title">Control Status Breakdown</div>
177
- <div class="grid grid-3" style="gap:12px;">
178
- <div class="stat">
179
- <div class="num" style="color:#22c55e;">${controlsByStatus.pass}</div>
180
- <div class="label">Pass</div>
181
- </div>
182
- <div class="stat">
183
- <div class="num" style="color:#ef4444;">${controlsByStatus.fail}</div>
184
- <div class="label">Fail</div>
185
- </div>
186
- <div class="stat">
187
- <div class="num" style="color:#eab308;">${controlsByStatus.warning}</div>
188
- <div class="label">Warning</div>
189
- </div>
190
- <div class="stat">
191
- <div class="num" style="color:#6b7280;">${controlsByStatus["not-implemented"]}</div>
192
- <div class="label">Not Impl</div>
362
+ <div class="card-title">Active Frameworks</div>
363
+ <div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;">
364
+ ${data.frameworks.map(fw => `<span class="tag" style="background:#d1fae5;color:#065f46;">${escapeHtml(fw)}</span>`).join('') || '<span style="color:#9ca3af;">No frameworks configured</span>'}
365
+ </div>
366
+ </div>
367
+ </div>
368
+ </div>
369
+
370
+ <div id="page-packs" class="page">
371
+ <div id="packs-list">
372
+ <h2 style="font-size:20px;font-weight:700;margin-bottom:16px;">Policy Packs &mdash; Detailed Reports</h2>
373
+ <p style="color:#6b7280;font-size:14px;margin-bottom:20px;">Click any pack to drill down into controls, findings, and fix guidance.</p>
374
+ <div class="grid grid-2">
375
+ ${packs.map(p => {
376
+ const pct = p.score;
377
+ const color = pct >= 80 ? '#22c55e' : pct >= 60 ? '#eab308' : pct >= 40 ? '#f97316' : '#ef4444';
378
+ return `<div class="card pack-card" onclick="loadPackDetail('${p.id}')">
379
+ <div class="pack-header">
380
+ <div>
381
+ <div class="pack-name">${escapeHtml(p.name)}</div>
382
+ <div style="font-size:12px;color:#6b7280;margin-top:2px;">${escapeHtml(p.id)} v${escapeHtml(p.version)}</div>
383
+ </div>
384
+ <div class="pack-score" style="color:${color};">${pct}%</div>
193
385
  </div>
194
- <div class="stat">
195
- <div class="num" style="color:#9ca3af;">${controlsByStatus["not-applicable"]}</div>
196
- <div class="label">N/A</div>
386
+ <div class="pack-desc">${escapeHtml(p.description)}</div>
387
+ ${scoreBarHtml(pct)}
388
+ <div class="pack-stats" style="margin-top:12px;">
389
+ <span><span class="badge badge-status" style="background:#22c55e;">${p.passedCount}</span> pass</span>
390
+ <span><span class="badge badge-status" style="background:#ef4444;">${p.failedCount}</span> fail</span>
391
+ <span><span class="badge badge-status" style="background:#eab308;">${p.warningCount}</span> warn</span>
392
+ <span><span class="badge badge-status" style="background:#6b7280;">${p.notImplementedCount}</span> not impl</span>
393
+ <span style="color:#ef4444;font-weight:600;">${p.findingsCount} findings</span>
394
+ <span>${p.controlCount} controls</span>
395
+ ${p.installed ? '<span style="color:#0f766e;font-weight:600;">Installed</span>' : '<span style="color:#9ca3af;">Not installed</span>'}
197
396
  </div>
198
- <div class="stat">
199
- <div class="num">${controls.length}</div>
200
- <div class="label">Total</div>
397
+ </div>`;
398
+ }).join('')}
399
+ </div>
400
+ </div>
401
+ <div id="pack-detail" style="display:none;"></div>
402
+ </div>
403
+
404
+ <div id="page-fixes" class="page">
405
+ ${renderDetailedFixesList(findings, controls, packs)}
406
+ </div>
407
+
408
+ <div id="page-findings" class="page">
409
+ <div id="findings-main">
410
+ <h2 style="font-size:20px;font-weight:700;margin-bottom:16px;">Security Findings Report</h2>
411
+ <div class="tab-bar">
412
+ <button class="tab-btn active" onclick="showFindingsTab('all')">All (${findings.length})</button>
413
+ <button class="tab-btn" onclick="showFindingsTab('critical')">Critical (${findingsBySeverity.critical})</button>
414
+ <button class="tab-btn" onclick="showFindingsTab('high')">High (${findingsBySeverity.high})</button>
415
+ <button class="tab-btn" onclick="showFindingsTab('medium')">Medium (${findingsBySeverity.medium})</button>
416
+ <button class="tab-btn" onclick="showFindingsTab('low')">Low (${findingsBySeverity.low})</button>
417
+ <button class="tab-btn" onclick="showFindingsTab('bypack')">By Pack</button>
418
+ </div>
419
+
420
+ <div id="findings-tab-all" class="tab-panel active">
421
+ ${renderFindingsTable(findings)}
422
+ </div>
423
+ <div id="findings-tab-critical" class="tab-panel">${renderFindingsTable(findings.filter(f => f.severity === "critical"))}</div>
424
+ <div id="findings-tab-high" class="tab-panel">${renderFindingsTable(findings.filter(f => f.severity === "high"))}</div>
425
+ <div id="findings-tab-medium" class="tab-panel">${renderFindingsTable(findings.filter(f => f.severity === "medium"))}</div>
426
+ <div id="findings-tab-low" class="tab-panel">${renderFindingsTable(findings.filter(f => f.severity === "low"))}</div>
427
+ <div id="findings-tab-bypack" class="tab-panel">
428
+ ${packs.filter(p => (findingsByPackId[p.id] || []).length > 0).length > 0 ? packs.filter(p => (findingsByPackId[p.id] || []).length > 0).map(p => `
429
+ <div class="card" style="margin-bottom:16px;">
430
+ <div class="card-title" style="cursor:pointer;" onclick="loadPackDetail('${p.id}')">
431
+ ${escapeHtml(p.name)} &mdash; ${(findingsByPackId[p.id] || []).length} findings
432
+ <span style="float:right;color:#0f766e;font-weight:400;font-size:11px;">View pack details &rarr;</span>
433
+ </div>
434
+ ${renderFindingsTable(findingsByPackId[p.id] || [])}
201
435
  </div>
202
- </div>
436
+ `).join('') : '<div class="empty-state"><div class="msg">No findings mapped to policy packs</div></div>'}
203
437
  </div>
204
438
  </div>
439
+ <div id="finding-detail" style="display:none;"></div>
440
+ </div>
205
441
 
206
- <div class="grid grid-2">
442
+ <div id="page-traceability" class="page">
443
+ <h2 style="font-size:20px;font-weight:700;margin-bottom:8px;">Fix Traceability Matrix</h2>
444
+ <p style="color:#6b7280;font-size:14px;margin-bottom:20px;">Finding &rarr; Fix &rarr; Control &rarr; Policy Pack traceability for every security issue.</p>
445
+ <div class="tab-bar">
446
+ <button class="tab-btn active" onclick="showTraceTab('matrix')">Matrix</button>
447
+ <button class="tab-btn" onclick="showTraceTab('fixes')">Prioritized Fixes</button>
448
+ <button class="tab-btn" onclick="showTraceTab('controls')">Control Coverage</button>
449
+ </div>
207
450
 
208
- <div class="card">
209
- <div class="card-title">Security Findings Detail (${findings.length})</div>
210
- ${findings.length > 0 ? `<table>
211
- <thead><tr><th>Severity</th><th>Rule</th><th>File</th><th>Issue</th></tr></thead>
451
+ <div id="trace-tab-matrix" class="tab-panel active">
452
+ ${findings.length > 0 ? `<div class="card">
453
+ <table>
454
+ <thead><tr><th>Finding</th><th>Severity</th><th>File</th><th>Linked Controls</th><th>Policy Pack</th><th>Fix Guidance</th></tr></thead>
212
455
  <tbody>
213
- ${findings.slice(0, 15).map(f => `<tr>
214
- <td>${severityBadge(f.severity)}</td>
215
- <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.ruleId)}</td>
216
- <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</td>
217
- <td>${escapeHtml(f.title)}</td>
218
- </tr>`).join('')}
456
+ ${findings.slice(0, 50).map(f => {
457
+ const linkedControls = controls.filter(c => f.controlIds.includes(c.id));
458
+ const linkedPackIds = new Set();
459
+ for (const ctrl of linkedControls) {
460
+ const pk = packs.find(pp => {
461
+ const pCtrls = getAllControlsForPack(pp.id, controls);
462
+ return pCtrls.some(c2 => c2.id === ctrl.id);
463
+ });
464
+ if (pk)
465
+ linkedPackIds.add(pk.id);
466
+ }
467
+ return `<tr>
468
+ <td>
469
+ <div style="font-weight:600;font-size:13px;">${escapeHtml(f.title)}</div>
470
+ <div style="font-size:11px;color:#6b7280;">${escapeHtml(f.ruleId)}</div>
471
+ </td>
472
+ <td><span class="badge badge-sev" style="background:${severityColor(f.severity)}">${f.severity.toUpperCase()}</span></td>
473
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</td>
474
+ <td>${linkedControls.length > 0 ? linkedControls.map(c => `<div style="margin-bottom:2px;"><span class="link" onclick="showControlDetail('${escapeHtml(c.id)}')">${escapeHtml(c.id)}</span> <span style="color:#6b7280;font-size:11px;">${escapeHtml(c.name)}</span></div>`).join('') : '<span style="color:#9ca3af;">No linked controls</span>'}</td>
475
+ <td>${linkedPackIds.size > 0 ? [...linkedPackIds].map(pid => {
476
+ const pk = packs.find(pp => pp.id === pid);
477
+ return pk ? `<span class="tag" style="cursor:pointer;" onclick="loadPackDetail('${pk.id}')">${escapeHtml(pk.name)}</span>` : '';
478
+ }).join(' ') : '<span style="color:#9ca3af;">-</span>'}</td>
479
+ <td style="max-width:300px;font-size:12px;color:#374151;">${escapeHtml(f.fix)}</td>
480
+ </tr>`;
481
+ }).join('')}
219
482
  </tbody>
220
483
  </table>
221
- ${findings.length > 15 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">... and ${findings.length - 15} more findings</div>` : ''}` : '<div style="padding:24px;text-align:center;color:#22c55e;font-weight:600;">No security findings. Project is clean.</div>'}
222
- </div>
484
+ ${findings.length > 50 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">Showing 50 of ${findings.length} findings</div>` : ''}
485
+ </div>` : '<div class="card"><div class="empty-state"><div class="icon">&#10003;</div><div class="msg" style="color:#22c55e;">No findings to trace</div><div class="sub">All clear</div></div></div>'}
486
+ </div>
487
+
488
+ <div id="trace-tab-fixes" class="tab-panel">
489
+ ${renderDetailedFixesList(findings, controls, packs)}
490
+ </div>
223
491
 
492
+ <div id="trace-tab-controls" class="tab-panel">
224
493
  <div class="card">
225
- <div class="card-title">Missing Controls (${missingControls.length})</div>
226
- ${missingControls.length > 0 ? `<table>
227
- <thead><tr><th>ID</th><th>Name</th><th>Severity</th><th>Status</th></tr></thead>
494
+ <div class="card-title">Control Coverage by Policy Pack</div>
495
+ <table>
496
+ <thead><tr><th>Policy Pack</th><th>Total Controls</th><th>Pass</th><th>Fail</th><th>Warn</th><th>Not Impl</th><th>Coverage</th><th>Findings</th></tr></thead>
228
497
  <tbody>
229
- ${missingControls.slice(0, 15).map(c => `<tr>
230
- <td style="font-family:monospace;font-size:11px;">${escapeHtml(c.id)}</td>
231
- <td>${escapeHtml(c.name)}</td>
232
- <td>${severityBadge(c.severity)}</td>
233
- <td>${statusBadge(c.status)}</td>
234
- </tr>`).join('')}
498
+ ${packs.map(p => {
499
+ const coverage = p.controlCount > 0 ? Math.round((p.passedCount / p.controlCount) * 100) : 0;
500
+ const covColor = coverage >= 80 ? '#22c55e' : coverage >= 60 ? '#eab308' : '#f97316';
501
+ return `<tr style="cursor:pointer;" onclick="loadPackDetail('${p.id}')">
502
+ <td style="font-weight:600;">${escapeHtml(p.name)}</td>
503
+ <td>${p.controlCount}</td>
504
+ <td style="color:#22c55e;font-weight:600;">${p.passedCount}</td>
505
+ <td style="color:#ef4444;font-weight:600;">${p.failedCount}</td>
506
+ <td style="color:#eab308;font-weight:600;">${p.warningCount}</td>
507
+ <td style="color:#6b7280;">${p.notImplementedCount}</td>
508
+ <td><span style="color:${covColor};font-weight:700;">${coverage}%</span></td>
509
+ <td>${p.findingsCount > 0 ? `<span style="color:#ef4444;font-weight:600;">${p.findingsCount}</span>` : '0'}</td>
510
+ </tr>`;
511
+ }).join('')}
235
512
  </tbody>
236
513
  </table>
237
- ${missingControls.length > 15 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">... and ${missingControls.length - 15} more</div>` : ''}` : '<div style="padding:24px;text-align:center;color:#22c55e;font-weight:600;">All controls are passing.</div>'}
238
514
  </div>
239
515
  </div>
516
+ </div>
240
517
 
241
- <div class="card">
242
- <div class="card-title">Installed Policy Packs (${data.packs.length})</div>
243
- <div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;">
244
- ${data.packs.map(p => `<span class="tag">${escapeHtml(p.id)} (${p.controlCount} controls)</span>`).join('')}
245
- </div>
246
- </div>
518
+ <div id="control-detail-modal" style="display:none;"></div>
247
519
 
248
- <div class="card">
249
- <div class="card-title">Active Frameworks</div>
250
- <div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;">
251
- ${data.frameworks.map(fw => `<span class="tag" style="background:#d1fae5;color:#065f46;">${escapeHtml(fw)}</span>`).join('') || '<span style="color:#9ca3af;">No frameworks configured</span>'}
252
- </div>
253
- </div>
254
-
255
- </div>
520
+ </div>
256
521
 
257
- <div class="footer">
258
- Generated by GESF v${escapeHtml(data.gesfVersion)} | Last audit: ${escapeHtml(new Date(data.lastAudit).toLocaleString())} | <a href="/api/data">JSON API</a>
259
- </div>
522
+ <div class="footer">
523
+ Generated by GESF v${escapeHtml(data.gesfVersion)} | Last audit: ${escapeHtml(new Date(data.lastAudit).toLocaleString())} | <a href="/api/data">JSON API</a> | <a href="/api/packs">Packs API</a>
260
524
  </div>
261
525
 
526
+ <script>
527
+ (function() {
528
+ var packData = ${JSON.stringify(packs.map(p => ({ id: p.id, name: p.name, controlCount: p.controlCount })))};
529
+ var allControlIds = ${JSON.stringify(controls.map(c => c.id))};
530
+
531
+ window.toggleFix = function(id) {
532
+ var body = document.getElementById(id);
533
+ var toggle = document.getElementById(id + '-toggle');
534
+ if (!body) return;
535
+ if (body.classList.contains('open')) {
536
+ body.classList.remove('open');
537
+ if (toggle) toggle.textContent = 'Expand';
538
+ } else {
539
+ body.classList.add('open');
540
+ if (toggle) toggle.textContent = 'Collapse';
541
+ }
542
+ };
543
+
544
+ window.showPage = function(page) {
545
+ var pages = document.querySelectorAll('.page');
546
+ for (var i = 0; i < pages.length; i++) pages[i].classList.remove('active');
547
+ document.getElementById('page-' + page).classList.add('active');
548
+ var tabs = document.querySelectorAll('.nav-tab');
549
+ for (var i = 0; i < tabs.length; i++) tabs[i].classList.remove('active');
550
+ event.target.classList.add('active');
551
+ if (page === 'packs') {
552
+ document.getElementById('packs-list').style.display = '';
553
+ document.getElementById('pack-detail').style.display = 'none';
554
+ }
555
+ if (page === 'findings') {
556
+ document.getElementById('findings-main').style.display = '';
557
+ document.getElementById('finding-detail').style.display = 'none';
558
+ }
559
+ };
560
+
561
+ window.showFindingsTab = function(tab) {
562
+ var panels = document.querySelectorAll('#page-findings .tab-panel');
563
+ for (var i = 0; i < panels.length; i++) panels[i].classList.remove('active');
564
+ document.getElementById('findings-tab-' + tab).classList.add('active');
565
+ var btns = document.querySelectorAll('#page-findings .tab-btn');
566
+ for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
567
+ event.target.classList.add('active');
568
+ };
569
+
570
+ window.showTraceTab = function(tab) {
571
+ var panels = document.querySelectorAll('#page-traceability .tab-panel');
572
+ for (var i = 0; i < panels.length; i++) panels[i].classList.remove('active');
573
+ document.getElementById('trace-tab-' + tab).classList.add('active');
574
+ var btns = document.querySelectorAll('#page-traceability .tab-btn');
575
+ for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
576
+ event.target.classList.add('active');
577
+ };
578
+
579
+ window.loadPackDetail = function(packId) {
580
+ var detail = document.getElementById('pack-detail');
581
+ detail.style.display = '';
582
+ detail.innerHTML = '<div class="card"><div class="card-title">Loading pack details...</div></div>';
583
+ fetch('/api/packs/' + packId)
584
+ .then(function(r) { return r.json(); })
585
+ .then(function(data) {
586
+ if (data.error) { detail.innerHTML = '<div class="card"><div class="card-title">Error</div><p>' + esc(data.error) + '</p></div>'; return; }
587
+ renderPackDetail(packId, data, detail);
588
+ })
589
+ .catch(function(e) { detail.innerHTML = '<div class="card"><div class="card-title">Error loading pack</div><p>' + esc(e.message) + '</p></div>'; });
590
+ document.getElementById('packs-list').style.display = 'none';
591
+
592
+ var navTabs = document.querySelectorAll('.nav-tab');
593
+ for (var i = 0; i < navTabs.length; i++) {
594
+ if (navTabs[i].textContent.indexOf('Policy') >= 0) {
595
+ navTabs[i].classList.add('active');
596
+ } else {
597
+ navTabs[i].classList.remove('active');
598
+ }
599
+ }
600
+ document.getElementById('page-packs').classList.add('active');
601
+ var pages = document.querySelectorAll('.page');
602
+ for (var i = 0; i < pages.length; i++) {
603
+ if (pages[i].id !== 'page-packs') pages[i].classList.remove('active');
604
+ }
605
+ };
606
+
607
+ function renderPackDetail(packId, data, container) {
608
+ var p = data.pack;
609
+ var pct = p.score;
610
+ var color = pct >= 80 ? '#22c55e' : pct >= 60 ? '#eab308' : pct >= 40 ? '#f97316' : '#ef4444';
611
+ var controls = data.controls || [];
612
+ var topFixes = data.topFixes || [];
613
+
614
+ var html = '<div class="detail-back" onclick="backToPacks()">&larr; Back to all packs</div>';
615
+ html += '<div class="detail-header">';
616
+ html += '<div><div class="detail-title">' + esc(p.name) + '</div>';
617
+ html += '<div class="detail-meta">' + esc(p.description) + '</div></div>';
618
+ html += '<div style="text-align:right;">';
619
+ html += '<div style="font-size:36px;font-weight:700;color:' + color + ';">' + pct + '%</div>';
620
+ html += '<span class="badge" style="background:' + color + ';padding:4px 14px;border-radius:6px;font-size:13px;">Grade: ' + p.grade + '</span>';
621
+ html += '</div></div>';
622
+
623
+ html += '<div class="grid grid-4" style="margin-bottom:20px;">';
624
+ html += '<div class="card stat"><div class="num">' + p.controlCount + '</div><div class="label">Controls</div></div>';
625
+ html += '<div class="card stat"><div class="num" style="color:#22c55e;">' + p.passedCount + '</div><div class="label">Pass</div></div>';
626
+ html += '<div class="card stat"><div class="num" style="color:#ef4444;">' + p.findingsCount + '</div><div class="label">Findings</div></div>';
627
+ html += '<div class="card stat"><div class="num" style="color:#f97316;">' + topFixes.length + '</div><div class="label">Need Fix</div></div>';
628
+ html += '</div>';
629
+
630
+ if (topFixes.length > 0) {
631
+ html += '<div class="card" style="margin-bottom:20px;"><div class="card-title">Prioritized Fixes (' + topFixes.length + ')</div>';
632
+ for (var i = 0; i < topFixes.length; i++) {
633
+ var fix = topFixes[i];
634
+ var sevClass = fix.severity;
635
+ var fixId = 'packfix-' + i;
636
+ html += '<div class="fix-detail-card" style="margin-bottom:8px;">';
637
+ html += '<div class="fix-detail-header ' + sevClass + '" onclick="toggleFix(\\'' + fixId + '\\')">';
638
+ html += '<div class="fix-detail-num" style="color:' + sevColor(fix.severity) + ';">' + (i + 1) + '</div>';
639
+ html += '<div class="fix-detail-info">';
640
+ html += '<div class="fix-detail-title">' + esc(fix.controlName) + '</div>';
641
+ html += '<div class="fix-detail-meta">' + esc(fix.controlId) + ' &mdash; ' + fix.findings.length + ' finding(s)</div>';
642
+ html += '</div>';
643
+ html += '<div class="fix-detail-badges">';
644
+ html += '<span class="badge badge-sev" style="background:' + sevColor(fix.severity) + ';font-size:10px;">' + fix.severity.toUpperCase() + '</span>';
645
+ html += '<span class="fix-toggle" id="' + fixId + '-toggle">Expand</span>';
646
+ html += '</div></div>';
647
+ html += '<div class="fix-detail-body" id="' + fixId + '">';
648
+ html += '<div class="fix-section"><div class="fix-section-title">Findings</div>';
649
+ for (var j = 0; j < fix.findings.length; j++) {
650
+ var ff = fix.findings[j];
651
+ html += '<div class="fix-finding-item ' + ff.severity + '">';
652
+ html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">';
653
+ html += '<span class="badge badge-sev" style="background:' + sevColor(ff.severity) + ';font-size:10px;">' + ff.severity.toUpperCase() + '</span>';
654
+ html += '<strong style="font-size:13px;">' + esc(ff.title) + '</strong></div>';
655
+ html += '<div style="font-size:12px;color:#6b7280;"><span style="font-family:monospace;font-weight:600;">' + esc(ff.ruleId) + '</span> &mdash; <span style="font-family:monospace;">' + esc(ff.file) + (ff.line ? ':' + ff.line : '') + '</span></div>';
656
+ if (ff.description) html += '<div style="font-size:12px;color:#4b5563;margin-top:4px;">' + esc(ff.description) + '</div>';
657
+ if (ff.evidence) html += '<div class="fix-evidence">' + esc(ff.evidence) + '</div>';
658
+ html += '</div>';
659
+ }
660
+ html += '</div>';
661
+ html += '<div class="fix-section"><div class="fix-section-title">Fix Guidance</div>';
662
+ html += '<div class="fix-guidance-box"><strong>How to fix:</strong> ' + esc(fix.guidance) + '</div>';
663
+ for (var j = 0; j < fix.findings.length; j++) {
664
+ if (fix.findings[j].fix) {
665
+ html += '<div class="fix-guidance-box" style="margin-top:8px;background:#eff6ff;border-color:#bfdbfe;"><strong>Fix for ' + esc(fix.findings[j].ruleId) + ':</strong> ' + esc(fix.findings[j].fix) + '</div>';
666
+ }
667
+ }
668
+ html += '</div>';
669
+ html += '</div></div>';
670
+ }
671
+ html += '</div>';
672
+ }
673
+
674
+ html += '<div class="tab-bar">';
675
+ html += '<button class="tab-btn active" onclick="showPackTab(\\'all\\',this)">All Controls (' + controls.length + ')</button>';
676
+ html += '<button class="tab-btn" onclick="showPackTab(\\'failing\\',this)">Failing (' + (controls.filter(function(c){return c.status!==\\'pass\\'&&c.status!==\\'not-applicable\\'}).length) + ')</button>';
677
+ html += '<button class="tab-btn" onclick="showPackTab(\\'withfindings\\',this)">With Findings (' + (controls.filter(function(c){return c.relatedFindings.length>0}).length) + ')</button>';
678
+ html += '</div>';
679
+
680
+ html += '<div id="pack-controls-all">';
681
+ html += renderControlsTable(controls);
682
+ html += '</div>';
683
+ html += '<div id="pack-controls-failing" style="display:none;">';
684
+ html += renderControlsTable(controls.filter(function(c){return c.status!==\\'pass\\'&&c.status!==\\'not-applicable\\'}));
685
+ html += '</div>';
686
+ html += '<div id="pack-controls-withfindings" style="display:none;">';
687
+ html += renderControlsTable(controls.filter(function(c){return c.relatedFindings.length>0}));
688
+ html += '</div>';
689
+
690
+ container.innerHTML = html;
691
+ }
692
+
693
+ function renderControlsTable(ctrls) {
694
+ if (ctrls.length === 0) return '<div class="empty-state"><div class="msg">No controls match this filter</div></div>';
695
+ var html = '<table><thead><tr><th>ID</th><th>Name</th><th>Severity</th><th>Status</th><th>Checks</th><th>Findings</th><th>Actions</th></tr></thead><tbody>';
696
+ for (var i = 0; i < ctrls.length; i++) {
697
+ var c = ctrls[i];
698
+ var passedChecks = c.checks.filter(function(ch){return ch.status==='pass'}).length;
699
+ html += '<tr class="control-row" onclick="showControlDetail(\\'' + c.id + '\\')">';
700
+ html += '<td style="font-family:monospace;font-size:11px;">' + esc(c.id) + '</td>';
701
+ html += '<td>' + esc(c.name) + '</td>';
702
+ html += '<td><span class="badge badge-sev" style="background:' + sevColor(c.severity) + '">' + c.severity.toUpperCase() + '</span></td>';
703
+ html += '<td><span class="badge badge-status" style="background:' + statColor(c.status) + '">' + statLabel(c.status) + '</span></td>';
704
+ html += '<td>' + passedChecks + '/' + c.checks.length + '</td>';
705
+ html += '<td>' + (c.relatedFindings.length > 0 ? '<span style="color:#ef4444;font-weight:600;">' + c.relatedFindings.length + '</span>' : '0') + '</td>';
706
+ html += '<td><span class="link">Details &rarr;</span></td>';
707
+ html += '</tr>';
708
+ }
709
+ html += '</tbody></table>';
710
+ return html;
711
+ }
712
+
713
+ window.showPackTab = function(tab, btn) {
714
+ var panels = ['all', 'failing', 'withfindings'];
715
+ for (var i = 0; i < panels.length; i++) {
716
+ var el = document.getElementById('pack-controls-' + panels[i]);
717
+ if (el) el.style.display = panels[i] === tab ? '' : 'none';
718
+ }
719
+ var btns = btn.parentElement.querySelectorAll('.tab-btn');
720
+ for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
721
+ btn.classList.add('active');
722
+ };
723
+
724
+ window.backToPacks = function() {
725
+ document.getElementById('packs-list').style.display = '';
726
+ document.getElementById('pack-detail').style.display = 'none';
727
+ };
728
+
729
+ window.showControlDetail = function(controlId) {
730
+ var modal = document.getElementById('control-detail-modal');
731
+ modal.style.display = '';
732
+ modal.innerHTML = '<div class="card"><div class="card-title">Loading control details...</div></div>';
733
+ modal.scrollIntoView({ behavior: 'smooth' });
734
+
735
+ fetch('/api/controls/' + controlId)
736
+ .then(function(r) { return r.json(); })
737
+ .then(function(data) {
738
+ if (data.error) { modal.innerHTML = '<div class="card"><div class="card-title">Error</div><p>' + esc(data.error) + '</p></div>'; return; }
739
+ renderControlModal(data, modal);
740
+ })
741
+ .catch(function(e) { modal.innerHTML = '<div class="card"><div class="card-title">Error</div><p>' + esc(e.message) + '</p></div>'; });
742
+ };
743
+
744
+ function renderControlModal(data, container) {
745
+ var html = '<div class="card" style="position:relative;">';
746
+ html += '<button onclick="document.getElementById(\\'control-detail-modal\\').style.display=\\'none\\'" style="position:absolute;top:16px;right:16px;background:none;border:none;font-size:20px;cursor:pointer;color:#6b7280;">&times;</button>';
747
+ html += '<div class="breadcrumb"><span onclick="showPage(\\'packs\\')">Policy Packs</span> &rsaquo; <span onclick="loadPackDetail(\\'' + data.packId + '\\')">' + esc(data.packName) + '</span> &rsaquo; ' + esc(data.id) + '</div>';
748
+ html += '<div class="detail-header">';
749
+ html += '<div><div class="detail-title">' + esc(data.name) + '</div>';
750
+ html += '<div class="detail-meta">' + esc(data.id) + ' | ' + esc(data.category) + ' | ' + esc(data.framework) + (data.article ? ' | ' + esc(data.article) : '') + '</div></div>';
751
+ html += '<div style="text-align:right;">';
752
+ html += '<span class="badge badge-sev" style="background:' + sevColor(data.severity) + ';font-size:12px;padding:4px 12px;">' + data.severity.toUpperCase() + '</span> ';
753
+ html += '<span class="badge badge-status" style="background:' + statColor(data.status) + ';font-size:12px;padding:4px 12px;">' + statLabel(data.status) + '</span>';
754
+ html += '</div></div>';
755
+
756
+ html += '<div style="margin:16px 0;font-size:14px;color:#374151;line-height:1.7;">' + esc(data.description) + '</div>';
757
+
758
+ html += '<h3 style="font-size:15px;font-weight:700;margin:20px 0 8px;">Checks (' + data.checks.length + ')</h3>';
759
+ html += '<ul class="check-list">';
760
+ for (var i = 0; i < data.checks.length; i++) {
761
+ var ch = data.checks[i];
762
+ var icon = ch.status === 'pass' ? '&#10003;' : '&#10007;';
763
+ var iconBg = statColor(ch.status);
764
+ html += '<li><span class="check-icon" style="background:' + iconBg + '">' + icon + '</span> <span>' + esc(ch.description) + '</span>';
765
+ if (ch.evidence) html += ' <span style="color:#6b7280;font-size:11px;">(' + esc(ch.evidence) + ')</span>';
766
+ html += '</li>';
767
+ }
768
+ html += '</ul>';
769
+
770
+ if (data.relatedFindings && data.relatedFindings.length > 0) {
771
+ html += '<h3 style="font-size:15px;font-weight:700;margin:20px 0 8px;">Related Findings (' + data.relatedFindings.length + ')</h3>';
772
+ html += '<div class="findings-trace">';
773
+ for (var i = 0; i < data.relatedFindings.length; i++) {
774
+ var f = data.relatedFindings[i];
775
+ html += '<div class="trace-item ' + f.severity + '">';
776
+ html += '<div class="trace-title"><span class="badge badge-sev" style="background:' + sevColor(f.severity) + '">' + f.severity.toUpperCase() + '</span> ' + esc(f.title) + '</div>';
777
+ html += '<div class="trace-detail">' + esc(f.ruleId) + ' | ' + esc(f.file) + (f.line ? ':' + f.line : '') + '</div>';
778
+ html += '</div>';
779
+ if (f.fix) {
780
+ html += '<div class="trace-fix"><div class="trace-fix-label">Fix Guidance</div>' + esc(f.fix) + '</div>';
781
+ }
782
+ }
783
+ html += '</div>';
784
+ }
785
+
786
+ html += '<div class="guidance-box">';
787
+ html += '<h4>Implementation Guidance</h4>';
788
+ html += '<p>' + esc(data.implementation_guidance) + '</p>';
789
+ html += '</div>';
790
+
791
+ html += '</div>';
792
+ container.innerHTML = html;
793
+ }
794
+
795
+ function sevColor(s) {
796
+ var m = { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6' };
797
+ return m[s] || '#6b7280';
798
+ }
799
+ function statColor(s) {
800
+ var m = { pass: '#22c55e', fail: '#ef4444', warning: '#eab308', 'not-implemented': '#6b7280', 'not-applicable': '#9ca3af' };
801
+ return m[s] || '#6b7280';
802
+ }
803
+ function statLabel(s) {
804
+ var m = { pass: 'PASS', fail: 'FAIL', warning: 'WARN', 'not-implemented': 'NOT IMPL', 'not-applicable': 'N/A' };
805
+ return m[s] || (s || '').toUpperCase();
806
+ }
807
+ function esc(str) {
808
+ if (!str) return '';
809
+ var d = document.createElement('div');
810
+ d.textContent = str;
811
+ return d.innerHTML;
812
+ }
813
+ })();
814
+ </script>
815
+
262
816
  </body>
263
817
  </html>`;
264
818
  }
819
+ function donutSvg(passed, total) {
820
+ const r = 54;
821
+ const cx = 70;
822
+ const cy = 70;
823
+ const circumference = 2 * Math.PI * r;
824
+ const pct = total > 0 ? passed / total : 0;
825
+ const offset = circumference * (1 - pct);
826
+ const color = pct >= 0.8 ? "#22c55e" : pct >= 0.6 ? "#eab308" : pct >= 0.4 ? "#f97316" : "#ef4444";
827
+ return `<svg width="140" height="140" viewBox="0 0 140 140">
828
+ <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/>
829
+ <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="12"
830
+ stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"
831
+ stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
832
+ <text x="${cx}" y="${cy - 4}" text-anchor="middle" font-size="28" font-weight="700" fill="#1f2937">${Math.round(pct * 100)}%</text>
833
+ <text x="${cx}" y="${cy + 16}" text-anchor="middle" font-size="11" fill="#6b7280">${passed}/${total} passed</text>
834
+ </svg>`;
835
+ }
836
+ function scoreBarHtml(score) {
837
+ const color = score >= 80 ? "#22c55e" : score >= 60 ? "#eab308" : score >= 40 ? "#f97316" : "#ef4444";
838
+ return `<div class="score-bar-wrap"><div class="score-bar-fill" style="width:${score}%;background:${color};"></div></div>`;
839
+ }
840
+ function renderFindingsTable(findings) {
841
+ if (findings.length === 0) {
842
+ return '<div class="empty-state"><div class="icon">&#10003;</div><div class="msg" style="color:#22c55e;">No findings in this category</div></div>';
843
+ }
844
+ return `<table>
845
+ <thead><tr><th>Severity</th><th>Rule</th><th>File</th><th>Issue</th><th>Fix Guidance</th></tr></thead>
846
+ <tbody>
847
+ ${findings.slice(0, 50).map(f => `<tr>
848
+ <td><span class="badge badge-sev" style="background:${severityColor(f.severity)}">${f.severity.toUpperCase()}</span></td>
849
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.ruleId)}</td>
850
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</td>
851
+ <td>${escapeHtml(f.title)}</td>
852
+ <td style="max-width:300px;font-size:12px;color:#374151;">${escapeHtml(f.fix)}</td>
853
+ </tr>`).join('')}
854
+ </tbody>
855
+ </table>
856
+ ${findings.length > 50 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">Showing 50 of ${findings.length}</div>` : ''}`;
857
+ }
858
+ function renderDetailedFixesList(findings, controls, packs) {
859
+ const fixesByControl = new Map();
860
+ for (const f of findings) {
861
+ for (const cid of f.controlIds) {
862
+ const ctrl = controls.find(c => c.id === cid);
863
+ if (!ctrl)
864
+ continue;
865
+ const existing = fixesByControl.get(cid);
866
+ if (existing) {
867
+ if (!existing.findings.some(ef => ef.ruleId === f.ruleId && ef.file === f.file && ef.line === f.line)) {
868
+ existing.findings.push(f);
869
+ }
870
+ }
871
+ else {
872
+ fixesByControl.set(cid, { control: ctrl, findings: [f] });
873
+ }
874
+ }
875
+ }
876
+ const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
877
+ const fixEntries = [...fixesByControl.values()].sort((a, b) => {
878
+ const aMin = Math.min(...a.findings.map(f => sevOrder[f.severity] ?? 4));
879
+ const bMin = Math.min(...b.findings.map(f => sevOrder[f.severity] ?? 4));
880
+ if (aMin !== bMin)
881
+ return aMin - bMin;
882
+ return b.findings.length - a.findings.length;
883
+ });
884
+ if (fixEntries.length === 0) {
885
+ return '<div class="card"><div class="empty-state"><div class="icon">&#10003;</div><div class="msg" style="color:#22c55e;">No fixes needed</div><div class="sub">All findings are resolved</div></div></div>';
886
+ }
887
+ const totalFindings = fixEntries.reduce((sum, e) => sum + e.findings.length, 0);
888
+ const criticalFixes = fixEntries.filter(e => e.findings.some(f => f.severity === "critical")).length;
889
+ let html = '';
890
+ html += `<div style="margin-bottom:20px;">`;
891
+ html += `<h2 style="font-size:20px;font-weight:700;margin-bottom:8px;">Detailed Fix List</h2>`;
892
+ html += `<p style="color:#6b7280;font-size:14px;margin-bottom:16px;">Every finding that needs a fix, grouped by the control it belongs to, with full traceability to the policy pack.</p>`;
893
+ html += `<div class="grid grid-4" style="margin-bottom:20px;">`;
894
+ html += `<div class="card stat"><div class="num" style="color:#ef4444;">${fixEntries.length}</div><div class="label">Controls to Fix</div></div>`;
895
+ html += `<div class="card stat"><div class="num">${totalFindings}</div><div class="label">Total Findings</div></div>`;
896
+ html += `<div class="card stat"><div class="num" style="color:#ef4444;">${criticalFixes}</div><div class="label">Critical Fixes</div></div>`;
897
+ html += `<div class="card stat"><div class="num" style="color:#f97316;">${fixEntries.filter(e => e.findings.some(f => f.severity === "high")).length}</div><div class="label">High Fixes</div></div>`;
898
+ html += `</div>`;
899
+ html += `</div>`;
900
+ for (let i = 0; i < fixEntries.length; i++) {
901
+ const entry = fixEntries[i];
902
+ const c = entry.control;
903
+ const maxSev = entry.findings.reduce((max, f) => {
904
+ return (sevOrder[f.severity] ?? 4) < (sevOrder[max] ?? 4) ? f.severity : max;
905
+ }, "low");
906
+ const packMatch = packs.find(p => {
907
+ const idUpper = c.id.toUpperCase();
908
+ const packPrefix = p.id.toUpperCase().replace(/-/g, "");
909
+ if (p.id === "gdpr")
910
+ return idUpper.startsWith("GDPR-");
911
+ if (p.id === "owasp")
912
+ return idUpper.startsWith("OWASP-");
913
+ if (p.id === "cis")
914
+ return idUpper.startsWith("CIS-");
915
+ if (p.id === "nist")
916
+ return idUpper.startsWith("NIST-");
917
+ if (p.id === "ai")
918
+ return idUpper.startsWith("AI-");
919
+ if (p.id === "blockchain")
920
+ return idUpper.startsWith("BC-");
921
+ if (p.id === "government")
922
+ return idUpper.startsWith("GOV-");
923
+ if (p.id === "iso27001")
924
+ return idUpper.startsWith("ISO27K-");
925
+ if (p.id === "iso27701")
926
+ return idUpper.startsWith("ISO277-");
927
+ if (p.id === "hipaa")
928
+ return idUpper.startsWith("HIPAA-");
929
+ return false;
930
+ });
931
+ const fixId = `fix-${i}`;
932
+ const passedChecks = c.checks.filter(ch => ch.status === "pass").length;
933
+ const failedChecks = c.checks.filter(ch => ch.status === "fail").length;
934
+ html += `<div class="fix-detail-card">`;
935
+ html += `<div class="fix-detail-header ${maxSev}" onclick="toggleFix('${fixId}')">`;
936
+ html += `<div class="fix-detail-num" style="color:${severityColor(maxSev)};">${i + 1}</div>`;
937
+ html += `<div class="fix-detail-info">`;
938
+ html += `<div class="fix-detail-title">${escapeHtml(c.name)}</div>`;
939
+ html += `<div class="fix-detail-meta">${escapeHtml(c.id)} | ${escapeHtml(c.category)} | ${escapeHtml(c.framework)}${c.article ? ' | ' + escapeHtml(c.article) : ''} | Pack: ${escapeHtml(packMatch?.name || "Direct")}</div>`;
940
+ html += `</div>`;
941
+ html += `<div class="fix-detail-badges">`;
942
+ html += `<span class="badge badge-sev" style="background:${severityColor(maxSev)}">${maxSev.toUpperCase()}</span>`;
943
+ html += `<span class="badge badge-status" style="background:${statusColor(c.status)}">${statusLabel(c.status)}</span>`;
944
+ html += `<span style="font-size:12px;color:#6b7280;">${entry.findings.length} finding(s)</span>`;
945
+ html += `<span style="font-size:12px;color:#6b7280;">${passedChecks}/${c.checks.length} checks</span>`;
946
+ html += `<span class="fix-toggle" id="${fixId}-toggle">Expand</span>`;
947
+ html += `</div>`;
948
+ html += `</div>`;
949
+ html += `<div class="fix-detail-body" id="${fixId}">`;
950
+ html += `<div class="fix-section">`;
951
+ html += `<div class="fix-section-title">Findings (${entry.findings.length})</div>`;
952
+ for (const f of entry.findings) {
953
+ html += `<div class="fix-finding-item ${f.severity}">`;
954
+ html += `<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">`;
955
+ html += `<span class="badge badge-sev" style="background:${severityColor(f.severity)};font-size:10px;">${f.severity.toUpperCase()}</span>`;
956
+ html += `<strong style="font-size:13px;">${escapeHtml(f.title)}</strong>`;
957
+ html += `</div>`;
958
+ html += `<div style="font-size:12px;color:#6b7280;">`;
959
+ html += `<span style="font-family:monospace;font-weight:600;">${escapeHtml(f.ruleId)}</span>`;
960
+ html += ` &mdash; <span style="font-family:monospace;">${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</span>`;
961
+ html += `</div>`;
962
+ if (f.description) {
963
+ html += `<div style="font-size:12px;color:#4b5563;margin-top:4px;">${escapeHtml(f.description)}</div>`;
964
+ }
965
+ if (f.evidence) {
966
+ html += `<div class="fix-evidence">${escapeHtml(f.evidence)}</div>`;
967
+ }
968
+ html += `</div>`;
969
+ }
970
+ html += `</div>`;
971
+ html += `<div class="fix-section">`;
972
+ html += `<div class="fix-section-title">Fix Guidance</div>`;
973
+ html += `<div class="fix-guidance-box"><strong>How to fix:</strong> ${escapeHtml(c.implementation_guidance)}</div>`;
974
+ for (const f of entry.findings) {
975
+ if (f.fix) {
976
+ html += `<div class="fix-guidance-box" style="margin-top:8px;background:#eff6ff;border-color:#bfdbfe;"><strong>Fix for ${escapeHtml(f.ruleId)}:</strong> ${escapeHtml(f.fix)}</div>`;
977
+ }
978
+ }
979
+ html += `</div>`;
980
+ if (c.checks.length > 0) {
981
+ html += `<div class="fix-section">`;
982
+ html += `<div class="fix-section-title">Control Checks (${passedChecks} passed, ${failedChecks} failed, ${c.checks.length - passedChecks - failedChecks} other)</div>`;
983
+ html += `<div class="fix-checks-grid">`;
984
+ for (const ch of c.checks) {
985
+ const icon = ch.status === "pass" ? "&#10003;" : "&#10007;";
986
+ const checkClass = ch.status === "pass" ? "fix-check-pass" : ch.status === "fail" ? "fix-check-fail" : "";
987
+ html += `<div class="fix-check-item ${checkClass}">`;
988
+ html += `<span class="fix-check-icon" style="background:${statusColor(ch.status)}">${icon}</span>`;
989
+ html += `<span>${escapeHtml(ch.description)}</span>`;
990
+ html += `</div>`;
991
+ }
992
+ html += `</div>`;
993
+ html += `</div>`;
994
+ }
995
+ html += `<div class="fix-section">`;
996
+ html += `<div class="fix-section-title">Traceability</div>`;
997
+ html += `<table><tbody>`;
998
+ html += `<tr><td style="font-weight:600;width:160px;">Control</td><td>${escapeHtml(c.id)} &mdash; ${escapeHtml(c.name)}</td></tr>`;
999
+ html += `<tr><td style="font-weight:600;">Category</td><td>${escapeHtml(c.category)}</td></tr>`;
1000
+ html += `<tr><td style="font-weight:600;">Framework</td><td>${escapeHtml(c.framework)}${c.article ? ' / ' + escapeHtml(c.article) : ''}</td></tr>`;
1001
+ html += `<tr><td style="font-weight:600;">Policy Pack</td><td>${packMatch ? `<span class="tag" style="cursor:pointer;" onclick="loadPackDetail('${packMatch.id}')">${escapeHtml(packMatch.name)}</span>` : 'Direct'}</td></tr>`;
1002
+ html += `<tr><td style="font-weight:600;">Severity</td><td><span class="badge badge-sev" style="background:${severityColor(c.severity)}">${c.severity.toUpperCase()}</span></td></tr>`;
1003
+ html += `<tr><td style="font-weight:600;">Status</td><td><span class="badge badge-status" style="background:${statusColor(c.status)}">${statusLabel(c.status)}</span></td></tr>`;
1004
+ html += `<tr><td style="font-weight:600;">Findings Count</td><td>${entry.findings.length}</td></tr>`;
1005
+ html += `<tr><td style="font-weight:600;">Description</td><td style="font-size:13px;color:#4b5563;">${escapeHtml(c.description)}</td></tr>`;
1006
+ html += `</tbody></table>`;
1007
+ html += `</div>`;
1008
+ html += `</div>`;
1009
+ html += `</div>`;
1010
+ }
1011
+ return html;
1012
+ }
265
1013
  function escapeHtml(str) {
266
1014
  return str
267
1015
  .replace(/&/g, "&amp;")