@greenarmor/ges-web-dashboard 1.4.3 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as http from "node:http";
2
- import type { ScoreFile, Control, FixHistoryEntry, ActivityLogEntry, GovernanceRecord, GovernanceVerificationResult } from "@greenarmor/ges-core";
2
+ import type { ScoreFile, Control, FixHistoryEntry, ActivityLogEntry, GovernanceRecord, GovernanceVerificationResult, FixAssignment } from "@greenarmor/ges-core";
3
3
  import type { Finding } from "@greenarmor/ges-audit-engine";
4
4
  export interface DashboardOptions {
5
5
  port?: number;
@@ -79,6 +79,7 @@ export interface DashboardData {
79
79
  fixHistory: FixHistoryEntry[];
80
80
  activityLog: ActivityLogEntry[];
81
81
  governance: GovernanceData;
82
+ fixAssignments: FixAssignment[];
82
83
  lastAudit: string;
83
84
  }
84
85
  export interface GovernanceData {
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { getAllPacks, getPack } from "@greenarmor/ges-policy-engine";
6
6
  import { generateScoreFile } from "@greenarmor/ges-scoring-engine";
7
7
  import { loadFixHistory, loadActivityLog, loadControlsFromDisk, loadControlOverrides, applyOverridesToControls } from "@greenarmor/ges-core";
8
8
  import { loadGovernanceRecords, verifyGovernanceRecord, verifyAllGovernanceRecords, createGovernanceRecord, addGovernanceRecord, findGovernanceRecord, setGovernanceApproval, addGovernanceEvidence, createEvidenceRef, deleteGovernanceRecord, setGovernanceRiskAssessment, setGovernancePolicyBasis, setGovernanceReviewCycle, setGovernanceDataInventory, setGovernanceComplianceLinks, setGovernanceCommittee, recordActivity, } from "@greenarmor/ges-core";
9
+ import { loadFixAssignments, createFixAssignment, addFixAssignment, resolveFixAssignment, unassignFix, } from "@greenarmor/ges-core";
9
10
  import { getInstalledPackIds as getInstalledPackIdsFromDisk } from "@greenarmor/ges-core";
10
11
  import { generateMarkdownReport, generateHtmlReport } from "@greenarmor/ges-report-generator";
11
12
  import { renderDashboard } from "./template.js";
@@ -179,6 +180,7 @@ export function collectDashboardData(projectPath) {
179
180
  const fixHistory = loadFixHistory(projectPath);
180
181
  const activityLog = loadActivityLog(projectPath);
181
182
  const governance = collectGovernanceData(projectPath);
183
+ const fixAssignments = loadFixAssignments(projectPath);
182
184
  const metadataPath = path.join(projectPath, ".ges", "metadata.json");
183
185
  let lastAudit = "";
184
186
  try {
@@ -193,7 +195,7 @@ export function collectDashboardData(projectPath) {
193
195
  projectName: config?.project_name || "Unknown Project",
194
196
  projectType: config?.project_type || "unknown",
195
197
  frameworks: allFrameworks,
196
- gesfVersion: "1.4.3",
198
+ gesfVersion: "1.5.1",
197
199
  score,
198
200
  controls,
199
201
  findings,
@@ -201,6 +203,7 @@ export function collectDashboardData(projectPath) {
201
203
  fixHistory,
202
204
  activityLog,
203
205
  governance,
206
+ fixAssignments,
204
207
  lastAudit,
205
208
  };
206
209
  }
@@ -543,6 +546,121 @@ export function startDashboard(options) {
543
546
  }
544
547
  return;
545
548
  }
549
+ if (req.method === "POST" && pathname === "/api/fix-assignments/assign") {
550
+ try {
551
+ const body = await readBody(req);
552
+ const fk = String(body.finding_key || "").trim();
553
+ const recordId = String(body.governance_record_id || "").trim();
554
+ const assignee = String(body.assignee || "").trim();
555
+ if (!fk || !recordId || !assignee) {
556
+ jsonError(res, "finding_key, governance_record_id, and assignee are required", 400);
557
+ return;
558
+ }
559
+ const record = findGovernanceRecord(options.projectPath, recordId);
560
+ if (!record) {
561
+ jsonError(res, `Governance record not found: ${recordId}`, 404);
562
+ return;
563
+ }
564
+ const assignment = createFixAssignment({
565
+ finding_key: fk,
566
+ finding_rule_id: String(body.finding_rule_id || ""),
567
+ finding_title: String(body.finding_title || ""),
568
+ finding_file: String(body.finding_file || ""),
569
+ finding_line: body.finding_line ? Number(body.finding_line) : undefined,
570
+ finding_severity: body.finding_severity || "medium",
571
+ finding_control_ids: parseList(body.finding_control_ids),
572
+ governance_record_id: record.id,
573
+ governance_system_name: record.system_name,
574
+ assignee,
575
+ assignee_role: String(body.assignee_role || ""),
576
+ assigned_by: String(body.assigned_by || body.actor_name || "dashboard"),
577
+ notes: String(body.notes || ""),
578
+ });
579
+ addFixAssignment(options.projectPath, assignment);
580
+ recordActivity(options.projectPath, {
581
+ source: "cli",
582
+ action: "fix_assign",
583
+ title: `Fix assigned: ${assignment.finding_rule_id} → ${record.system_name}`,
584
+ description: `Assigned ${assignment.finding_rule_id} (${assignment.finding_title}) to ${assignee} (${assignment.assignee_role || "unspecified role"}), linked to governance record ${record.system_name}.`,
585
+ details: {
586
+ finding_key: fk,
587
+ governance_record_id: record.id,
588
+ assignee,
589
+ governance_system_name: record.system_name,
590
+ },
591
+ actor_name: body.actor_name ? String(body.actor_name) : undefined,
592
+ actor_role: body.actor_role ? String(body.actor_role) : undefined,
593
+ });
594
+ jsonResponse(res, { success: true, assignment });
595
+ }
596
+ catch (err) {
597
+ jsonError(res, err instanceof Error ? err.message : String(err));
598
+ }
599
+ return;
600
+ }
601
+ if (req.method === "POST" && pathname === "/api/fix-assignments/resolve") {
602
+ try {
603
+ const body = await readBody(req);
604
+ const fk = String(body.finding_key || "").trim();
605
+ if (!fk) {
606
+ jsonError(res, "finding_key is required", 400);
607
+ return;
608
+ }
609
+ const resolved = resolveFixAssignment(options.projectPath, fk, {
610
+ resolved_by: String(body.resolved_by || body.actor_name || "dashboard"),
611
+ resolved_by_role: String(body.resolved_by_role || body.actor_role || ""),
612
+ method: body.method || "manual",
613
+ resolution_notes: String(body.resolution_notes || ""),
614
+ });
615
+ if (!resolved) {
616
+ jsonError(res, `Fix assignment not found for finding_key: ${fk}`, 404);
617
+ return;
618
+ }
619
+ recordActivity(options.projectPath, {
620
+ source: "cli",
621
+ action: "fix_resolve",
622
+ title: `Fix resolved: ${resolved.finding_rule_id}`,
623
+ description: `Resolved ${resolved.finding_rule_id} via ${body.method || "manual"} by ${body.resolved_by || body.actor_name || "dashboard"}.`,
624
+ details: {
625
+ finding_key: fk,
626
+ governance_record_id: resolved.governance_record_id,
627
+ method: body.method || "manual",
628
+ },
629
+ actor_name: body.actor_name ? String(body.actor_name) : undefined,
630
+ actor_role: body.actor_role ? String(body.actor_role) : undefined,
631
+ });
632
+ jsonResponse(res, { success: true, assignment: resolved });
633
+ }
634
+ catch (err) {
635
+ jsonError(res, err instanceof Error ? err.message : String(err));
636
+ }
637
+ return;
638
+ }
639
+ const fixAssignDeleteMatch = req.method === "POST" ? pathname.match(/^\/api\/fix-assignments\/([^/]+)\/unassign$/) : null;
640
+ if (fixAssignDeleteMatch) {
641
+ try {
642
+ const fkey = decodeURIComponent(fixAssignDeleteMatch[1]);
643
+ const deleted = unassignFix(options.projectPath, fkey);
644
+ if (!deleted) {
645
+ jsonError(res, `Fix assignment not found for finding: ${fkey}`, 404);
646
+ return;
647
+ }
648
+ recordActivity(options.projectPath, {
649
+ source: "cli",
650
+ action: "fix_assign",
651
+ title: `Fix assignment removed`,
652
+ description: `Removed fix assignment for finding ${fkey}.`,
653
+ details: { finding_key: fkey },
654
+ actor_name: undefined,
655
+ actor_role: undefined,
656
+ });
657
+ jsonResponse(res, { success: true });
658
+ }
659
+ catch (err) {
660
+ jsonError(res, err instanceof Error ? err.message : String(err));
661
+ }
662
+ return;
663
+ }
546
664
  if (req.method !== "GET") {
547
665
  jsonError(res, "Method not allowed", 405);
548
666
  return;
@@ -590,6 +708,16 @@ export function startDashboard(options) {
590
708
  }
591
709
  return;
592
710
  }
711
+ if (pathname === "/api/fix-assignments") {
712
+ try {
713
+ const data = collectDashboardData(options.projectPath);
714
+ jsonResponse(res, data.fixAssignments);
715
+ }
716
+ catch (err) {
717
+ jsonError(res, err instanceof Error ? err.message : String(err));
718
+ }
719
+ return;
720
+ }
593
721
  if (pathname === "/api/activity-log") {
594
722
  try {
595
723
  const data = collectDashboardData(options.projectPath);
package/dist/template.js CHANGED
@@ -74,6 +74,10 @@ export function renderDashboard(data) {
74
74
  const controls = data.controls;
75
75
  const packs = data.packs;
76
76
  const governance = data.governance;
77
+ const fixAssignmentMap = new Map();
78
+ for (const a of data.fixAssignments) {
79
+ fixAssignmentMap.set(a.finding_key, a);
80
+ }
77
81
  const findingsBySeverity = {
78
82
  critical: findings.filter(f => f.severity === "critical").length,
79
83
  high: findings.filter(f => f.severity === "high").length,
@@ -574,7 +578,7 @@ export function renderDashboard(data) {
574
578
  </div>
575
579
 
576
580
  <div id="fixes-tab-pending" class="tab-panel">
577
- ${renderComplianceFixCards(complianceIssues, "fix")}
581
+ ${renderComplianceFixCards(complianceIssues, "fix", fixAssignmentMap, governance.records)}
578
582
  </div>
579
583
  </div>
580
584
 
@@ -656,7 +660,7 @@ export function renderDashboard(data) {
656
660
  </div>
657
661
 
658
662
  <div id="trace-tab-fixes" class="tab-panel">
659
- ${renderComplianceFixCards(complianceIssues, "trace")}
663
+ ${renderComplianceFixCards(complianceIssues, "trace", fixAssignmentMap, governance.records)}
660
664
  </div>
661
665
 
662
666
  <div id="trace-tab-controls" class="tab-panel">
@@ -695,10 +699,14 @@ export function renderDashboard(data) {
695
699
 
696
700
  <div id="control-detail-modal" style="display:none;"></div>
697
701
 
702
+ <div class="gov-modal-overlay" id="assign-modal-overlay" onclick="if(event.target===this)closeAssignModal()">
703
+ <div class="gov-modal" id="assign-modal"></div>
704
+ </div>
705
+
698
706
  </div>
699
707
 
700
708
  <div class="footer">
701
- 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> | <a href="/api/fix-history">Fix History API</a> | <a href="/api/activity-log">Activity Log API</a> | <a href="/api/governance">Governance API</a>
709
+ 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> | <a href="/api/fix-history">Fix History API</a> | <a href="/api/fix-assignments">Fix Assignments API</a> | <a href="/api/activity-log">Activity Log API</a> | <a href="/api/governance">Governance API</a>
702
710
  </div>
703
711
 
704
712
  <script>
@@ -1149,6 +1157,130 @@ export function renderDashboard(data) {
1149
1157
  c.appendChild(t);
1150
1158
  setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function() { if(t.parentNode) t.remove(); }, 300); }, 3000);
1151
1159
  };
1160
+
1161
+ var govRecordsForAssign = ${JSON.stringify(governance.records.map(r => ({ id: r.id, name: r.system_name, status: r.status, risk: r.risk_level })))};
1162
+
1163
+ window.openAssignModal = function(fkey, ruleId, title, file, line, severity, controlIds) {
1164
+ var overlay = document.getElementById('assign-modal-overlay');
1165
+ var modal = document.getElementById('assign-modal');
1166
+ if (!overlay || !modal) return;
1167
+ if (govRecordsForAssign.length === 0) {
1168
+ showToast('No governance records found. Create one first on the Governance tab.', 'error');
1169
+ return;
1170
+ }
1171
+ var recordOptions = govRecordsForAssign.map(function(r) {
1172
+ return '<option value="' + r.id + '">' + r.name + ' (' + r.status + ', ' + r.risk + ' risk)</option>';
1173
+ }).join('');
1174
+ modal.innerHTML =
1175
+ '<div class="gov-modal-header"><h3>Assign Fix to Governance Record</h3><button class="gov-modal-close" onclick="closeAssignModal()">&times;</button></div>' +
1176
+ '<div class="gov-modal-body">' +
1177
+ '<div style="margin-bottom:12px;padding:8px 10px;background:#f9fafb;border-radius:6px;font-size:12px;">' +
1178
+ '<strong>' + ruleId + '</strong> &mdash; ' + title + '<br>' +
1179
+ '<span style="color:#6b7280;font-family:monospace;">' + file + (line ? ':' + line : '') + '</span>' +
1180
+ '</div>' +
1181
+ '<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Governance Record *</label>' +
1182
+ '<select name="governance_record_id" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:6px;margin-bottom:12px;">' + recordOptions + '</select>' +
1183
+ '<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Assignee Name *</label>' +
1184
+ '<input name="assignee" type="text" placeholder="e.g., Jane Doe" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:6px;margin-bottom:12px;" />' +
1185
+ '<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Assignee Role</label>' +
1186
+ '<input name="assignee_role" type="text" placeholder="e.g., Security Engineer" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:6px;margin-bottom:12px;" />' +
1187
+ '<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Notes</label>' +
1188
+ '<textarea name="notes" rows="2" placeholder="Optional context for this assignment..." style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:6px;margin-bottom:12px;"></textarea>' +
1189
+ '<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Your Name (for audit log)</label>' +
1190
+ '<input name="actor_name" type="text" placeholder="Who is making this assignment" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:6px;margin-bottom:12px;" />' +
1191
+ '<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Your Role</label>' +
1192
+ '<input name="actor_role" type="text" placeholder="e.g., Tech Lead" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:6px;margin-bottom:12px;" />' +
1193
+ '</div>' +
1194
+ '<div class="gov-modal-footer"><button class="gov-btn gov-btn-outline" onclick="closeAssignModal()">Cancel</button><button class="gov-btn gov-btn-primary" onclick="submitAssignForm()">Assign</button></div>';
1195
+ modal.dataset.fkey = fkey;
1196
+ modal.dataset.ruleId = ruleId;
1197
+ modal.dataset.title = title;
1198
+ modal.dataset.file = file;
1199
+ modal.dataset.line = String(line || 0);
1200
+ modal.dataset.severity = severity;
1201
+ modal.dataset.controlIds = controlIds;
1202
+ overlay.classList.add('active');
1203
+ };
1204
+
1205
+ window.closeAssignModal = function() {
1206
+ var overlay = document.getElementById('assign-modal-overlay');
1207
+ var modal = document.getElementById('assign-modal');
1208
+ if (overlay) overlay.classList.remove('active');
1209
+ if (modal) modal.innerHTML = '';
1210
+ };
1211
+
1212
+ window.submitAssignForm = function() {
1213
+ var modal = document.getElementById('assign-modal');
1214
+ if (!modal) return;
1215
+ var inputs = modal.querySelectorAll('[name]');
1216
+ var body = {
1217
+ finding_key: modal.dataset.fkey,
1218
+ finding_rule_id: modal.dataset.ruleId,
1219
+ finding_title: modal.dataset.title,
1220
+ finding_file: modal.dataset.file,
1221
+ finding_line: parseInt(modal.dataset.line, 10),
1222
+ finding_severity: modal.dataset.severity,
1223
+ finding_control_ids: (modal.dataset.controlIds || '').split(',').filter(Boolean),
1224
+ };
1225
+ for (var i = 0; i < inputs.length; i++) {
1226
+ body[inputs[i].name] = inputs[i].value;
1227
+ }
1228
+ var btn = modal.querySelector('.gov-btn-primary');
1229
+ if (btn) { btn.textContent = 'Saving...'; btn.disabled = true; }
1230
+ fetch('/api/fix-assignments/assign', {
1231
+ method: 'POST',
1232
+ headers: { 'Content-Type': 'application/json' },
1233
+ body: JSON.stringify(body),
1234
+ })
1235
+ .then(function(r) { return r.json(); })
1236
+ .then(function(d) {
1237
+ if (d.success) { closeAssignModal(); showToast('Fix assigned! Reloading...', 'success'); setTimeout(function() { location.reload(); }, 800); }
1238
+ else { showToast(d.error || 'Failed', 'error'); if (btn) { btn.textContent = 'Assign'; btn.disabled = false; } }
1239
+ })
1240
+ .catch(function(e) { showToast('Error: ' + (e.message||'network'), 'error'); if (btn) { btn.textContent = 'Assign'; btn.disabled = false; } });
1241
+ };
1242
+
1243
+ window.resolveFindingFix = function(fkey) {
1244
+ if (!confirm('Mark this fix as resolved?')) return;
1245
+ var resolver = prompt('Your name:', '') || 'dashboard';
1246
+ var resolverRole = prompt('Your role:', '') || '';
1247
+ var method = prompt('Method (auto-fix / manual / not-applicable):', 'manual') || 'manual';
1248
+ var notes = prompt('Resolution notes (optional):', '') || '';
1249
+ fetch('/api/fix-assignments/resolve', {
1250
+ method: 'POST',
1251
+ headers: { 'Content-Type': 'application/json' },
1252
+ body: JSON.stringify({
1253
+ finding_key: fkey,
1254
+ resolved_by: resolver,
1255
+ resolved_by_role: resolverRole,
1256
+ method: method,
1257
+ resolution_notes: notes,
1258
+ actor_name: resolver,
1259
+ actor_role: resolverRole,
1260
+ }),
1261
+ })
1262
+ .then(function(r) { return r.json(); })
1263
+ .then(function(d) {
1264
+ if (d.success) { showToast('Fix resolved! Reloading...', 'success'); setTimeout(function() { location.reload(); }, 800); }
1265
+ else { showToast(d.error || 'Failed', 'error'); }
1266
+ })
1267
+ .catch(function(e) { showToast('Error: ' + (e.message||'network'), 'error'); });
1268
+ };
1269
+
1270
+ window.unassignFix = function(fkey) {
1271
+ if (!confirm('Remove this fix assignment?')) return;
1272
+ fetch('/api/fix-assignments/' + encodeURIComponent(fkey) + '/unassign', {
1273
+ method: 'POST',
1274
+ headers: { 'Content-Type': 'application/json' },
1275
+ body: '{}',
1276
+ })
1277
+ .then(function(r) { return r.json(); })
1278
+ .then(function(d) {
1279
+ if (d.success) { showToast('Unassigned! Reloading...', 'success'); setTimeout(function() { location.reload(); }, 800); }
1280
+ else { showToast(d.error || 'Failed', 'error'); }
1281
+ })
1282
+ .catch(function(e) { showToast('Error: ' + (e.message||'network'), 'error'); });
1283
+ };
1152
1284
  })();
1153
1285
  </script>
1154
1286
 
@@ -1531,13 +1663,14 @@ function renderComplianceIssuesTable(issues) {
1531
1663
  </tbody>
1532
1664
  </table>`;
1533
1665
  }
1534
- function renderComplianceFixCards(issues, idPrefix) {
1666
+ function renderComplianceFixCards(issues, idPrefix, assignmentMap, govRecords) {
1535
1667
  if (issues.length === 0) {
1536
1668
  return '<div class="card"><div class="empty-state"><div class="icon">&#10003;</div><div class="msg" style="color:#22c55e;">All controls passing</div><div class="sub">No fixes needed</div></div></div>';
1537
1669
  }
1538
1670
  const totalAuditFindings = issues.reduce((sum, i) => sum + i.auditFindings.length, 0);
1539
1671
  const criticalCount = issues.filter(i => i.severity === "critical").length;
1540
1672
  const highCount = issues.filter(i => i.severity === "high").length;
1673
+ const assignedCount = assignmentMap ? assignmentMap.size : 0;
1541
1674
  let html = '';
1542
1675
  html += `<div style="margin-bottom:20px;">`;
1543
1676
  html += `<div class="grid grid-4" style="margin-bottom:20px;">`;
@@ -1545,6 +1678,7 @@ function renderComplianceFixCards(issues, idPrefix) {
1545
1678
  html += `<div class="card stat"><div class="num" style="color:#ef4444;">${criticalCount}</div><div class="label">Critical</div></div>`;
1546
1679
  html += `<div class="card stat"><div class="num" style="color:#f97316;">${highCount}</div><div class="label">High</div></div>`;
1547
1680
  html += `<div class="card stat"><div class="num">${totalAuditFindings}</div><div class="label">Audit Findings</div></div>`;
1681
+ html += `<div class="card stat"><div class="num" style="color:${assignedCount > 0 ? "#3b82f6" : "#9ca3af"};">${assignedCount}</div><div class="label">Assigned</div></div>`;
1548
1682
  html += `</div>`;
1549
1683
  html += `</div>`;
1550
1684
  for (let i = 0; i < issues.length; i++) {
@@ -1571,8 +1705,10 @@ function renderComplianceFixCards(issues, idPrefix) {
1571
1705
  html += `<div style="font-size:13px;color:#4b5563;line-height:1.6;">${escapeHtml(issue.description)}</div>`;
1572
1706
  html += `</div>`;
1573
1707
  if (issue.auditFindings.length > 0) {
1574
- html += `<div class="fix-section"><div class="fix-section-title">Audit Evidence (${issue.auditFindings.length})</div>`;
1708
+ html += `<div class="fix-section"><div class="fix-section-title">Audit Evidence &amp; Fix Assignments (${issue.auditFindings.length})</div>`;
1575
1709
  for (const f of issue.auditFindings) {
1710
+ const fkey = `${f.ruleId}:${f.file}:${f.line || 0}`;
1711
+ const assignment = assignmentMap?.get(fkey);
1576
1712
  html += `<div class="fix-finding-item ${f.severity}">`;
1577
1713
  html += `<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">`;
1578
1714
  html += `<span class="badge badge-sev" style="background:${severityColor(f.severity)};font-size:10px;">${f.severity.toUpperCase()}</span>`;
@@ -1582,6 +1718,30 @@ function renderComplianceFixCards(issues, idPrefix) {
1582
1718
  html += `<div style="font-size:12px;color:#4b5563;margin-top:4px;">${escapeHtml(f.description)}</div>`;
1583
1719
  if (f.evidence)
1584
1720
  html += `<div class="fix-evidence">${escapeHtml(f.evidence)}</div>`;
1721
+ if (assignment) {
1722
+ const statusColorAssign = assignment.status === "fixed" || assignment.status === "verified" ? "#22c55e" : assignment.status === "in-progress" ? "#3b82f6" : "#eab308";
1723
+ html += `<div class="fix-assign-box" style="margin-top:8px;padding:10px 12px;border-radius:8px;background:#f0fdf4;border:1px solid #bbf7d0;">`;
1724
+ html += `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">`;
1725
+ html += `<span class="badge" style="background:${statusColorAssign};color:#fff;font-size:10px;text-transform:uppercase;">${escapeHtml(assignment.status)}</span>`;
1726
+ html += `<span style="font-size:12px;font-weight:600;color:#166534;">Linked: ${escapeHtml(assignment.governance_system_name)}</span>`;
1727
+ html += `<span style="font-size:11px;color:#4b5563;">Assignee: ${escapeHtml(assignment.assignee)}${assignment.assignee_role ? ' (' + escapeHtml(assignment.assignee_role) + ')' : ''}</span>`;
1728
+ html += `</div>`;
1729
+ if (assignment.resolution) {
1730
+ html += `<div style="font-size:11px;color:#4b5563;margin-top:4px;">Resolved by ${escapeHtml(assignment.resolution.resolved_by)} via ${escapeHtml(assignment.resolution.method)} on ${escapeHtml(new Date(assignment.resolution.resolved_at).toLocaleDateString())}</div>`;
1731
+ }
1732
+ html += `<div style="margin-top:6px;display:flex;gap:6px;">`;
1733
+ if (assignment.status !== "fixed" && assignment.status !== "verified") {
1734
+ html += `<button class="gov-action-btn" style="background:#22c55e;color:#fff;border:none;padding:4px 10px;border-radius:4px;font-size:11px;cursor:pointer;" onclick="event.stopPropagation();resolveFindingFix('${escapeHtml(fkey)}')">Mark Fixed</button>`;
1735
+ }
1736
+ html += `<button class="gov-action-btn" style="background:#fee2e2;color:#991b1b;border:none;padding:4px 10px;border-radius:4px;font-size:11px;cursor:pointer;" onclick="event.stopPropagation();unassignFix('${escapeHtml(fkey)}')">Unassign</button>`;
1737
+ html += `</div>`;
1738
+ html += `</div>`;
1739
+ }
1740
+ else {
1741
+ html += `<div style="margin-top:8px;">`;
1742
+ html += `<button class="gov-action-btn" style="background:#3b82f6;color:#fff;border:none;padding:4px 12px;border-radius:4px;font-size:11px;cursor:pointer;" onclick="event.stopPropagation();openAssignModal('${escapeHtml(fkey)}','${escapeHtml(f.ruleId)}','${escapeHtml(f.title.replace(/'/g, "\\'"))}','${escapeHtml(f.file)}',${f.line || 0},'${escapeHtml(f.severity)}','${escapeHtml(f.controlIds.join(","))}')">+ Assign to Governance Record</button>`;
1743
+ html += `</div>`;
1744
+ }
1585
1745
  html += `</div>`;
1586
1746
  }
1587
1747
  html += `</div>`;
@@ -1594,6 +1754,9 @@ function renderComplianceFixCards(issues, idPrefix) {
1594
1754
  }
1595
1755
  }
1596
1756
  html += `</div>`;
1757
+ const ctrlFkey = `${issue.controlId}::0`;
1758
+ const ctrlAssignment = assignmentMap?.get(ctrlFkey);
1759
+ html += renderGovernanceProvenanceSection(issue.controlId, issue.controlName, issue.severity, ctrlFkey, ctrlAssignment, govRecords);
1597
1760
  html += `<div class="fix-section"><div class="fix-section-title">Traceability</div>`;
1598
1761
  html += `<table><tbody>`;
1599
1762
  html += `<tr><td style="font-weight:600;width:160px;">Control</td><td><span class="link" onclick="showControlDetail('${escapeHtml(issue.controlId)}')">${escapeHtml(issue.controlId)}</span> &mdash; ${escapeHtml(issue.controlName)}</td></tr>`;
@@ -1610,6 +1773,142 @@ function renderComplianceFixCards(issues, idPrefix) {
1610
1773
  }
1611
1774
  return html;
1612
1775
  }
1776
+ function renderGovernanceProvenanceSection(controlId, controlName, severity, ctrlFkey, ctrlAssignment, govRecords) {
1777
+ let html = `<div class="fix-section"><div class="fix-section-title">Governance Provenance Chain</div>`;
1778
+ if (ctrlAssignment && govRecords) {
1779
+ const record = govRecords.find(r => r.id === ctrlAssignment.governance_record_id);
1780
+ const aStatusColor = ctrlAssignment.status === "fixed" || ctrlAssignment.status === "verified"
1781
+ ? "#22c55e"
1782
+ : ctrlAssignment.status === "in-progress"
1783
+ ? "#3b82f6"
1784
+ : "#eab308";
1785
+ html += `<div style="padding:12px 16px;border-radius:8px;background:#f0fdf4;border:1px solid #bbf7d0;margin-bottom:10px;">`;
1786
+ html += `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px;">`;
1787
+ html += `<span class="badge" style="background:${aStatusColor};color:#fff;font-size:10px;text-transform:uppercase;">${escapeHtml(ctrlAssignment.status)}</span>`;
1788
+ html += `<span style="font-size:12px;font-weight:600;color:#166534;">Assignee: ${escapeHtml(ctrlAssignment.assignee)}${ctrlAssignment.assignee_role ? ' (' + escapeHtml(ctrlAssignment.assignee_role) + ')' : ''}</span>`;
1789
+ html += `<span style="font-size:11px;color:#6b7280;">Assigned by ${escapeHtml(ctrlAssignment.assigned_by)} on ${escapeHtml(new Date(ctrlAssignment.assigned_at).toLocaleDateString())}</span>`;
1790
+ html += `</div>`;
1791
+ if (ctrlAssignment.notes) {
1792
+ html += `<div style="font-size:12px;color:#4b5563;margin-bottom:6px;"><strong>Notes:</strong> ${escapeHtml(ctrlAssignment.notes)}</div>`;
1793
+ }
1794
+ if (ctrlAssignment.resolution) {
1795
+ const r = ctrlAssignment.resolution;
1796
+ html += `<div style="font-size:12px;padding:6px 10px;background:#dcfce7;border-radius:6px;margin-top:6px;">`;
1797
+ html += `<strong>&#10003; Resolved</strong> by ${escapeHtml(r.resolved_by)}${r.resolved_by_role ? ' (' + escapeHtml(r.resolved_by_role) + ')' : ''} via <strong>${escapeHtml(r.method)}</strong> on ${escapeHtml(new Date(r.resolved_at).toLocaleDateString())}`;
1798
+ if (r.resolution_notes)
1799
+ html += `<br><span style="color:#4b5563;">${escapeHtml(r.resolution_notes)}</span>`;
1800
+ html += `</div>`;
1801
+ }
1802
+ html += `<div style="margin-top:8px;display:flex;gap:6px;">`;
1803
+ if (ctrlAssignment.status !== "fixed" && ctrlAssignment.status !== "verified") {
1804
+ html += `<button class="gov-action-btn" style="background:#22c55e;color:#fff;border:none;padding:4px 10px;border-radius:4px;font-size:11px;cursor:pointer;" onclick="event.stopPropagation();resolveFindingFix('${escapeHtml(ctrlFkey)}')">Mark Fixed</button>`;
1805
+ }
1806
+ html += `<button class="gov-action-btn" style="background:#fee2e2;color:#991b1b;border:none;padding:4px 10px;border-radius:4px;font-size:11px;cursor:pointer;" onclick="event.stopPropagation();unassignFix('${escapeHtml(ctrlFkey)}')">Unassign</button>`;
1807
+ html += `</div>`;
1808
+ html += `</div>`;
1809
+ if (record) {
1810
+ html += renderProvenanceChainInline(record);
1811
+ }
1812
+ }
1813
+ else {
1814
+ html += `<div style="padding:12px 16px;border-radius:8px;background:#f9fafb;border:1px dashed #d1d5db;">`;
1815
+ html += `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">`;
1816
+ html += `<div style="font-size:13px;color:#6b7280;">This control is not linked to any governance record. Assign it to create a provenance chain for auditors.</div>`;
1817
+ html += `<button class="gov-action-btn" style="background:#4f46e5;color:#fff;border:none;padding:6px 14px;border-radius:6px;font-size:12px;cursor:pointer;font-weight:600;white-space:nowrap;" onclick="event.stopPropagation();openAssignModal('${escapeHtml(ctrlFkey)}','${escapeHtml(controlId)}','${escapeHtml(controlName.replace(/'/g, "\\'"))}','',0,'${escapeHtml(severity)}','${escapeHtml(controlId)}')">+ Assign to Governance Record</button>`;
1818
+ html += `</div>`;
1819
+ html += `</div>`;
1820
+ if (issueHasFindingLevelAssignments(ctrlFkey, controlId)) {
1821
+ html += `<div style="margin-top:8px;font-size:11px;color:#6b7280;">&#8505; Individual audit findings within this control may already be assigned at the finding level below.</div>`;
1822
+ }
1823
+ }
1824
+ html += `</div>`;
1825
+ return html;
1826
+ }
1827
+ function issueHasFindingLevelAssignments(ctrlFkey, controlId) {
1828
+ return false;
1829
+ }
1830
+ function renderProvenanceChainInline(record) {
1831
+ let html = `<div style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">`;
1832
+ html += `<div style="background:#f3f4f6;padding:10px 14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">`;
1833
+ html += `<span style="font-size:14px;font-weight:700;color:#1f2937;">${escapeHtml(record.system_name)}</span>`;
1834
+ html += `<span class="badge" style="background:${record.status === "approved" ? "#22c55e" : record.status === "rejected" || record.status === "revoked" ? "#ef4444" : "#eab308"};color:#fff;font-size:10px;text-transform:uppercase;">${escapeHtml(record.status)}</span>`;
1835
+ html += `<span class="badge" style="background:${record.risk_level === "critical" ? "#ef4444" : record.risk_level === "high" ? "#f97316" : record.risk_level === "medium" ? "#eab308" : "#22c55e"};color:#fff;font-size:10px;text-transform:uppercase;">${escapeHtml(record.risk_level)} RISK</span>`;
1836
+ html += `<span style="font-size:11px;color:#9ca3af;font-family:monospace;">${escapeHtml(record.id)}</span>`;
1837
+ html += `</div>`;
1838
+ html += `<div style="padding:10px 14px;">`;
1839
+ html += `<table style="width:100%;font-size:12px;border-collapse:collapse;">`;
1840
+ if (record.approval) {
1841
+ const a = record.approval;
1842
+ const decColor = a.decision === "approved" ? "#22c55e" : "#ef4444";
1843
+ html += `<tr style="border-bottom:1px solid #f3f4f6;">`;
1844
+ html += `<td style="padding:6px 8px;font-weight:600;width:140px;color:#374151;">Approval</td>`;
1845
+ html += `<td style="padding:6px 8px;">`;
1846
+ html += `<span style="color:${decColor};font-weight:600;">${escapeHtml(a.decision.toUpperCase())}</span> by ${escapeHtml(a.approver_name)} (${escapeHtml(a.approver_role)})`;
1847
+ if (a.valid_until)
1848
+ html += ` &mdash; valid until ${escapeHtml(a.valid_until)}`;
1849
+ html += `</td></tr>`;
1850
+ }
1851
+ else {
1852
+ html += `<tr style="border-bottom:1px solid #f3f4f6;"><td style="padding:6px 8px;font-weight:600;width:140px;color:#374151;">Approval</td><td style="padding:6px 8px;color:#ef4444;">&#10007; Not recorded</td></tr>`;
1853
+ }
1854
+ if (record.risk_assessment) {
1855
+ const ra = record.risk_assessment;
1856
+ html += `<tr style="border-bottom:1px solid #f3f4f6;">`;
1857
+ html += `<td style="padding:6px 8px;font-weight:600;color:#374151;">Risk Assessment</td>`;
1858
+ html += `<td style="padding:6px 8px;">Score: <strong>${escapeHtml(ra.risk_score)}</strong> &mdash; Residual: ${escapeHtml(ra.residual_risk)} (${escapeHtml(ra.methodology)})</td>`;
1859
+ html += `</tr>`;
1860
+ }
1861
+ else {
1862
+ html += `<tr style="border-bottom:1px solid #f3f4f6;"><td style="padding:6px 8px;font-weight:600;color:#374151;">Risk Assessment</td><td style="padding:6px 8px;color:#9ca3af;">&#10007; Not assessed</td></tr>`;
1863
+ }
1864
+ if (record.policy_basis) {
1865
+ const pb = record.policy_basis;
1866
+ html += `<tr style="border-bottom:1px solid #f3f4f6;">`;
1867
+ html += `<td style="padding:6px 8px;font-weight:600;color:#374151;">Policy Basis</td>`;
1868
+ html += `<td style="padding:6px 8px;">${escapeHtml(pb.policy_name)} v${escapeHtml(pb.version)} (${escapeHtml(pb.standard)})</td>`;
1869
+ html += `</tr>`;
1870
+ }
1871
+ else {
1872
+ html += `<tr style="border-bottom:1px solid #f3f4f6;"><td style="padding:6px 8px;font-weight:600;color:#374151;">Policy Basis</td><td style="padding:6px 8px;color:#9ca3af;">&#10007; Not documented</td></tr>`;
1873
+ }
1874
+ html += `<tr style="border-bottom:1px solid #f3f4f6;">`;
1875
+ html += `<td style="padding:6px 8px;font-weight:600;color:#374151;">Evidence Chain</td>`;
1876
+ if (record.evidence.length > 0) {
1877
+ html += `<td style="padding:6px 8px;">`;
1878
+ for (const e of record.evidence) {
1879
+ html += `<span style="display:inline-block;margin-right:6px;margin-bottom:2px;padding:2px 8px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:4px;font-size:11px;">${escapeHtml(e.title)} <span style="color:#6b7280;">(${escapeHtml(e.source_system)}: ${escapeHtml(e.reference)})</span></span>`;
1880
+ }
1881
+ html += `</td>`;
1882
+ }
1883
+ else {
1884
+ html += `<td style="padding:6px 8px;color:#9ca3af;">&#10007; No evidence references</td>`;
1885
+ }
1886
+ html += `</tr>`;
1887
+ if (record.review_cycle) {
1888
+ const rc = record.review_cycle;
1889
+ html += `<tr style="border-bottom:1px solid #f3f4f6;">`;
1890
+ html += `<td style="padding:6px 8px;font-weight:600;color:#374151;">Review Cycle</td>`;
1891
+ html += `<td style="padding:6px 8px;">${escapeHtml(rc.frequency)} &mdash; next review: ${escapeHtml(rc.next_review)}</td>`;
1892
+ html += `</tr>`;
1893
+ }
1894
+ else {
1895
+ html += `<tr style="border-bottom:1px solid #f3f4f6;"><td style="padding:6px 8px;font-weight:600;color:#374151;">Review Cycle</td><td style="padding:6px 8px;color:#9ca3af;">&#10007; Not scheduled</td></tr>`;
1896
+ }
1897
+ html += `<tr>`;
1898
+ html += `<td style="padding:6px 8px;font-weight:600;color:#374151;">Provenance Chain</td>`;
1899
+ const chainParts = [];
1900
+ chainParts.push(record.approval ? "&#10003;" : "&#10007;");
1901
+ chainParts.push(record.risk_assessment ? "&#10003;" : "&#10007;");
1902
+ chainParts.push(record.policy_basis ? "&#10003;" : "&#10007;");
1903
+ chainParts.push(record.evidence.length > 0 ? "&#10003;" : "&#10007;");
1904
+ chainParts.push(record.review_cycle ? "&#10003;" : "&#10007;");
1905
+ html += `<td style="padding:6px 8px;font-size:11px;">Approval ${chainParts[0]} &rarr; Risk ${chainParts[1]} &rarr; Policy ${chainParts[2]} &rarr; Evidence ${chainParts[3]} &rarr; Review ${chainParts[4]}</td>`;
1906
+ html += `</tr>`;
1907
+ html += `</table>`;
1908
+ html += `</div>`;
1909
+ html += `</div>`;
1910
+ return html;
1911
+ }
1613
1912
  function renderActivityLogSection(entries) {
1614
1913
  if (!entries || entries.length === 0) {
1615
1914
  return `<div class="card">
@@ -1886,8 +2185,8 @@ function renderGovernanceSection(data) {
1886
2185
  html += `<div><strong>Decision:</strong> <span style="color:${a.decision === "approved" ? "#22c55e" : "#ef4444"};font-weight:600;">${a.decision.toUpperCase()}</span></div>`;
1887
2186
  html += `<div><strong>Date:</strong> ${escapeHtml(a.decision_date)}</div>`;
1888
2187
  html += `<div><strong>Validity:</strong> ${escapeHtml(a.valid_from)} &rarr; ${escapeHtml(a.valid_until || "indefinite")}</div>`;
1889
- if (a.conditions.length > 0) {
1890
- html += `<div><strong>Conditions:</strong> ${a.conditions.map(c => escapeHtml(c)).join("; ")}</div>`;
2188
+ if (a.conditions && a.conditions.length > 0) {
2189
+ html += `<div><strong>Conditions:</strong> ${(a.conditions || []).map(c => escapeHtml(c)).join("; ")}</div>`;
1891
2190
  }
1892
2191
  if (a.rationale) {
1893
2192
  html += `<div><strong>Rationale:</strong> ${escapeHtml(a.rationale)}</div>`;
@@ -1906,8 +2205,8 @@ function renderGovernanceSection(data) {
1906
2205
  html += `<div><strong>Methodology:</strong> ${escapeHtml(ra.methodology)}</div>`;
1907
2206
  html += `<div><strong>Risk Score:</strong> ${escapeHtml(ra.risk_score)} &mdash; <strong>Residual:</strong> ${escapeHtml(ra.residual_risk)}</div>`;
1908
2207
  html += `<div><strong>Date:</strong> ${escapeHtml(ra.assessment_date)}</div>`;
1909
- if (ra.identified_risks.length > 0) {
1910
- html += `<div><strong>Identified Risks:</strong> ${ra.identified_risks.map(r => escapeHtml(r)).join(", ")}</div>`;
2208
+ if (ra.identified_risks && ra.identified_risks.length > 0) {
2209
+ html += `<div><strong>Identified Risks:</strong> ${(ra.identified_risks || []).map(r => escapeHtml(r)).join(", ")}</div>`;
1911
2210
  }
1912
2211
  html += `</div>`;
1913
2212
  html += `</div>`;
@@ -1918,8 +2217,8 @@ function renderGovernanceSection(data) {
1918
2217
  html += `<div style="font-size:13px;line-height:1.8;">`;
1919
2218
  html += `<div><strong>Policy:</strong> ${escapeHtml(pb.policy_name)} (${escapeHtml(pb.policy_id)} v${escapeHtml(pb.version)})</div>`;
1920
2219
  html += `<div><strong>Standard:</strong> ${escapeHtml(pb.standard)}</div>`;
1921
- if (pb.clauses.length > 0) {
1922
- html += `<div><strong>Clauses:</strong> ${pb.clauses.map(c => escapeHtml(c)).join(", ")}</div>`;
2220
+ if (pb.clauses && pb.clauses.length > 0) {
2221
+ html += `<div><strong>Clauses:</strong> ${(pb.clauses || []).map(c => escapeHtml(c)).join(", ")}</div>`;
1923
2222
  }
1924
2223
  html += `</div>`;
1925
2224
  html += `</div>`;
@@ -1957,7 +2256,7 @@ function renderGovernanceSection(data) {
1957
2256
  html += `<div><strong>Committee:</strong> ${escapeHtml(c.committee_name)}</div>`;
1958
2257
  html += `<div><strong>Meeting:</strong> ${escapeHtml(c.meeting_date)} (${escapeHtml(c.meeting_reference)})</div>`;
1959
2258
  if (c.attendees.length > 0) {
1960
- html += `<div><strong>Attendees:</strong> ${c.attendees.map(a => escapeHtml(a)).join(", ")}</div>`;
2259
+ html += `<div><strong>Attendees:</strong> ${(c.attendees || []).map(a => escapeHtml(a)).join(", ")}</div>`;
1961
2260
  }
1962
2261
  html += `</div>`;
1963
2262
  html += `</div>`;
@@ -1983,7 +2282,9 @@ function renderGovernanceSection(data) {
1983
2282
  return html;
1984
2283
  }
1985
2284
  function escapeHtml(str) {
1986
- return str
2285
+ if (str === null || str === undefined)
2286
+ return "";
2287
+ return String(str)
1987
2288
  .replace(/&/g, "&amp;")
1988
2289
  .replace(/</g, "&lt;")
1989
2290
  .replace(/>/g, "&gt;")
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "dependencies": {
3
- "@greenarmor/ges-audit-engine": "1.4.3",
4
- "@greenarmor/ges-core": "1.4.3",
5
- "@greenarmor/ges-policy-engine": "1.4.3",
6
- "@greenarmor/ges-report-generator": "1.4.3",
7
- "@greenarmor/ges-scoring-engine": "1.4.3"
3
+ "@greenarmor/ges-audit-engine": "1.5.1",
4
+ "@greenarmor/ges-core": "1.5.1",
5
+ "@greenarmor/ges-policy-engine": "1.5.1",
6
+ "@greenarmor/ges-report-generator": "1.5.1",
7
+ "@greenarmor/ges-scoring-engine": "1.5.1"
8
8
  },
9
9
  "description": "GESF Web Dashboard - Visual compliance dashboard for teams",
10
10
  "devDependencies": {
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "type": "module",
43
43
  "types": "./dist/index.d.ts",
44
- "version": "1.4.3",
44
+ "version": "1.5.1",
45
45
  "scripts": {
46
46
  "build": "tsc",
47
47
  "clean": "rm -rf dist tsconfig.tsbuildinfo",