kingfisher/docs/access-map-viewer/index.html

3136 lines
No EOL
108 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kingfisher Access Map Viewer</title>
<style>
:root {
color-scheme: dark;
--brand: #0e7c56;
--brand-dark: #0c6d4d;
--brand-soft: #10241c;
--bg: #060b16;
--surface: #0d1424;
--surface-muted: #111a2b;
--surface-strong: #0f192c;
--text-main: #f3f4f6;
--text-muted: #c7d2e2;
--border: #1f2a3f;
--border-strong: #2c3a55;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.15);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.25), 0 2px 4px -2px rgb(0 0 0 / 0.2);
--critical: #fca5a5;
--success: #34d399;
--radius: 8px;
--hover: #1f2937;
--table-header: #0f172a;
--code-bg: #0d1526;
--code-border: #1f2937;
--code-border-strong: rgba(56, 189, 248, 0.4);
--code-text: #e2e8f0;
--code-accent: rgba(56, 189, 248, 0.08);
--link-strong: #a5f3fc;
--link-strong-hover: #e0f2fe;
--link-underline: rgba(255, 255, 255, 0.35);
}
:root[data-theme="light"] {
color-scheme: light;
--brand: #0e7c56;
--brand-dark: #07402c;
--brand-soft: #e6f4ed;
--bg: #f3f4f6;
--surface: #ffffff;
--surface-strong: #f4f6fb;
--surface-muted: #f9fafb;
--text-main: #111827;
--text-muted: #6b7280;
--border: #e5e7eb;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--critical: #dc2626;
--success: #059669;
--hover: #e5e7eb;
--table-header: #f9fafb;
--code-bg: #f8fafc;
--code-border: #e5e7eb;
--code-border-strong: rgba(14, 124, 86, 0.2);
--code-text: #0f172a;
--code-accent: rgba(14, 124, 86, 0.08);
--link-strong: #1d4ed8;
--link-strong-hover: #1e3a8a;
--link-underline: rgba(30, 64, 175, 0.35);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text-main);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
/* Header */
.hero {
background: var(--brand);
color: #ffffff;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-md);
position: sticky;
top: 0;
z-index: 50;
}
.hero__brand {
display: flex;
align-items: center;
gap: 12px;
}
.hero__icon {
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.15);
color: white;
border-radius: 6px;
display: grid;
place-items: center;
font-weight: 600;
font-size: 18px;
}
.hero__title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
}
.hero__subtitle {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
margin-left: 10px;
padding-left: 10px;
border-left: 1px solid rgba(255, 255, 255, 0.25);
}
.layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 18px;
align-items: start;
}
.sidebar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 14px;
position: sticky;
top: 82px;
display: flex;
flex-direction: column;
gap: 12px;
}
.nav-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.nav-button {
width: 100%;
text-align: left;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid transparent;
background: var(--surface-muted);
color: var(--text-main);
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: border 0.15s ease, background 0.15s ease, transform 0.1s ease;
}
.nav-button:hover {
background: var(--hover);
border-color: var(--border);
}
.nav-button.active {
background: var(--brand-soft);
border-color: var(--brand);
color: var(--brand-dark);
box-shadow: var(--shadow-sm);
}
.nav-icon {
font-size: 16px;
opacity: 0.9;
}
.view {
display: flex;
flex-direction: column;
gap: 18px;
}
.view-stack {
display: flex;
flex-direction: column;
gap: 18px;
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
top: auto;
}
}
.info-banner {
background: var(--surface);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
text-align: center;
padding: 10px 16px;
font-size: 13px;
}
.page {
max-width: 1600px;
margin: 24px auto;
padding: 0 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.panel__header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--surface-strong);
}
.panel__title h3 { margin: 0; font-size: 16px; font-weight: 600; }
.panel__title p { margin: 4px 0 0; font-size: 13px; color: var(--text-muted); }
/* Upload */
.upload-area {
padding: 32px;
text-align: center;
background: var(--surface-muted);
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid var(--border);
border-radius: var(--radius);
}
.upload-area:hover,
.upload-area.active {
background: var(--brand-soft);
border-color: var(--brand);
}
.upload-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.8; }
.upload-text { font-size: 16px; font-weight: 500; }
.upload-sub { color: var(--text-muted); margin-top: 4px; }
/* Metrics */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
padding: 20px;
gap: 16px;
}
.metric {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
box-shadow: var(--shadow-sm);
}
.metric__label {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric__value {
font-size: 26px;
font-weight: 700;
margin-top: 6px;
}
.metric.critical .metric__value { color: var(--critical); }
.metric.success .metric__value { color: var(--success); }
/* Access Map */
.am-container {
display: grid;
grid-template-columns: 2fr 1fr; /* tree 2/3, detail 1/3 */
height: 600px;
background: var(--surface);
}
.am-sidebar {
border-right: 1px solid var(--border);
background: var(--surface-muted);
overflow-y: auto;
padding: 16px;
}
.am-main {
padding: 24px;
overflow-y: auto;
background: var(--surface);
}
.am-search {
position: sticky;
top: 0;
background: var(--surface-muted);
padding-bottom: 10px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
z-index: 5;
}
.am-search input {
width: 100%;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 13px;
background: var(--surface);
color: var(--text-main);
}
.am-search input:focus {
outline: none;
border-color: var(--brand);
}
.am-empty-hint {
padding: 12px 16px;
background: var(--surface-muted);
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text-muted);
margin: 12px 20px 0;
}
/* Tree */
.tree-node {
position: relative;
margin-left: 20px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
.tree-node:last-child {
border-left: none;
}
.tree-node::before {
content: "";
position: absolute;
top: 14px;
left: 0;
width: 12px;
height: 1px;
background: var(--border);
}
.node-content {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
user-select: none;
font-size: 13px;
}
.node-content:hover {
background: var(--hover);
}
.node-icon {
width: 20px;
height: 20px;
border-radius: 4px;
display: grid;
place-items: center;
font-size: 12px;
flex-shrink: 0;
}
.icon-identity { background: #e0e7ff; color: #3730a3; }
.icon-resource { background: #ffe4e6; color: #9f1239; }
.icon-permission { background: #dcfce7; color: #166534; }
.icon-group { background: #fef3c7; color: #92400e; }
.tree-children {
padding-top: 4px;
padding-left: 8px;
display: none;
}
.tree-children.open {
display: block;
}
/* Access detail */
.am-card-header {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.am-card-title {
font-size: 18px;
font-weight: 700;
display: flex;
align-items: center;
gap: 12px;
}
.detail-icon-lg {
width: 40px;
height: 40px;
border-radius: 8px;
display: grid;
place-items: center;
background: var(--surface-muted);
border: 1px solid var(--border);
font-size: 20px;
}
.am-card-meta {
display: flex;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
flex-wrap: wrap;
}
.badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
border: 1px solid transparent;
}
.badge-aws { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
.badge-gcp { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
.badge-azure { background: #ecfeff; color: #0e7490; border-color: #a5f3fc; }
.badge-github { background: #f4f4f5; color: #18181b; border-color: #d4d4d8; }
.badge-gitlab { background: #fff1f2; color: #be123c; border-color: #fecdd3; }
.badge-perm { background: #ecfdf5; color: #16a34a; border-color: #bbf7d0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.detail-field label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.detail-field div {
font-weight: 500;
font-size: 14px;
word-break: break-all;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
padding: 0 20px 20px;
}
.chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow-sm);
display: flex;
gap: 12px;
align-items: center;
min-height: 220px;
}
.chart-legend {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: var(--text-main);
}
.chart-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
border: 1px solid var(--border);
flex-shrink: 0;
}
#status-chart {
background: var(--surface-muted);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
/* Findings table */
.table-container { width: 100%; overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th {
text-align: left;
padding: 12px 16px;
background: var(--table-header);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-weight: 600;
}
.table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: var(--surface-muted); cursor: pointer; }
.sortable {
cursor: pointer;
user-select: none;
}
.sort-indicator {
font-size: 10px;
margin-left: 4px;
opacity: 0.6;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.status-badge.active { background: #ecfdf5; color: #15803d; }
.status-badge.inactive { background: #fef2f2; color: #b91c1c; }
.status-badge.unknown { background: var(--surface-muted); color: var(--text-muted); }
/* Finding detail snippet */
.snippet-box {
position: relative;
background: linear-gradient(90deg, var(--code-accent), transparent), var(--code-bg);
color: var(--code-text);
padding: 16px;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
max-width: 100%;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid var(--code-border);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35), 0 0 0 1px var(--code-border-strong);
}
.snippet-box::after {
content: "";
position: absolute;
inset: 0;
border-radius: 8px;
pointer-events: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
}
#fd-validation-box {
margin-top: 16px;
padding: 14px 16px;
background: #ecfdf5;
border: 1px solid #bbf7d0;
border-radius: 6px;
}
#fd-validation-box label {
font-size: 13px;
font-weight: 600;
color: #166534;
display: block;
margin-bottom: 4px;
}
#fd-validation-res {
white-space: pre-wrap;
font-size: 12px;
color: #166534;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
/* Validate command box - blue theme */
#fd-validate-box {
margin-top: 16px;
padding: 14px 16px;
background: #eff6ff;
border: 1px solid #93c5fd;
border-radius: 6px;
}
#fd-validate-box label {
font-size: 13px;
font-weight: 600;
color: #1e40af;
display: block;
margin-bottom: 8px;
}
#fd-validate-cmd {
flex: 1;
font-size: 12px;
color: #1e3a8a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: rgba(255, 255, 255, 0.5);
padding: 8px 12px;
border-radius: 4px;
word-break: break-all;
white-space: pre-wrap;
}
.copy-btn-validate {
color: #1e40af;
background: #dbeafe;
border: 1px solid #3b82f6;
}
.copy-btn-validate:hover {
background: #bfdbfe;
border-color: #2563eb;
}
/* Revoke command box - amber/warning theme */
#fd-revoke-box {
margin-top: 16px;
padding: 14px 16px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 6px;
}
#fd-revoke-box label {
font-size: 13px;
font-weight: 600;
color: #92400e;
display: block;
margin-bottom: 8px;
}
#fd-revoke-cmd {
flex: 1;
font-size: 12px;
color: #78350f;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: rgba(255, 255, 255, 0.5);
padding: 8px 12px;
border-radius: 4px;
word-break: break-all;
white-space: pre-wrap;
}
/* Shared command container styles */
.cmd-container {
display: flex;
align-items: flex-start;
gap: 10px;
}
.copy-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
font-size: 12px;
font-weight: 500;
color: #78350f;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s;
}
.copy-btn:hover {
background: #fde68a;
border-color: #d97706;
}
.copy-btn.copied {
background: #d1fae5;
border-color: #10b981;
color: #065f46;
}
.path-area {
width: 100%;
min-height: 48px;
background: var(--surface-muted);
color: var(--text-main);
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border-strong);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 13px;
resize: vertical;
overflow: auto;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.link-mono a {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
color: var(--link-strong);
text-decoration: underline;
text-decoration-color: var(--link-underline);
text-decoration-thickness: 2px;
font-weight: 600;
word-break: break-all;
}
.link-mono a:hover {
color: var(--link-strong-hover);
text-decoration-color: currentColor;
}
/* Search & pagination */
.search-input {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
width: 220px;
font-size: 13px;
background: var(--surface);
color: var(--text-main);
}
.rows-label {
font-size: 12px;
color: var(--text-muted);
}
.rows-select {
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
font-size: 13px;
color: var(--text-main);
}
.pager {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.pager-btn {
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
color: var(--text-main);
}
.pager-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* Buttons & loader */
.btn {
background: var(--surface);
border: 1px solid var(--border);
padding: 8px 16px;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
color: var(--text-main);
transition: background 0.1s, border-color 0.1s;
}
.btn:hover {
background: var(--surface-muted);
border-color: var(--border);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#theme-toggle {
background: #f9fafb;
color: var(--brand);
border-color: rgba(255, 255, 255, 0.7);
font-weight: 700;
box-shadow: var(--shadow-sm);
}
#theme-toggle:hover {
background: #ffffff;
border-color: rgba(255, 255, 255, 0.9);
color: var(--brand-dark);
}
:root[data-theme="light"] #theme-toggle {
background: #0e7c56;
color: #ffffff;
border-color: #0c6d4d;
}
:root[data-theme="light"] #theme-toggle:hover {
background: #0c6d4d;
border-color: #0a5d40;
}
.hidden { display: none !important; }
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.2);
backdrop-filter: blur(2px);
z-index: 100;
display: grid;
place-items: center;
}
.spinner {
width: 40px;
height: 40px;
border-radius: 999px;
border: 3px solid var(--border);
border-top-color: #0e7c56;
animation: spin 0.8s linear infinite;
}
.progress-track {
width: 220px;
height: 6px;
border-radius: 999px;
background: var(--border);
overflow: hidden;
margin: 12px auto 0;
}
.progress-inner {
width: 40%;
height: 100%;
background: linear-gradient(90deg, #0e7c56, #22c55e, #f59e0b);
animation: progress-move 0.9s linear infinite;
}
@keyframes progress-move {
0% { transform: translateX(-100%); }
50% { transform: translateX(0%); }
100% { transform: translateX(100%); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="loader" class="loading-overlay hidden">
<div style="background:var(--surface); padding:18px 22px; border-radius:10px; border:1px solid var(--border); box-shadow:var(--shadow-md); text-align:center; min-width:260px; color:var(--text-main);">
<div class="spinner" style="margin:0 auto 12px;"></div>
<div class="progress-track"><div class="progress-inner"></div></div>
<div id="loader-text" style="margin-top:10px; font-size:13px; color:var(--text-muted);">
Processing report...
</div>
</div>
</div>
<header class="hero">
<div class="hero__brand">
<div class="hero__icon">K</div>
<div style="display:flex; align-items:center;">
<span class="hero__title">Kingfisher Viewer</span>
<span class="hero__subtitle">Access Map &amp; Findings</span>
</div>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn" id="theme-toggle" type="button">Light Mode</button>
<button class="btn" id="reset-btn">Load New Report</button>
</div>
</header>
<div class="info-banner">Client-Side Only. No data is uploaded anywhere.</div>
<main class="page">
<section id="upload-section" class="panel" style="max-width: 640px; margin: 0 auto;">
<div class="panel__header">
<div class="panel__title">
<h3>Upload Report</h3>
<p>Analyze Kingfisher JSON / JSONL output (auto-loads if provided on the CLI)</p>
</div>
</div>
<div style="padding:22px;">
<div class="upload-area" id="drop-zone">
<div class="upload-icon">📄</div>
<div class="upload-text">Drag &amp; drop a report here</div>
<div class="upload-sub">…or click to choose a file</div>
<div class="upload-sub">Your file stays in the browser—load JSON or JSONL reports locally.</div>
<input type="file" id="file-input" hidden accept=".json,.jsonl">
</div>
<div id="error-msg" class="hidden" style="margin-top:16px; padding:10px 12px; background:#fef2f2; border:1px solid #fecaca; border-radius:6px; color:#b91c1c; font-size:13px;"></div>
</div>
</section>
<div id="dashboard" class="hidden">
<div class="layout">
<aside class="sidebar">
<div class="nav-group">
<button class="nav-button active" data-view-target="view-dashboard">
<span class="nav-icon">📊</span>
Dashboard
</button>
<button class="nav-button" data-view-target="view-access">
<span class="nav-icon">🕸️</span>
Access Map
</button>
<button class="nav-button" data-view-target="view-findings">
<span class="nav-icon">📄</span>
Findings
</button>
</div>
<div class="nav-group" style="display:flex; flex-direction:column; gap:10px;">
<button class="btn" id="download-findings-report" type="button" style="width:100%;">Download Findings Report</button>
<div style="display:flex; flex-direction:column; gap:6px;">
<span style="font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:var(--text-muted);">Findings scope</span>
<select id="report-scope" class="rows-select">
<option value="all" selected>All findings</option>
<option value="filtered">Filtered findings</option>
</select>
</div>
<button class="btn" id="download-access-report" type="button" style="width:100%;" disabled>Download Access Map Report</button>
</div>
</aside>
<div class="view-stack">
<section id="view-dashboard" class="view">
<section class="panel">
<div class="metrics-grid">
<div class="metric">
<div class="metric__label">Total Findings</div>
<div class="metric__value" id="stat-total">0</div>
</div>
<div class="metric critical">
<div class="metric__label">High Confidence</div>
<div class="metric__value" id="stat-high">0</div>
</div>
<div class="metric success">
<div class="metric__label">Active Credentials</div>
<div class="metric__value" id="stat-active">0</div>
</div>
<div class="metric">
<div class="metric__label">Identities Mapped</div>
<div class="metric__value" id="stat-identities">0</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel__header">
<div class="panel__title">
<h3>Finding Status Distribution</h3>
<p>Active vs Inactive vs Not Attempted</p>
</div>
</div>
<div class="chart-grid">
<div class="chart-card">
<canvas id="status-chart" width="260" height="220"></canvas>
<div class="chart-legend" id="status-legend">
<!-- filled by JS -->
</div>
</div>
</div>
</section>
</section>
<section id="view-access" class="view hidden">
<section class="panel" id="am-section">
<div class="panel__header">
<div class="panel__title">
<h3>Access Map</h3>
<p>Identity hierarchy: Identity &gt; Resources &gt; Resource &gt; Permissions</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn" id="copy-access-map" type="button">Copy Access Map</button>
<button class="btn" id="am-toggle" type="button">Collapse</button>
</div>
</div>
<div id="am-empty-notice" class="am-empty-hint hidden">No Access Map entries in this report.</div>
<div class="am-container" id="am-container">
<div class="am-sidebar">
<div class="am-search">
<input id="tree-search" type="text" placeholder="Filter identities or resources…">
</div>
<div id="am-tree-root" style="padding-top:8px;">
<div style="color:var(--text-muted); font-size:13px; text-align:center; margin-top:32px;">
No access map data found in report.
</div>
</div>
</div>
<div class="am-main">
<div id="am-empty-state" style="display:grid; place-items:center; height:100%; color:var(--text-muted);">
Select an item in the tree to view details.
</div>
<div id="am-detail-view" class="hidden">
<div class="am-card-header">
<div class="am-card-title">
<div id="am-detail-icon" class="detail-icon-lg">👤</div>
<div>
<div id="am-detail-name">Identity</div>
<div class="am-card-meta" id="am-detail-meta"></div>
</div>
</div>
</div>
<div class="detail-grid">
<div class="detail-field">
<label>Type</label>
<div id="am-detail-type">Identity</div>
</div>
<div class="detail-field">
<label>Cloud Provider</label>
<div id="am-detail-cloud">-</div>
</div>
</div>
<div id="am-token-container" class="hidden">
<h4 style="margin-bottom:10px; border-bottom:1px solid var(--border); padding-bottom:6px;">Token Details</h4>
<div class="detail-grid">
<div class="detail-field">
<label>Token Name</label>
<div id="am-token-name">-</div>
</div>
<div class="detail-field">
<label>Username</label>
<div id="am-token-username">-</div>
</div>
<div class="detail-field">
<label>Token Type</label>
<div id="am-token-type">-</div>
</div>
<div class="detail-field">
<label>Account Type</label>
<div id="am-token-account-type">-</div>
</div>
<div class="detail-field">
<label>User ID</label>
<div id="am-token-user">-</div>
</div>
<div class="detail-field">
<label>Company</label>
<div id="am-token-company">-</div>
</div>
<div class="detail-field">
<label>Created At</label>
<div id="am-token-created">-</div>
</div>
<div class="detail-field">
<label>Location</label>
<div id="am-token-location">-</div>
</div>
<div class="detail-field">
<label>Last Used</label>
<div id="am-token-last-used">-</div>
</div>
<div class="detail-field">
<label>Email</label>
<div id="am-token-email">-</div>
</div>
<div class="detail-field">
<label>Expires At</label>
<div id="am-token-expires">-</div>
</div>
<div class="detail-field">
<label>Profile URL</label>
<div id="am-token-url">-</div>
</div>
<div class="detail-field">
<label>Instance Version</label>
<div id="am-token-version">-</div>
</div>
<div class="detail-field">
<label>Enterprise</label>
<div id="am-token-enterprise">-</div>
</div>
</div>
<div id="am-token-scopes" style="display:flex; flex-wrap:wrap; gap:6px;"></div>
</div>
<div id="am-perms-container" class="hidden">
<h4 style="margin-bottom:10px; border-bottom:1px solid var(--border); padding-bottom:6px;">Permissions</h4>
<div id="am-perms-list" style="display:flex; flex-wrap:wrap; gap:6px;"></div>
</div>
</div>
</div>
</div>
</section>
</section>
<section id="view-findings" class="view hidden">
<section class="panel">
<div class="panel__header">
<div class="panel__title">
<h3>Findings</h3>
<p>Detailed list of detected secrets</p>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<button class="btn" id="download-json" type="button">Download JSON</button>
<button class="btn" id="download-csv" type="button">Export CSV</button>
<input type="text" id="search-input" class="search-input" placeholder="Search rule, path, snippet">
<select id="validation-filter" class="rows-select">
<option value="all" selected>All validation states</option>
<option value="active">Active Credential</option>
<option value="inactive">Inactive Credential</option>
<option value="not_attempted">Not Attempted</option>
</select>
<span class="rows-label">Rows</span>
<select id="rows-select" class="rows-select">
<option value="10" selected>10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
<div class="pager">
<button id="page-prev" class="pager-btn" type="button">&lsaquo;</button>
<span id="page-info">0 of 0</span>
<button id="page-next" class="pager-btn" type="button">&rsaquo;</button>
</div>
</div>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th class="sortable" data-field="rule">
Rule <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="location">
Location <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="validation">
Validation <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="confidence">
Confidence <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="line">
Line <span class="sort-indicator"></span>
</th>
</tr>
</thead>
<tbody id="findings-body"></tbody>
</table>
</div>
</section>
<section id="finding-detail" class="panel hidden">
<div class="panel__header">
<div class="panel__title">
<h3>Finding Details</h3>
</div>
<button class="btn" onclick="document.getElementById('finding-detail').classList.add('hidden')">Close</button>
</div>
<div style="padding:20px 22px 22px;">
<div class="detail-grid">
<div class="detail-field">
<label>Rule ID</label>
<div id="fd-rule-id"></div>
</div>
<div class="detail-field">
<label>Fingerprint</label>
<div id="fd-fingerprint"></div>
</div>
<div class="detail-field">
<label>Entropy</label>
<div id="fd-entropy"></div>
</div>
<div class="detail-field">
<label>Validation Status</label>
<div id="fd-validation-status"></div>
</div>
<div class="detail-field">
<label>Git Commit</label>
<div id="fd-commit"></div>
</div>
<div class="detail-field" id="fd-committer-email-wrapper">
<label>Committer Email</label>
<div id="fd-committer-email"></div>
</div>
<div class="detail-field">
<label>File Path</label>
<textarea id="fd-path" class="path-area" readonly></textarea>
</div>
<div class="detail-field" id="fd-git-url-wrapper">
<label>Git URL</label>
<div id="fd-git-url" class="link-mono"></div>
</div>
</div>
<label style="font-size:12px; color:var(--text-muted); display:block; margin-bottom:6px; text-transform:uppercase; letter-spacing:0.06em; font-weight:600;">
Match Snippet
</label>
<div class="snippet-box" id="fd-snippet"></div>
<div id="fd-validation-box" class="hidden">
<label>Live Validation Response</label>
<pre id="fd-validation-res"></pre>
</div>
<div id="fd-validate-box" class="hidden">
<label>Validate Command</label>
<div class="cmd-container">
<code id="fd-validate-cmd"></code>
<button class="copy-btn copy-btn-validate" id="fd-validate-copy" title="Copy to clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span>Copy</span>
</button>
</div>
</div>
<div id="fd-revoke-box" class="hidden">
<label>Revoke Command</label>
<div class="cmd-container">
<code id="fd-revoke-cmd"></code>
<button class="copy-btn" id="fd-revoke-copy" title="Copy to clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span>Copy</span>
</button>
</div>
</div>
</div>
</section>
</section>
</div>
</div>
</div>
</main>
<script>
let rawData = null;
let findings = [];
let accessMap = [];
let filteredAccessMapView = [];
let currentFilter = "";
let validationFilter = "all";
let pageSize = 10;
let currentPage = 1;
let sortField = "rule";
let sortDirection = "asc";
let autoCollapsedAccessMap = false;
const dropZone = document.getElementById("drop-zone");
const fileInput = document.getElementById("file-input");
const loader = document.getElementById("loader");
const loaderText = document.getElementById("loader-text");
const errorMsg = document.getElementById("error-msg");
const uploadSection = document.getElementById("upload-section");
const dashboard = document.getElementById("dashboard");
const downloadFindingsReportBtn = document.getElementById("download-findings-report");
const reportScopeSelect = document.getElementById("report-scope");
const downloadAccessReportBtn = document.getElementById("download-access-report");
const searchInput = document.getElementById("search-input");
const validationSelect = document.getElementById("validation-filter");
const rowsSelect = document.getElementById("rows-select");
const pagePrev = document.getElementById("page-prev");
const pageNext = document.getElementById("page-next");
const pageInfo = document.getElementById("page-info");
const findingsBody = document.getElementById("findings-body");
const downloadJsonBtn = document.getElementById("download-json");
const downloadCsvBtn = document.getElementById("download-csv");
const treeSearch = document.getElementById("tree-search");
const amContainer = document.getElementById("am-container");
const amToggle = document.getElementById("am-toggle");
const copyAccessMapButton = document.getElementById("copy-access-map");
const amEmptyNotice = document.getElementById("am-empty-notice");
const themeToggle = document.getElementById("theme-toggle");
const fdPathInput = document.getElementById("fd-path");
const navButtons = document.querySelectorAll("[data-view-target]");
const statusChartCanvas = document.getElementById("status-chart");
const statusLegend = document.getElementById("status-legend");
const viewRegistry = {
"view-dashboard": document.getElementById("view-dashboard"),
"view-access": document.getElementById("view-access"),
"view-findings": document.getElementById("view-findings"),
};
const THEME_KEY = "access-map-viewer-theme";
function setTheme(theme) {
const normalized = theme === "light" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", normalized);
if (themeToggle) {
themeToggle.textContent = normalized === "dark" ? "Light Mode" : "Dark Mode";
}
}
setTheme(localStorage.getItem(THEME_KEY));
dropZone.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => {
if (e.target.files.length) processFile(e.target.files[0]);
});
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("active");
});
dropZone.addEventListener("dragleave", (e) => {
e.preventDefault();
dropZone.classList.remove("active");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("active");
if (e.dataTransfer.files.length) processFile(e.dataTransfer.files[0]);
});
navButtons.forEach((btn) => {
btn.addEventListener("click", () => setActiveView(btn.dataset.viewTarget));
});
const resetButton = document.getElementById("reset-btn");
resetButton.addEventListener("click", () => {
const confirmReset = confirm(
"Loading a new report will clear the currently loaded data (your file stays on disk). Continue?",
);
if (confirmReset) {
resetViewer(true);
}
});
searchInput.addEventListener("input", (e) => {
currentFilter = e.target.value || "";
currentPage = 1;
renderFindingsTable();
});
validationSelect.addEventListener("change", (e) => {
validationFilter = e.target.value || "all";
currentPage = 1;
renderFindingsTable();
});
rowsSelect.addEventListener("change", (e) => {
pageSize = parseInt(e.target.value, 10) || 10;
currentPage = 1;
renderFindingsTable();
});
pagePrev.addEventListener("click", () => {
if (currentPage > 1) {
currentPage--;
renderFindingsTable();
}
});
pageNext.addEventListener("click", () => {
const total = getFilteredSortedFindings().length;
const totalPages = total ? Math.ceil(total / pageSize) : 1;
if (currentPage < totalPages) {
currentPage++;
renderFindingsTable();
}
});
treeSearch.addEventListener("input", (e) => {
renderAccessMapTree(e.target.value || "");
});
amToggle.addEventListener("click", () => {
const willCollapse = !amContainer.classList.contains("hidden");
setAccessMapCollapsed(willCollapse);
});
downloadJsonBtn.addEventListener("click", downloadFindingsJson);
downloadCsvBtn.addEventListener("click", downloadFindingsCsv);
copyAccessMapButton.addEventListener("click", copyFilteredAccessMap);
if (downloadFindingsReportBtn) {
downloadFindingsReportBtn.addEventListener("click", () => {
const scope = reportScopeSelect ? reportScopeSelect.value : "all";
generatePdfReport(scope);
});
}
if (downloadAccessReportBtn) {
downloadAccessReportBtn.addEventListener("click", generateAccessMapReport);
}
if (themeToggle) {
themeToggle.addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
const next = current === "dark" ? "light" : "dark";
localStorage.setItem(THEME_KEY, next);
setTheme(next);
});
}
loadCliReport();
document.querySelectorAll("th.sortable").forEach((th) => {
th.addEventListener("click", () => {
const field = th.dataset.field;
if (!field) return;
if (sortField === field) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortField = field;
sortDirection = "asc";
}
currentPage = 1;
renderFindingsTable();
});
});
function setActiveView(targetId) {
const fallback = "view-dashboard";
const viewId = targetId && viewRegistry[targetId] ? targetId : fallback;
Object.entries(viewRegistry).forEach(([id, el]) => {
if (!el) return;
if (id === viewId) el.classList.remove("hidden");
else el.classList.add("hidden");
});
navButtons.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.viewTarget === viewId);
});
}
function setAccessMapCollapsed(collapsed, { auto = false } = {}) {
if (!amContainer) return;
if (collapsed) {
amContainer.classList.add("hidden");
amToggle.textContent = "Expand";
autoCollapsedAccessMap = auto;
} else {
amContainer.classList.remove("hidden");
amToggle.textContent = "Collapse";
autoCollapsedAccessMap = false;
}
}
function syncAccessMapUi(hasAccess) {
const previouslyAutoCollapsed = autoCollapsedAccessMap;
if (amToggle) amToggle.disabled = !hasAccess;
if (downloadAccessReportBtn) downloadAccessReportBtn.disabled = !hasAccess;
if (amEmptyNotice) {
amEmptyNotice.classList.toggle("hidden", hasAccess);
}
if (!hasAccess) {
setAccessMapCollapsed(true, { auto: true });
return;
}
if (previouslyAutoCollapsed) {
setAccessMapCollapsed(false);
}
}
function escapeHtml(unsafe) {
if (unsafe === undefined || unsafe === null) return "";
return unsafe
.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function parsePossiblyMultiJson(text) {
try {
return JSON.parse(text);
} catch (e) {
const trimmed = text.trim();
const parts = trimmed.split(/\r?\n(?=\s*{)/g).filter(Boolean);
if (parts.length > 1) {
try {
return JSON.parse("[" + parts.join(",") + "]");
} catch (e2) {}
}
}
return null;
}
function collectReportData(entries) {
const findings = [];
const accessMap = [];
let mainReport = null;
let statsReport = null;
const items = Array.isArray(entries) ? entries : [entries];
items.forEach((item) => {
if (!item || typeof item !== "object") return;
if (Array.isArray(item.findings) || Array.isArray(item.access_map)) {
if (!mainReport) {
mainReport = item;
}
}
if (Array.isArray(item.findings)) {
findings.push(...item.findings);
}
if (Array.isArray(item.access_map)) {
accessMap.push(...item.access_map);
}
if (item.rule && item.finding) {
findings.push(item);
}
if (item.provider && item.account) {
accessMap.push(item);
}
if (
typeof item.scan_duration !== "undefined" ||
typeof item.scanDuration !== "undefined" ||
typeof item.bytes_scanned !== "undefined" ||
item.kingfisher ||
item.stats ||
item.summary
) {
statsReport = item;
}
});
return { findings, accessMap, mainReport, statsReport };
}
function processFile(file) {
loaderText.textContent = 'Processing "' + file.name + '"…';
loader.classList.remove("hidden");
errorMsg.classList.add("hidden");
errorMsg.textContent = "";
setTimeout(() => {
const reader = new FileReader();
reader.onload = (e) => {
try {
parseAndRender(e.target.result);
} catch (err) {
console.error(err);
errorMsg.textContent = "Error parsing file: " + err.message;
errorMsg.classList.remove("hidden");
loader.classList.add("hidden");
}
};
reader.readAsText(file);
}, 30);
}
async function loadCliReport() {
try {
loaderText.textContent = "Loading report from CLI…";
loader.classList.remove("hidden");
const response = await fetch("/report", { cache: "no-store" });
if (response.status === 404) {
loader.classList.add("hidden");
return;
}
if (!response.ok) {
throw new Error("Server returned status " + response.status);
}
const text = await response.text();
parseAndRender(text);
} catch (err) {
loader.classList.add("hidden");
errorMsg.textContent = "Failed to load report from CLI: " + err.message;
errorMsg.classList.remove("hidden");
}
}
function resetViewer(promptFilePicker = false) {
findings = [];
accessMap = [];
filteredAccessMapView = [];
rawData = null;
currentFilter = "";
validationFilter = "all";
pageSize = 10;
currentPage = 1;
sortField = "rule";
sortDirection = "asc";
searchInput.value = "";
validationSelect.value = "all";
rowsSelect.value = "10";
treeSearch.value = "";
document.getElementById("am-empty-state").classList.remove("hidden");
document.getElementById("am-detail-view").classList.add("hidden");
setAccessMapCollapsed(true, { auto: true });
if (amToggle) amToggle.disabled = true;
if (amEmptyNotice) amEmptyNotice.classList.add("hidden");
renderAccessMapTree();
renderFindingsTable();
updateMetrics();
setActiveView("view-dashboard");
dashboard.classList.add("hidden");
uploadSection.classList.remove("hidden");
errorMsg.classList.add("hidden");
errorMsg.textContent = "";
loader.classList.add("hidden");
loaderText.textContent = "Processing report...";
fileInput.value = "";
if (promptFilePicker) {
fileInput.click();
}
}
function parseAndRender(text) {
const t0 = performance.now();
findings = [];
accessMap = [];
rawData = null;
let parsed = parsePossiblyMultiJson(text);
if (parsed === null) {
const lines = text.split(/\r?\n/);
const entries = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line[0] !== "{") continue;
try {
const obj = JSON.parse(line);
entries.push(obj);
} catch (errLine) {
console.warn("Skipping invalid JSON line", i);
}
}
const collected = collectReportData(entries);
findings = collected.findings;
accessMap = collected.accessMap;
if (collected.mainReport || collected.statsReport) {
rawData = Object.assign({}, collected.mainReport || {}, collected.statsReport || {});
}
} else {
const collected = collectReportData(parsed);
findings = collected.findings;
accessMap = collected.accessMap;
if (collected.mainReport || collected.statsReport || parsed) {
rawData = Object.assign({}, collected.mainReport || {}, collected.statsReport || {});
}
}
currentPage = 1;
currentFilter = "";
validationFilter = "all";
searchInput.value = "";
validationSelect.value = "all";
setActiveView("view-dashboard");
updateMetrics();
renderAccessMapTree();
renderFindingsTable();
uploadSection.classList.add("hidden");
dashboard.classList.remove("hidden");
const elapsed = performance.now() - t0;
loaderText.textContent = "Done in " + elapsed.toFixed(1) + " ms";
setTimeout(() => loader.classList.add("hidden"), 250);
}
function updateMetrics() {
const totalFindings = findings.length;
document.getElementById("stat-total").textContent = totalFindings.toString();
const highSev = findings.filter((f) => {
const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : "";
return conf.toLowerCase() === "high";
}).length;
document.getElementById("stat-high").textContent = highSev.toString();
const validationCounts = calculateValidationCounts();
document.getElementById("stat-active").textContent = (validationCounts.active || 0).toString();
document.getElementById("stat-identities").textContent = (accessMap || []).length.toString();
renderStatusChart(validationCounts);
}
function getFilteredSortedFindings() {
const filterLower = currentFilter.toLowerCase();
const validation = validationFilter;
let arr = findings.filter((f) => {
const rule = f.rule || {};
const finding = f.finding || {};
const ruleName = (rule.name || rule.id || "").toLowerCase();
const path = (finding.path || "").toLowerCase();
const snippet = (finding.snippet || "").toLowerCase();
const fingerprint = (finding.fingerprint || "").toLowerCase();
const status = (finding.validation && finding.validation.status
? String(finding.validation.status)
: "").toLowerCase();
if (filterLower) {
if (!ruleName.includes(filterLower) && !path.includes(filterLower) && !snippet.includes(filterLower) && !fingerprint.includes(filterLower)) {
return false;
}
}
const normalizedStatus = normalizeValidationStatus(status);
if (validation === "active") {
return normalizedStatus === "active";
} else if (validation === "inactive") {
return normalizedStatus === "inactive";
} else if (validation === "not_attempted") {
return normalizedStatus === "not_attempted";
}
return true;
});
const dirFactor = sortDirection === "asc" ? 1 : -1;
arr.sort((a, b) => {
const ra = a.rule || {};
const rb = b.rule || {};
const fa = a.finding || {};
const fb = b.finding || {};
let va = "";
let vb = "";
switch (sortField) {
case "rule":
va = (ra.name || ra.id || "").toLowerCase();
vb = (rb.name || rb.id || "").toLowerCase();
break;
case "location":
va = (fa.path || "").toLowerCase();
vb = (fb.path || "").toLowerCase();
break;
case "confidence":
va = (fa.confidence || "").toLowerCase();
vb = (fb.confidence || "").toLowerCase();
break;
case "validation":
va = (fa.validation && fa.validation.status ? fa.validation.status : "").toLowerCase();
vb = (fb.validation && fb.validation.status ? fb.validation.status : "").toLowerCase();
break;
case "line":
va = fa.line != null ? Number(fa.line) : Number.POSITIVE_INFINITY;
vb = fb.line != null ? Number(fb.line) : Number.POSITIVE_INFINITY;
break;
default:
return 0;
}
if (typeof va === "number" && typeof vb === "number") {
if (va < vb) return -1 * dirFactor;
if (va > vb) return 1 * dirFactor;
return 0;
} else {
if (va < vb) return -1 * dirFactor;
if (va > vb) return 1 * dirFactor;
return 0;
}
});
return arr;
}
function updateSortIndicators() {
document.querySelectorAll("th.sortable").forEach((th) => {
const field = th.dataset.field;
const span = th.querySelector(".sort-indicator");
if (!span) return;
if (field === sortField) {
span.textContent = sortDirection === "asc" ? "▲" : "▼";
} else {
span.textContent = "";
}
});
}
function trimPath(path) {
if (!path) return "-";
const parts = path.split("/");
if (parts.length > 4) {
return ".../" + parts.slice(-3).join("/");
}
return path;
}
function normalizeValidationStatus(status) {
const normalized = (status || "").toString().trim().toLowerCase();
if (normalized === "active credential" || normalized === "active") {
return "active";
}
if (normalized === "inactive credential" || normalized === "inactive") {
return "inactive";
}
if (normalized === "not attempted" || normalized === "not_attempted") {
return "not_attempted";
}
return "unknown";
}
function calculateValidationCounts(list = findings) {
const counts = { active: 0, inactive: 0, not_attempted: 0, unknown: 0 };
(list || []).forEach((f) => {
const status =
f.finding && f.finding.validation && f.finding.validation.status
? f.finding.validation.status
: "";
const key = normalizeValidationStatus(status);
counts[key] = (counts[key] || 0) + 1;
});
return counts;
}
function renderStatusChart(counts) {
if (!statusChartCanvas) return;
const ctx = statusChartCanvas.getContext("2d");
if (!ctx) return;
const palette = {
active: "#22c55e",
inactive: "#f97316",
not_attempted: "#38bdf8",
unknown: "#9ca3af",
};
const dataPoints = [
{ key: "active", label: "Active", color: palette.active },
{ key: "inactive", label: "Inactive", color: palette.inactive },
{ key: "not_attempted", label: "Not Attempted", color: palette.not_attempted },
{ key: "unknown", label: "Unknown", color: palette.unknown },
];
const total = dataPoints.reduce((sum, entry) => sum + (counts[entry.key] || 0), 0);
ctx.clearRect(0, 0, statusChartCanvas.width, statusChartCanvas.height);
const style = getComputedStyle(document.documentElement);
const surfaceColor = style.getPropertyValue("--surface") || "#0d1424";
const textColor = style.getPropertyValue("--text-main") || "#e5e7eb";
const radius = Math.min(statusChartCanvas.width, statusChartCanvas.height) / 2 - 10;
const centerX = statusChartCanvas.width / 2;
const centerY = statusChartCanvas.height / 2;
ctx.save();
ctx.fillStyle = surfaceColor;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
if (total === 0) {
ctx.fillStyle = textColor;
ctx.font = "600 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("No validation data yet", centerX, centerY);
} else {
let startAngle = -Math.PI / 2;
dataPoints.forEach((entry) => {
const value = counts[entry.key] || 0;
if (!value) return;
const slice = (value / total) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.fillStyle = entry.color;
ctx.arc(centerX, centerY, radius, startAngle, startAngle + slice);
ctx.closePath();
ctx.fill();
startAngle += slice;
});
}
const innerRadius = radius * 0.55;
ctx.save();
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.save();
ctx.fillStyle = textColor;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "700 16px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
ctx.fillText(total + " findings", centerX, centerY);
ctx.restore();
if (statusLegend) {
statusLegend.innerHTML = "";
dataPoints.forEach((entry) => {
const row = document.createElement("div");
row.className = "chart-legend-item";
const swatch = document.createElement("span");
swatch.className = "legend-swatch";
swatch.style.background = entry.color;
const text = document.createElement("span");
text.textContent = `${entry.label}: ${counts[entry.key] || 0}`;
row.appendChild(swatch);
row.appendChild(text);
statusLegend.appendChild(row);
});
}
}
function triggerDownload(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function downloadFindingsJson() {
const filtered = getFilteredSortedFindings();
if (!filtered.length) {
alert("No findings are available for the current filters.");
return;
}
const json = JSON.stringify(filtered, null, 2);
triggerDownload("kingfisher-findings.json", json, "application/json");
}
function csvEscape(value) {
const str = String(value ?? "");
if (/[",\n]/.test(str)) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
function downloadFindingsCsv() {
const filtered = getFilteredSortedFindings();
if (!filtered.length) {
alert("No findings are available for the current filters.");
return;
}
const headers = [
"rule_id",
"rule_name",
"file_path",
"line",
"validation_status",
"confidence",
"snippet",
];
const rows = filtered.map((entry) => {
const rule = entry.rule || {};
const finding = entry.finding || {};
const status =
finding.validation && finding.validation.status ? finding.validation.status : "";
return [
rule.id || "",
rule.name || "",
finding.path || "",
finding.line != null ? finding.line : "",
status,
finding.confidence || "",
(finding.snippet || "").replace(/\s+/g, " ").trim(),
];
});
const csv = [headers.join(",")].concat(rows.map((r) => r.map(csvEscape).join(","))).join("\n");
triggerDownload("kingfisher-findings.csv", csv, "text/csv");
}
function getFindingIdFromFinding(finding) {
if (!finding) return "";
return (
finding.id ||
finding.finding_id ||
finding.findingId ||
finding.fingerprint ||
""
);
}
function getFindingIdFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.finding_id ||
entry.findingId ||
entry.finding ||
entry.fingerprint ||
""
);
}
function getTokenNameFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.token_name ||
entry.tokenName ||
(entry.token_details && entry.token_details.name) ||
(entry.token && entry.token.name) ||
""
);
}
function getUserIdFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.user_id ||
entry.userId ||
(entry.token_details && entry.token_details.user_id) ||
(entry.token && entry.token.user_id) ||
""
);
}
function buildAccessMapListHtml({ includeMeta = false } = {}) {
if (!Array.isArray(accessMap) || accessMap.length === 0) {
return "";
}
return accessMap
.map((entry) => {
const groups = Array.isArray(entry.groups) ? entry.groups : [];
const findingId = getFindingIdFromAccessEntry(entry);
const tokenName = getTokenNameFromAccessEntry(entry);
const userId = getUserIdFromAccessEntry(entry);
const metaLine = includeMeta
? `<div style="font-size:12px; color:#475569; margin-top:4px;">
<strong>Finding ID:</strong> ${escapeHtml(findingId || "-")} ·
<strong>Token Name:</strong> ${escapeHtml(tokenName || "-")} ·
<strong>User ID:</strong> ${escapeHtml(userId || "-")}
</div>`
: "";
const groupList = groups
.map((g) => {
const resList = Array.isArray(g.resources) && g.resources.length
? g.resources.map((r) => escapeHtml(String(r))).join(", ")
: "No resources listed";
const permList = Array.isArray(g.permissions) && g.permissions.length
? g.permissions.map((p) => escapeHtml(String(p))).join(", ")
: "No permissions listed";
return `<li><strong>Resources:</strong> ${resList}<br><strong>Permissions:</strong> ${permList}</li>`;
})
.join("");
return `
<li class="access-entry">
<div class="access-head">
${escapeHtml(entry.account || "(identity)")}
<span class="tag">${escapeHtml((entry.provider || "Unknown").toUpperCase())}</span>
</div>
${metaLine}
<ul class="access-groups">
${groupList || "<li>No resources recorded.</li>"}
</ul>
</li>
`;
})
.join("");
}
function generatePdfReport(scope = "all") {
if (!rawData) {
alert("Load a report before downloading a Findings report.");
return;
}
const statusOrder = { active: 0, inactive: 1, not_attempted: 2, unknown: 3 };
const baseFindings =
scope === "filtered" ? getFilteredSortedFindings().slice() : (Array.isArray(findings) ? findings.slice() : []);
const findingsForReport = baseFindings.slice();
findingsForReport.sort((a, b) => {
const fa = a.finding || {};
const fb = b.finding || {};
const keyA = normalizeValidationStatus(fa.validation && fa.validation.status ? fa.validation.status : "");
const keyB = normalizeValidationStatus(fb.validation && fb.validation.status ? fb.validation.status : "");
const sa = statusOrder.hasOwnProperty(keyA) ? statusOrder[keyA] : 4;
const sb = statusOrder.hasOwnProperty(keyB) ? statusOrder[keyB] : 4;
if (sa !== sb) return sa - sb;
const ra = (a.rule && (a.rule.name || a.rule.id)) || "";
const rb = (b.rule && (b.rule.name || b.rule.id)) || "";
return ra.localeCompare(rb);
});
const counts = calculateValidationCounts(baseFindings);
const hasAccess = Array.isArray(accessMap) && accessMap.length > 0;
const statusImage = scope === "all" && statusChartCanvas ? statusChartCanvas.toDataURL("image/png") : "";
const highConfidence = baseFindings.filter((f) => {
const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : "";
return conf.toLowerCase() === "high";
}).length;
const scopeLabel = scope === "filtered" ? "Filtered findings" : "All findings";
const findingsHtml = findingsForReport.length
? findingsForReport
.map((entry) => {
const rule = entry.rule || {};
const finding = entry.finding || {};
const statusRaw = finding.validation && finding.validation.status ? finding.validation.status : "Unknown";
const status = normalizeValidationStatus(statusRaw);
const findingId = getFindingIdFromFinding(finding);
const gitUrl = getFileUrlFromFinding(finding);
const snippet = (finding.snippet || "").toString().replace(/\s+/g, " ").trim();
return `
<tr>
<td>${escapeHtml(rule.name || rule.id || "")}</td>
<td>${escapeHtml(findingId || "")}</td>
<td>${escapeHtml(finding.path || "")}</td>
<td>${escapeHtml(statusRaw)}</td>
<td>${finding.line != null ? escapeHtml(finding.line) : ""}</td>
<td>${escapeHtml(gitUrl || "")}</td>
<td>${escapeHtml(snippet.slice(0, 200))}</td>
</tr>
`;
})
.join("")
: '<tr><td colspan="7">No findings available.</td></tr>';
const accessListHtml = hasAccess
? buildAccessMapListHtml()
: "";
const accessSectionContent = hasAccess
? `<ul class="access-list">${accessListHtml}</ul>`
: "<p>No Access Map entries were found for this report.</p>";
const pdfHtml = `
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Kingfisher Report</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 16px; color: #0f172a; }
h1, h2, h3 { margin: 0 0 12px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; }
.stat { padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; background: #f8fafc; }
.stat .label { font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; }
.stat .value { font-size: 22px; font-weight: 700; margin-top: 6px; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; table-layout: fixed; }
th, td { border: 1px solid #d1d5db; padding: 6px 8px; font-size: 11px; text-align: left; word-break: break-word; overflow-wrap: anywhere; }
th { background: #e5e7eb; }
.section { margin-bottom: 28px; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e0f2fe; color: #075985; font-weight: 700; font-size: 12px; margin-left: 8px; }
.access-list { list-style: none; padding-left: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
.access-entry { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; background: #f8fafc; }
.access-head { font-weight: 700; font-size: 14px; margin-bottom: 6px; }
.access-groups { margin: 0; padding-left: 16px; color: #1f2937; }
.meta { color: #4b5563; font-size: 13px; margin-bottom: 14px; }
.chart-wrapper { display: flex; gap: 14px; align-items: center; }
.legend { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend .dot { width: 12px; height: 12px; border-radius: 4px; display: inline-block; }
</style>
</head>
<body>
<h1>Kingfisher Report</h1>
<div class="meta">Generated ${escapeHtml(new Date().toLocaleString())} · Scope: ${escapeHtml(scopeLabel)}</div>
<div class="section">
<h2>Dashboard</h2>
<div class="grid">
<div class="stat"><div class="label">Total Findings</div><div class="value">${baseFindings.length}</div></div>
<div class="stat"><div class="label">High Confidence</div><div class="value">${highConfidence}</div></div>
<div class="stat"><div class="label">Active Credentials</div><div class="value">${counts.active || 0}</div></div>
<div class="stat"><div class="label">Identities Mapped</div><div class="value">${accessMap.length}</div></div>
</div>
<div class="chart-wrapper">
${statusImage ? `<img src="${statusImage}" alt="Status chart" style="max-width:280px; border:1px solid #d1d5db; border-radius:10px;">` : ""}
<div class="legend">
<span><span class="dot" style="background:#22c55e"></span> Active: ${counts.active || 0}</span>
<span><span class="dot" style="background:#f97316"></span> Inactive: ${counts.inactive || 0}</span>
<span><span class="dot" style="background:#38bdf8"></span> Not Attempted: ${counts.not_attempted || 0}</span>
<span><span class="dot" style="background:#9ca3af"></span> Unknown: ${counts.unknown || 0}</span>
</div>
</div>
</div>
<div class="section">
<h2>${escapeHtml(scope === "filtered" ? "Findings (Filtered, Active first)" : "Findings (Active first)")}</h2>
<table>
<thead>
<tr>
<th>Rule</th>
<th>Finding ID</th>
<th>File Path</th>
<th>Status</th>
<th>Line</th>
<th>Git URL</th>
<th>Snippet</th>
</tr>
</thead>
<tbody>
${findingsHtml}
</tbody>
</table>
</div>
</body>
</html>
`;
const win = window.open("", "_blank", "width=1200,height=900");
if (!win) {
alert("Please allow pop-ups to download the Findings report.");
return;
}
win.document.write(pdfHtml);
win.document.close();
win.focus();
setTimeout(() => win.print(), 400);
}
function generateAccessMapReport() {
if (!rawData) {
alert("Load a report before downloading an Access Map report.");
return;
}
const accessListHtml = buildAccessMapListHtml({ includeMeta: true });
if (!accessListHtml) {
alert("No Access Map entries are available for this report.");
return;
}
const pdfHtml = `
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Kingfisher Access Map Report</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 16px; color: #0f172a; }
h1, h2 { margin: 0 0 12px; }
.meta { color: #4b5563; font-size: 13px; margin-bottom: 14px; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e0f2fe; color: #075985; font-weight: 700; font-size: 12px; margin-left: 8px; }
.access-list { list-style: none; padding-left: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
.access-entry { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; background: #f8fafc; }
.access-head { font-weight: 700; font-size: 14px; margin-bottom: 6px; }
.access-groups { margin: 0; padding-left: 16px; color: #1f2937; }
</style>
</head>
<body>
<h1>Kingfisher Access Map Report</h1>
<div class="meta">Generated ${escapeHtml(new Date().toLocaleString())}</div>
<div class="section">
<h2>Access Map</h2>
<ul class="access-list">${accessListHtml}</ul>
</div>
</body>
</html>
`;
const win = window.open("", "_blank", "width=1200,height=900");
if (!win) {
alert("Please allow pop-ups to download the Access Map report.");
return;
}
win.document.write(pdfHtml);
win.document.close();
win.focus();
setTimeout(() => win.print(), 400);
}
function renderFindingsTable() {
const all = getFilteredSortedFindings();
const total = all.length;
const totalPages = total ? Math.ceil(total / pageSize) : 1;
if (currentPage > totalPages) currentPage = totalPages;
const startIndex = total ? (currentPage - 1) * pageSize : 0;
const endIndex = total ? Math.min(startIndex + pageSize, total) : 0;
const pageItems = total ? all.slice(startIndex, endIndex) : [];
findingsBody.innerHTML = "";
const frag = document.createDocumentFragment();
if (pageItems.length === 0) {
findingsBody.innerHTML =
'<tr><td colspan="5" style="text-align:center; padding:24px; color:var(--text-muted);">No findings match your filters.</td></tr>';
} else {
for (let i = 0; i < pageItems.length; i++) {
const f = pageItems[i] || {};
const rule = f.rule || {};
const finding = f.finding || {};
const ruleName = rule.name || rule.id || "(unknown rule)";
const path = finding.path || "";
const status =
finding.validation && finding.validation.status
? String(finding.validation.status)
: "Unknown";
const normalizedStatus = normalizeValidationStatus(status);
const badgeClass =
normalizedStatus === "active"
? "active"
: normalizedStatus === "inactive"
? "inactive"
: "unknown";
const tr = document.createElement("tr");
tr.innerHTML = `
<td>
<div style="font-weight:600">${escapeHtml(ruleName)}</div>
<div style="font-size:11px; color:var(--text-muted)">${escapeHtml(rule.id || "")}</div>
</td>
<td style="font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size:12px; color:var(--text-muted);" title="${escapeHtml(path)}">
${escapeHtml(trimPath(path))}
</td>
<td>
<span class="status-badge ${badgeClass}">${escapeHtml(status)}</span>
</td>
<td>${escapeHtml(finding.confidence || "")}</td>
<td style="font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;">
${finding.line != null ? finding.line : ""}
</td>
`;
tr.onclick = () => showFindingDetail(f);
frag.appendChild(tr);
}
findingsBody.appendChild(frag);
}
if (total === 0) {
pageInfo.textContent = "0 of 0";
pagePrev.disabled = true;
pageNext.disabled = true;
} else {
pageInfo.textContent = (startIndex + 1) + "" + endIndex + " of " + total;
pagePrev.disabled = currentPage <= 1;
pageNext.disabled = currentPage >= totalPages;
}
updateSortIndicators();
}
function showFindingDetail(f) {
const rule = f.rule || {};
const finding = f.finding || {};
const panel = document.getElementById("finding-detail");
panel.classList.remove("hidden");
panel.scrollIntoView({ behavior: "smooth", block: "start" });
document.getElementById("fd-rule-id").textContent = rule.id || "";
const fpEl = document.getElementById("fd-fingerprint");
const fpVal = finding.fingerprint || "";
fpEl.textContent = fpVal;
fpEl.innerHTML = fpVal; // reset content
if (fpVal && Array.isArray(accessMap)) {
const hasEntry = accessMap.some(entry => entry.fingerprint === fpVal);
if (hasEntry) {
const btn = document.createElement("button");
btn.className = "badge";
btn.textContent = "Go to Access Map";
btn.style.marginLeft = "10px";
btn.style.cursor = "pointer";
btn.style.background = "var(--brand-soft)";
btn.style.borderColor = "var(--brand)";
btn.style.color = "var(--brand-dark)";
btn.onclick = () => {
setActiveView("view-access");
const treeSearch = document.getElementById("tree-search");
if (treeSearch) {
treeSearch.value = fpVal;
treeSearch.dispatchEvent(new Event('input'));
}
};
fpEl.appendChild(btn);
}
}
document.getElementById("fd-entropy").textContent =
finding.entropy != null ? String(finding.entropy) : "";
const commit =
finding.git_metadata && finding.git_metadata.commit && finding.git_metadata.commit.id
? finding.git_metadata.commit.id.substring(0, 8)
: "N/A";
document.getElementById("fd-commit").textContent = commit;
const committerWrapper = document.getElementById("fd-committer-email-wrapper");
const committerEmailEl = document.getElementById("fd-committer-email");
const committerEmail =
finding.git_metadata &&
finding.git_metadata.commit &&
finding.git_metadata.commit.committer &&
finding.git_metadata.commit.committer.email
? String(finding.git_metadata.commit.committer.email)
: "";
if (committerWrapper && committerEmailEl) {
if (committerEmail) {
committerWrapper.style.display = "";
committerEmailEl.textContent = committerEmail;
} else {
committerWrapper.style.display = "none";
committerEmailEl.textContent = "";
}
}
const statusRaw =
finding.validation && finding.validation.status ? String(finding.validation.status) : "Unknown";
const normalizedStatus = normalizeValidationStatus(statusRaw);
const badgeClass =
normalizedStatus === "active"
? "active"
: normalizedStatus === "inactive"
? "inactive"
: "unknown";
const statusEl = document.getElementById("fd-validation-status");
if (statusEl) {
statusEl.innerHTML = `<span class="status-badge ${badgeClass}">${escapeHtml(statusRaw)}</span>`;
}
const path = finding.path || "";
if (fdPathInput) {
fdPathInput.value = path || "—";
fdPathInput.style.height = "auto";
fdPathInput.style.height = Math.min(fdPathInput.scrollHeight, 260) + "px";
}
const gitUrlWrapper = document.getElementById("fd-git-url-wrapper");
const gitUrlEl = document.getElementById("fd-git-url");
const gitUrl = getFileUrlFromFinding(finding);
gitUrlEl.innerHTML = "";
if (gitUrl) {
gitUrlWrapper.style.display = "";
const a = document.createElement("a");
a.href = gitUrl;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = gitUrl;
gitUrlEl.appendChild(a);
} else {
gitUrlWrapper.style.display = "none";
}
document.getElementById("fd-snippet").textContent = finding.snippet || "";
const valBox = document.getElementById("fd-validation-box");
if (finding.validation && finding.validation.response) {
valBox.classList.remove("hidden");
document.getElementById("fd-validation-res").textContent =
finding.validation.response;
} else {
valBox.classList.add("hidden");
document.getElementById("fd-validation-res").textContent = "";
}
// Validate command section
const validateBox = document.getElementById("fd-validate-box");
const validateCmd = document.getElementById("fd-validate-cmd");
const validateCopyBtn = document.getElementById("fd-validate-copy");
if (finding.validate_command) {
validateBox.classList.remove("hidden");
validateCmd.textContent = finding.validate_command;
// Set up copy button
validateCopyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(finding.validate_command);
validateCopyBtn.classList.add("copied");
validateCopyBtn.querySelector("span").textContent = "Copied!";
setTimeout(() => {
validateCopyBtn.classList.remove("copied");
validateCopyBtn.querySelector("span").textContent = "Copy";
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
} else {
validateBox.classList.add("hidden");
validateCmd.textContent = "";
}
// Revoke command section
const revokeBox = document.getElementById("fd-revoke-box");
const revokeCmd = document.getElementById("fd-revoke-cmd");
const revokeCopyBtn = document.getElementById("fd-revoke-copy");
if (finding.revoke_command) {
revokeBox.classList.remove("hidden");
revokeCmd.textContent = finding.revoke_command;
// Set up copy button
revokeCopyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(finding.revoke_command);
revokeCopyBtn.classList.add("copied");
revokeCopyBtn.querySelector("span").textContent = "Copied!";
setTimeout(() => {
revokeCopyBtn.classList.remove("copied");
revokeCopyBtn.querySelector("span").textContent = "Copy";
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
} else {
revokeBox.classList.add("hidden");
revokeCmd.textContent = "";
}
}
function getFileUrlFromFinding(finding) {
if (!finding || !finding.git_metadata) return "";
const fileObj = finding.git_metadata.file || {};
const url = fileObj.url || "";
if (url && /^https?:\/\//i.test(url)) return url;
return "";
}
function renderAccessMapTree(filter = "") {
const root = document.getElementById("am-tree-root");
root.innerHTML = "";
const filterLower = (filter || "").toLowerCase();
filteredAccessMapView = buildRenderableAccessMap(filterLower);
const hasAccess = Array.isArray(accessMap) && accessMap.length > 0;
syncAccessMapUi(hasAccess);
if (!filteredAccessMapView || filteredAccessMapView.length === 0) {
root.innerHTML =
'<div style="padding:16px; text-align:center; color:var(--text-muted); font-size:13px;">No access map data available</div>';
return;
}
filteredAccessMapView.forEach((entry) => {
const identity = entry.identity;
const account = formatIdentityLabel(identity);
const identityNameMatches = entry.identityNameMatches;
const idNode = createTreeNode(account, "identity", true, identity.provider);
idNode.container.style.borderLeft = "none";
idNode.container.style.marginLeft = "0";
idNode.header.addEventListener("click", (e) => {
e.stopPropagation();
showAccessDetail("identity", identity);
toggleNode(idNode.childrenContainer, idNode.toggleIcon);
});
root.appendChild(idNode.container);
const resGroup = createTreeNode("Resources", "group", true);
resGroup.header.addEventListener("click", (e) => {
e.stopPropagation();
toggleNode(resGroup.childrenContainer, resGroup.toggleIcon);
});
idNode.childrenContainer.appendChild(resGroup.container);
entry.groups.forEach((group) => {
const resourcesToShow = Array.isArray(group.resources) ? group.resources : [];
const perms = Array.isArray(group.permissions) ? group.permissions : [];
if (resourcesToShow && resourcesToShow.length > 0) {
resourcesToShow.forEach((resName) => {
const resNode = createTreeNode(String(resName), "resource", false);
resNode.header.addEventListener("click", (e) => {
e.stopPropagation();
showAccessDetail("resource", {
name: resName,
provider: identity.provider,
permissions: perms,
});
toggleNode(resNode.childrenContainer, resNode.toggleIcon);
});
resGroup.childrenContainer.appendChild(resNode.container);
if (perms.length > 0) {
perms.forEach((perm) => {
const permNode = createLeafNode(String(perm), "permission");
permNode.addEventListener("click", (e) => {
e.stopPropagation();
showAccessDetail("permission", { name: perm });
});
resNode.childrenContainer.appendChild(permNode);
});
} else {
const empty = createLeafNode("No specific permissions listed", "group");
resNode.childrenContainer.appendChild(empty);
}
});
} else if (perms.length > 0 && (!filterLower || identityNameMatches)) {
const wildNode = createTreeNode("Project-wide / Unscoped", "resource", false);
resGroup.childrenContainer.appendChild(wildNode.container);
perms.forEach((perm) => {
const pNode = createLeafNode(String(perm), "permission");
wildNode.childrenContainer.appendChild(pNode);
});
}
});
});
}
function buildRenderableAccessMap(filterLower) {
if (!accessMap || accessMap.length === 0) return [];
const identities = [];
accessMap.forEach((identity) => {
const account = formatIdentityLabel(identity);
const groups = Array.isArray(identity.groups) ? identity.groups : [];
const fingerprint = (identity.fingerprint || "").toLowerCase();
const identityNameMatches = Boolean(filterLower) && (account.toLowerCase().includes(filterLower) || fingerprint.includes(filterLower));
let anyResourceMatches = false;
const preparedGroups = groups.map((group) => {
const resources = Array.isArray(group.resources) ? group.resources : [];
const perms = Array.isArray(group.permissions) ? group.permissions : [];
const filteredResources = resources.filter((resName) => {
const resLower = String(resName).toLowerCase();
const matches = !filterLower || resLower.includes(filterLower) || identityNameMatches;
if (!identityNameMatches && filterLower && resLower.includes(filterLower)) {
anyResourceMatches = true;
}
return matches;
});
return { resources, filteredResources, permissions: perms };
});
const hasMatch = !filterLower || identityNameMatches || anyResourceMatches;
if (!hasMatch) return;
const viewGroups = preparedGroups.map((group) => ({
resources: !filterLower || identityNameMatches ? group.resources : group.filteredResources,
permissions: group.permissions,
}));
identities.push({ identity, groups: viewGroups, identityNameMatches });
});
return identities;
}
function copyFilteredAccessMap() {
if (!filteredAccessMapView || filteredAccessMapView.length === 0) {
alert("No access map entries are available for the current filter.");
return;
}
const payload = filteredAccessMapView.map((entry) => {
return Object.assign({}, entry.identity, { groups: entry.groups });
});
const text = JSON.stringify(payload, null, 2);
const fallbackCopy = () => {
const area = document.createElement("textarea");
area.value = text;
document.body.appendChild(area);
area.select();
document.execCommand("copy");
document.body.removeChild(area);
alert("Copied access map for " + payload.length + " identities.");
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => alert("Copied access map for " + payload.length + " identities."))
.catch(fallbackCopy);
} else {
fallbackCopy();
}
}
function createTreeNode(label, type, isOpen, provider) {
const container = document.createElement("div");
container.className = "tree-node";
const header = document.createElement("div");
header.className = "node-content";
const toggle = document.createElement("span");
toggle.style.fontSize = "10px";
toggle.style.color = "#9ca3af";
toggle.style.width = "12px";
toggle.textContent = isOpen ? "▼" : "▶";
const icon = document.createElement("span");
icon.className = "node-icon icon-" + type;
if (type === "identity") icon.textContent = "👤";
else if (type === "resource") icon.textContent = "📦";
else if (type === "group") icon.textContent = "🗂️";
const text = document.createElement("span");
text.textContent = label;
text.style.whiteSpace = "nowrap";
text.style.overflow = "hidden";
text.style.textOverflow = "ellipsis";
header.appendChild(toggle);
header.appendChild(icon);
header.appendChild(text);
if (type === "identity" && provider) {
addBadge(header, provider.toUpperCase(), providerBadgeClass(provider));
}
const children = document.createElement("div");
children.className = "tree-children";
if (isOpen) {
children.classList.add("open");
children.style.display = "block";
}
container.appendChild(header);
container.appendChild(children);
return { container, header, childrenContainer: children, toggleIcon: toggle };
}
function createLeafNode(label, type) {
const div = document.createElement("div");
div.className = "node-content";
div.style.marginLeft = "20px";
div.style.fontSize = "13px";
const icon = document.createElement("span");
icon.className = "node-icon icon-" + type;
if (type === "permission") icon.textContent = "🔑";
else if (type === "group") icon.textContent = "…";
const text = document.createElement("span");
text.textContent = label;
div.appendChild(icon);
div.appendChild(text);
return div;
}
function toggleNode(container, icon) {
if (container.style.display === "none" || !container.classList.contains("open")) {
container.classList.add("open");
container.style.display = "block";
icon.textContent = "▼";
} else {
container.classList.remove("open");
container.style.display = "none";
icon.textContent = "▶";
}
}
function addBadge(container, text, cls) {
const span = document.createElement("span");
span.className = "badge " + cls;
span.textContent = text;
container.appendChild(span);
}
function providerBadgeClass(provider) {
const normalized = String(provider || "").toLowerCase();
if (normalized === "aws") return "badge-aws";
if (normalized === "gcp") return "badge-gcp";
if (normalized === "azure") return "badge-azure";
if (normalized === "github") return "badge-github";
if (normalized === "gitlab") return "badge-gitlab";
return "badge-aws";
}
function formatIdentityLabel(identity) {
const base = identity && identity.account ? identity.account : "unknown-identity";
const provider = identity && identity.provider ? identity.provider.toUpperCase() : null;
return provider ? `[${provider}] ${base}` : base;
}
function setDetailName(text, link) {
const nameEl = document.getElementById("am-detail-name");
nameEl.innerHTML = "";
if (link) {
const anchor = document.createElement("a");
anchor.href = link;
anchor.target = "_blank";
anchor.rel = "noopener noreferrer";
anchor.textContent = text;
nameEl.appendChild(anchor);
} else {
nameEl.textContent = text;
}
}
function extractResourceParts(label) {
if (!label) return { resourceType: "", resourceName: "" };
const idx = label.indexOf(":");
if (idx === -1) return { resourceType: "", resourceName: label };
return { resourceType: label.slice(0, idx), resourceName: label.slice(idx + 1) };
}
function buildResourceConsoleLink(provider, resourceType, resourceName) {
if (!provider || !resourceName) return null;
const normalizedProvider = provider.toLowerCase();
if (normalizedProvider === "aws") return awsResourceConsoleLink(resourceName);
if (normalizedProvider === "gcp") return gcpResourceConsoleLink(resourceName);
return null;
}
function awsResourceConsoleLink(resource) {
if (!resource || !resource.startsWith("arn:")) return null;
const parts = resource.split(":");
if (parts.length < 6) return null;
const service = parts[2];
const region = parts[3];
const resourcePart = parts[5] || "";
if (service === "s3") {
const bucket = resourcePart.replace(/^\/*/, "");
return `https://console.aws.amazon.com/s3/buckets/${encodeURIComponent(bucket)}`;
}
if (service === "iam") {
const match = resource.match(/^arn:aws:iam::\d+:([^/]+)\/(.+)$/);
const kind = match ? match[1] : null;
const res = match ? match[2] : null;
if (kind === "role") {
return `https://console.aws.amazon.com/iam/home?#/roles/${encodeURIComponent(res)}`;
}
if (kind === "user") {
return `https://console.aws.amazon.com/iam/home?#/users/${encodeURIComponent(res)}`;
}
return null;
}
if (service === "lambda") {
const match = resourcePart.match(/^function[:\/](.+)$/);
if (match && match[1]) {
const fnName = match[1];
const regionQuery = region ? `?region=${encodeURIComponent(region)}` : "";
return `https://console.aws.amazon.com/lambda/home${regionQuery}#/functions/${encodeURIComponent(fnName)}`;
}
return null;
}
if (service === "ec2") {
const match = resourcePart.match(/^instance\/(.+)$/);
if (match && match[1]) {
const instanceId = match[1];
return `https://console.aws.amazon.com/ec2/v2/home?#InstanceDetails:instanceId=${encodeURIComponent(instanceId)}`;
}
return null;
}
if (service === "kms") {
const match = resourcePart.match(/^(?:key|alias)\/(.+)$/);
if (match && match[1]) {
return `https://console.aws.amazon.com/kms/home?#/kms/keys/${encodeURIComponent(match[1])}`;
}
return null;
}
if (service === "secretsmanager") {
return `https://console.aws.amazon.com/secretsmanager/home?#/secret?name=${encodeURIComponent(resource)}`;
}
if (service === "dynamodb") {
const match = resourcePart.match(/^(?:table\/(.+)|table:(.+))/);
const tableRaw = match ? match[1] || match[2] : null;
const table = tableRaw ? tableRaw.split("/")[0] : null;
if (table) {
const regionQuery = region ? `?region=${encodeURIComponent(region)}` : "";
return `https://console.aws.amazon.com/dynamodbv2/home${regionQuery}#/table/${encodeURIComponent(table)}/items`;
}
return null;
}
return null;
}
function gcpResourceConsoleLink(resource) {
if (!resource) return null;
const projectMatch = resource.match(/^projects\/([^/]+)/);
const project = projectMatch ? projectMatch[1] : null;
if (resource.includes("/buckets/")) {
const bucketMatch = resource.match(/\/buckets\/([^/]+)/);
const bucket = bucketMatch ? bucketMatch[1] : null;
if (bucket && project) {
return `https://console.cloud.google.com/storage/browser/${encodeURIComponent(bucket)}?project=${encodeURIComponent(project)}`;
}
}
if (resource.includes("/datasets/")) {
const datasetMatch = resource.match(/\/datasets\/([^/]+)/);
const dataset = datasetMatch ? datasetMatch[1] : null;
if (dataset && project) {
return `https://console.cloud.google.com/bigquery?project=${encodeURIComponent(project)}&p=${encodeURIComponent(project)}&d=${encodeURIComponent(dataset)}&page=dataset`;
}
}
if (resource.includes("/secrets/")) {
const secretMatch = resource.match(/\/secrets\/([^/:]+)/);
const secret = secretMatch ? secretMatch[1] : null;
if (secret && project) {
return `https://console.cloud.google.com/security/secret-manager/secret/${encodeURIComponent(secret)}/versions?project=${encodeURIComponent(project)}`;
}
}
if (resource.includes("/functions/")) {
const fnMatch = resource.match(/\/locations\/([^/]+)\/functions\/([^/]+)/);
if (fnMatch && project) {
const region = fnMatch[1];
const fnName = fnMatch[2];
return `https://console.cloud.google.com/functions/details/${encodeURIComponent(region)}/${encodeURIComponent(fnName)}?project=${encodeURIComponent(project)}`;
}
}
if (project) {
return `https://console.cloud.google.com/home/dashboard?project=${encodeURIComponent(project)}`;
}
return null;
}
function showAccessDetail(type, data) {
document.getElementById("am-empty-state").classList.add("hidden");
const view = document.getElementById("am-detail-view");
view.classList.remove("hidden");
const icon = document.getElementById("am-detail-icon");
const meta = document.getElementById("am-detail-meta");
const typeField = document.getElementById("am-detail-type");
const cloudField = document.getElementById("am-detail-cloud");
const permsContainer = document.getElementById("am-perms-container");
const permsList = document.getElementById("am-perms-list");
const tokenContainer = document.getElementById("am-token-container");
const tokenName = document.getElementById("am-token-name");
const tokenUsername = document.getElementById("am-token-username");
const tokenType = document.getElementById("am-token-type");
const tokenAccountType = document.getElementById("am-token-account-type");
const tokenUser = document.getElementById("am-token-user");
const tokenCompany = document.getElementById("am-token-company");
const tokenCreated = document.getElementById("am-token-created");
const tokenLocation = document.getElementById("am-token-location");
const tokenLastUsed = document.getElementById("am-token-last-used");
const tokenEmail = document.getElementById("am-token-email");
const tokenExpires = document.getElementById("am-token-expires");
const tokenUrl = document.getElementById("am-token-url");
const tokenVersion = document.getElementById("am-token-version");
const tokenEnterprise = document.getElementById("am-token-enterprise");
const tokenScopes = document.getElementById("am-token-scopes");
meta.innerHTML = "";
permsList.innerHTML = "";
permsContainer.classList.add("hidden");
tokenScopes.innerHTML = "";
tokenContainer.classList.add("hidden");
let detailName = "";
let detailLink = null;
if (type === "identity") {
icon.textContent = "👤";
icon.className = "detail-icon-lg";
detailName = data.account || "(unknown identity)";
typeField.textContent = "Identity";
const provider = (data.provider || "unknown").toUpperCase();
cloudField.textContent = provider;
if (data.provider) {
addBadge(meta, provider, providerBadgeClass(data.provider));
}
if (data.fingerprint) {
const fpDiv = document.createElement("div");
fpDiv.style.width = "100%";
fpDiv.style.marginTop = "6px";
fpDiv.style.fontSize = "11px";
fpDiv.style.color = "var(--text-muted)";
fpDiv.textContent = "Fingerprint: " + data.fingerprint;
meta.appendChild(fpDiv);
const btn = document.createElement("button");
btn.className = "badge";
btn.textContent = "Go to finding";
btn.style.marginTop = "6px";
btn.style.cursor = "pointer";
btn.style.background = "var(--brand-soft)";
btn.style.borderColor = "var(--brand)";
btn.style.color = "var(--brand-dark)";
btn.onclick = () => {
const searchInput = document.getElementById("search-input");
if (searchInput) {
setActiveView("view-findings");
searchInput.value = data.fingerprint;
currentFilter = data.fingerprint;
currentPage = 1;
renderFindingsTable();
setTimeout(() => {
const tableContainer = document.querySelector('.table-container');
if (tableContainer) {
tableContainer.scrollIntoView({behavior: 'smooth', block: 'start'});
}
}, 50);
}
};
meta.appendChild(btn);
}
if (data.token_details) {
const details = data.token_details;
tokenName.textContent = details.name || "-";
tokenUsername.textContent = details.username || "-";
tokenType.textContent = details.token_type || "-";
tokenAccountType.textContent = details.account_type || "-";
tokenUser.textContent = details.user_id || "-";
tokenCompany.textContent = details.company || "-";
tokenCreated.textContent = details.created_at || "-";
tokenLocation.textContent = details.location || "-";
tokenLastUsed.textContent = details.last_used_at || "-";
tokenEmail.textContent = details.email || "-";
tokenExpires.textContent = details.expires_at || "-";
if (details.url) {
tokenUrl.innerHTML = "";
const urlLink = document.createElement("a");
urlLink.href = details.url;
urlLink.target = "_blank";
urlLink.rel = "noreferrer noopener";
urlLink.textContent = details.url;
tokenUrl.appendChild(urlLink);
} else {
tokenUrl.textContent = "-";
}
tokenVersion.textContent =
(data.provider_metadata && data.provider_metadata.version) || "-";
tokenEnterprise.textContent =
data.provider_metadata && typeof data.provider_metadata.enterprise === "boolean"
? data.provider_metadata.enterprise
? "true"
: "false"
: "-";
if (Array.isArray(details.scopes)) {
details.scopes.forEach((scope) => addBadge(tokenScopes, scope, "badge-perm"));
}
tokenContainer.classList.remove("hidden");
}
} else if (type === "resource") {
icon.textContent = "📦";
icon.className = "detail-icon-lg";
const resourceLabel = data.name || "(resource)";
const { resourceType, resourceName } = extractResourceParts(resourceLabel);
detailName = resourceLabel;
detailLink = buildResourceConsoleLink(data.provider, resourceType, resourceName);
typeField.textContent = "Resource";
const provider = (data.provider || "unknown").toUpperCase();
cloudField.textContent = provider;
if (data.provider) {
addBadge(meta, provider, providerBadgeClass(data.provider));
}
if (Array.isArray(data.permissions) && data.permissions.length) {
permsContainer.classList.remove("hidden");
data.permissions.forEach((p) => {
addBadge(permsList, p, "badge-perm");
});
}
} else if (type === "permission") {
icon.textContent = "🔑";
icon.className = "detail-icon-lg";
detailName = data.name || "(permission)";
typeField.textContent = "Permission string";
cloudField.textContent = "-";
}
setDetailName(detailName, detailLink);
}
</script>
</body>
</html>