forked from mirrors/kingfisher
3136 lines
No EOL
108 KiB
HTML
3136 lines
No EOL
108 KiB
HTML
<!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 & 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 & 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 > Resources > Resource > 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">‹</button>
|
||
<span id="page-info">0 of 0</span>
|
||
<button id="page-next" class="pager-btn" type="button">›</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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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> |