kingfisher/docs/access-map-viewer/index.html
Mick Grove 7237a931d5 v1.73.0
2026-01-01 22:24:57 -08:00

2761 lines
94 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kingfisher Access Map Viewer</title>
<style>
:root {
color-scheme: dark;
--brand: #0e7c56;
--brand-dark: #0c6d4d;
--brand-soft: #10241c;
--bg: #060b16;
--surface: #0d1424;
--surface-muted: #111a2b;
--surface-strong: #0f192c;
--text-main: #f3f4f6;
--text-muted: #c7d2e2;
--border: #1f2a3f;
--border-strong: #2c3a55;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.15);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.25), 0 2px 4px -2px rgb(0 0 0 / 0.2);
--critical: #fca5a5;
--success: #34d399;
--radius: 8px;
--hover: #1f2937;
--table-header: #0f172a;
--code-bg: #0d1526;
--code-border: #1f2937;
--code-border-strong: rgba(56, 189, 248, 0.4);
--code-text: #e2e8f0;
--code-accent: rgba(56, 189, 248, 0.08);
--link-strong: #a5f3fc;
--link-strong-hover: #e0f2fe;
--link-underline: rgba(255, 255, 255, 0.35);
}
:root[data-theme="light"] {
color-scheme: light;
--brand: #0e7c56;
--brand-dark: #07402c;
--brand-soft: #e6f4ed;
--bg: #f3f4f6;
--surface: #ffffff;
--surface-strong: #f4f6fb;
--surface-muted: #f9fafb;
--text-main: #111827;
--text-muted: #6b7280;
--border: #e5e7eb;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--critical: #dc2626;
--success: #059669;
--hover: #e5e7eb;
--table-header: #f9fafb;
--code-bg: #f8fafc;
--code-border: #e5e7eb;
--code-border-strong: rgba(14, 124, 86, 0.2);
--code-text: #0f172a;
--code-accent: rgba(14, 124, 86, 0.08);
--link-strong: #1d4ed8;
--link-strong-hover: #1e3a8a;
--link-underline: rgba(30, 64, 175, 0.35);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text-main);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
/* Header */
.hero {
background: var(--brand);
color: #ffffff;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-md);
position: sticky;
top: 0;
z-index: 50;
}
.hero__brand {
display: flex;
align-items: center;
gap: 12px;
}
.hero__icon {
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.15);
color: white;
border-radius: 6px;
display: grid;
place-items: center;
font-weight: 600;
font-size: 18px;
}
.hero__title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
}
.hero__subtitle {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
margin-left: 10px;
padding-left: 10px;
border-left: 1px solid rgba(255, 255, 255, 0.25);
}
.layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 18px;
align-items: start;
}
.sidebar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 14px;
position: sticky;
top: 82px;
display: flex;
flex-direction: column;
gap: 12px;
}
.nav-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.nav-button {
width: 100%;
text-align: left;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid transparent;
background: var(--surface-muted);
color: var(--text-main);
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: border 0.15s ease, background 0.15s ease, transform 0.1s ease;
}
.nav-button:hover {
background: var(--hover);
border-color: var(--border);
}
.nav-button.active {
background: var(--brand-soft);
border-color: var(--brand);
color: var(--brand-dark);
box-shadow: var(--shadow-sm);
}
.nav-icon {
font-size: 16px;
opacity: 0.9;
}
.view {
display: flex;
flex-direction: column;
gap: 18px;
}
.view-stack {
display: flex;
flex-direction: column;
gap: 18px;
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
top: auto;
}
}
.info-banner {
background: var(--surface);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
text-align: center;
padding: 10px 16px;
font-size: 13px;
}
.page {
max-width: 1600px;
margin: 24px auto;
padding: 0 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.panel__header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--surface-strong);
}
.panel__title h3 { margin: 0; font-size: 16px; font-weight: 600; }
.panel__title p { margin: 4px 0 0; font-size: 13px; color: var(--text-muted); }
/* Upload */
.upload-area {
padding: 32px;
text-align: center;
background: var(--surface-muted);
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid var(--border);
border-radius: var(--radius);
}
.upload-area:hover,
.upload-area.active {
background: var(--brand-soft);
border-color: var(--brand);
}
.upload-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.8; }
.upload-text { font-size: 16px; font-weight: 500; }
.upload-sub { color: var(--text-muted); margin-top: 4px; }
/* Metrics */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
padding: 20px;
gap: 16px;
}
.metric {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
box-shadow: var(--shadow-sm);
}
.metric__label {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric__value {
font-size: 26px;
font-weight: 700;
margin-top: 6px;
}
.metric.critical .metric__value { color: var(--critical); }
.metric.success .metric__value { color: var(--success); }
/* Access Map */
.am-container {
display: grid;
grid-template-columns: 2fr 1fr; /* tree 2/3, detail 1/3 */
height: 600px;
background: var(--surface);
}
.am-sidebar {
border-right: 1px solid var(--border);
background: var(--surface-muted);
overflow-y: auto;
padding: 16px;
}
.am-main {
padding: 24px;
overflow-y: auto;
background: var(--surface);
}
.am-search {
position: sticky;
top: 0;
background: var(--surface-muted);
padding-bottom: 10px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
z-index: 5;
}
.am-search input {
width: 100%;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 13px;
background: var(--surface);
color: var(--text-main);
}
.am-search input:focus {
outline: none;
border-color: var(--brand);
}
.am-empty-hint {
padding: 12px 16px;
background: var(--surface-muted);
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text-muted);
margin: 12px 20px 0;
}
/* Tree */
.tree-node {
position: relative;
margin-left: 20px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
.tree-node:last-child {
border-left: none;
}
.tree-node::before {
content: "";
position: absolute;
top: 14px;
left: 0;
width: 12px;
height: 1px;
background: var(--border);
}
.node-content {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
user-select: none;
font-size: 13px;
}
.node-content:hover {
background: var(--hover);
}
.node-icon {
width: 20px;
height: 20px;
border-radius: 4px;
display: grid;
place-items: center;
font-size: 12px;
flex-shrink: 0;
}
.icon-identity { background: #e0e7ff; color: #3730a3; }
.icon-resource { background: #ffe4e6; color: #9f1239; }
.icon-permission { background: #dcfce7; color: #166534; }
.icon-group { background: #fef3c7; color: #92400e; }
.tree-children {
padding-top: 4px;
padding-left: 8px;
display: none;
}
.tree-children.open {
display: block;
}
/* Access detail */
.am-card-header {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.am-card-title {
font-size: 18px;
font-weight: 700;
display: flex;
align-items: center;
gap: 12px;
}
.detail-icon-lg {
width: 40px;
height: 40px;
border-radius: 8px;
display: grid;
place-items: center;
background: var(--surface-muted);
border: 1px solid var(--border);
font-size: 20px;
}
.am-card-meta {
display: flex;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
flex-wrap: wrap;
}
.badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
border: 1px solid transparent;
}
.badge-aws { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
.badge-gcp { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
.badge-azure { background: #ecfeff; color: #0e7490; border-color: #a5f3fc; }
.badge-github { background: #f4f4f5; color: #18181b; border-color: #d4d4d8; }
.badge-gitlab { background: #fff1f2; color: #be123c; border-color: #fecdd3; }
.badge-perm { background: #ecfdf5; color: #16a34a; border-color: #bbf7d0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.detail-field label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.detail-field div {
font-weight: 500;
font-size: 14px;
word-break: break-all;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
padding: 0 20px 20px;
}
.chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow-sm);
display: flex;
gap: 12px;
align-items: center;
min-height: 220px;
}
.chart-legend {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: var(--text-main);
}
.chart-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
border: 1px solid var(--border);
flex-shrink: 0;
}
#status-chart {
background: var(--surface-muted);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
/* Findings table */
.table-container { width: 100%; overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th {
text-align: left;
padding: 12px 16px;
background: var(--table-header);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-weight: 600;
}
.table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: var(--surface-muted); cursor: pointer; }
.sortable {
cursor: pointer;
user-select: none;
}
.sort-indicator {
font-size: 10px;
margin-left: 4px;
opacity: 0.6;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.status-badge.active { background: #ecfdf5; color: #15803d; }
.status-badge.inactive { background: #fef2f2; color: #b91c1c; }
.status-badge.unknown { background: var(--surface-muted); color: var(--text-muted); }
/* Finding detail snippet */
.snippet-box {
position: relative;
background: linear-gradient(90deg, var(--code-accent), transparent), var(--code-bg);
color: var(--code-text);
padding: 16px;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
max-width: 100%;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid var(--code-border);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35), 0 0 0 1px var(--code-border-strong);
}
.snippet-box::after {
content: "";
position: absolute;
inset: 0;
border-radius: 8px;
pointer-events: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
}
#fd-validation-box {
margin-top: 16px;
padding: 14px 16px;
background: #ecfdf5;
border: 1px solid #bbf7d0;
border-radius: 6px;
}
#fd-validation-box label {
font-size: 13px;
font-weight: 600;
color: #166534;
display: block;
margin-bottom: 4px;
}
#fd-validation-res {
white-space: pre-wrap;
font-size: 12px;
color: #166534;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.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);
}
#theme-toggle {
background: #f9fafb;
color: var(--brand);
border-color: rgba(255, 255, 255, 0.7);
font-weight: 700;
box-shadow: var(--shadow-sm);
}
#theme-toggle:hover {
background: #ffffff;
border-color: rgba(255, 255, 255, 0.9);
color: var(--brand-dark);
}
:root[data-theme="light"] #theme-toggle {
background: #0e7c56;
color: #ffffff;
border-color: #0c6d4d;
}
:root[data-theme="light"] #theme-toggle:hover {
background: #0c6d4d;
border-color: #0a5d40;
}
.hidden { display: none !important; }
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.2);
backdrop-filter: blur(2px);
z-index: 100;
display: grid;
place-items: center;
}
.spinner {
width: 40px;
height: 40px;
border-radius: 999px;
border: 3px solid var(--border);
border-top-color: #0e7c56;
animation: spin 0.8s linear infinite;
}
.progress-track {
width: 220px;
height: 6px;
border-radius: 999px;
background: var(--border);
overflow: hidden;
margin: 12px auto 0;
}
.progress-inner {
width: 40%;
height: 100%;
background: linear-gradient(90deg, #0e7c56, #22c55e, #f59e0b);
animation: progress-move 0.9s linear infinite;
}
@keyframes progress-move {
0% { transform: translateX(-100%); }
50% { transform: translateX(0%); }
100% { transform: translateX(100%); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="loader" class="loading-overlay hidden">
<div style="background:var(--surface); padding:18px 22px; border-radius:10px; border:1px solid var(--border); box-shadow:var(--shadow-md); text-align:center; min-width:260px; color:var(--text-main);">
<div class="spinner" style="margin:0 auto 12px;"></div>
<div class="progress-track"><div class="progress-inner"></div></div>
<div id="loader-text" style="margin-top:10px; font-size:13px; color:var(--text-muted);">
Processing report...
</div>
</div>
</div>
<header class="hero">
<div class="hero__brand">
<div class="hero__icon">K</div>
<div style="display:flex; align-items:center;">
<span class="hero__title">Kingfisher Viewer</span>
<span class="hero__subtitle">Access Map &amp; Findings</span>
</div>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn" id="theme-toggle" type="button">Light Mode</button>
<button class="btn" id="reset-btn">Load New Report</button>
</div>
</header>
<div class="info-banner">Client-Side Only. No data is uploaded anywhere.</div>
<main class="page">
<section id="upload-section" class="panel" style="max-width: 640px; margin: 0 auto;">
<div class="panel__header">
<div class="panel__title">
<h3>Upload Report</h3>
<p>Analyze Kingfisher JSON / JSONL output (auto-loads if provided on the CLI)</p>
</div>
</div>
<div style="padding:22px;">
<div class="upload-area" id="drop-zone">
<div class="upload-icon">📄</div>
<div class="upload-text">Drag &amp; drop a report here</div>
<div class="upload-sub">…or click to choose a file</div>
<div class="upload-sub">Your file stays in the browser—load JSON or JSONL reports locally.</div>
<input type="file" id="file-input" hidden accept=".json,.jsonl">
</div>
<div id="error-msg" class="hidden" style="margin-top:16px; padding:10px 12px; background:#fef2f2; border:1px solid #fecaca; border-radius:6px; color:#b91c1c; font-size:13px;"></div>
</div>
</section>
<div id="dashboard" class="hidden">
<div class="layout">
<aside class="sidebar">
<div class="nav-group">
<button class="nav-button active" data-view-target="view-dashboard">
<span class="nav-icon">📊</span>
Dashboard
</button>
<button class="nav-button" data-view-target="view-access">
<span class="nav-icon">🕸️</span>
Access Map
</button>
<button class="nav-button" data-view-target="view-findings">
<span class="nav-icon">📄</span>
Findings
</button>
</div>
<div class="nav-group">
<button class="btn" id="download-pdf" type="button" style="width:100%;">Download PDF 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 class="metric">
<div class="metric__label">Scan Duration</div>
<div class="metric__value" id="stat-duration" style="font-size:20px; margin-top:8px;">-</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel__header">
<div class="panel__title">
<h3>Finding Status Distribution</h3>
<p>Active vs Inactive vs Not Attempted</p>
</div>
</div>
<div class="chart-grid">
<div class="chart-card">
<canvas id="status-chart" width="260" height="220"></canvas>
<div class="chart-legend" id="status-legend">
<!-- filled by JS -->
</div>
</div>
</div>
</section>
</section>
<section id="view-access" class="view hidden">
<section class="panel" id="am-section">
<div class="panel__header">
<div class="panel__title">
<h3>Access Map</h3>
<p>Identity hierarchy: Identity &gt; Resources &gt; Resource &gt; Permissions</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn" id="copy-access-map" type="button">Copy Access Map</button>
<button class="btn" id="am-toggle" type="button">Collapse</button>
</div>
</div>
<div id="am-empty-notice" class="am-empty-hint hidden">No Access Map entries in this report.</div>
<div class="am-container" id="am-container">
<div class="am-sidebar">
<div class="am-search">
<input id="tree-search" type="text" placeholder="Filter identities or resources…">
</div>
<div id="am-tree-root" style="padding-top:8px;">
<div style="color:var(--text-muted); font-size:13px; text-align:center; margin-top:32px;">
No access map data found in report.
</div>
</div>
</div>
<div class="am-main">
<div id="am-empty-state" style="display:grid; place-items:center; height:100%; color:var(--text-muted);">
Select an item in the tree to view details.
</div>
<div id="am-detail-view" class="hidden">
<div class="am-card-header">
<div class="am-card-title">
<div id="am-detail-icon" class="detail-icon-lg">👤</div>
<div>
<div id="am-detail-name">Identity</div>
<div class="am-card-meta" id="am-detail-meta"></div>
</div>
</div>
</div>
<div class="detail-grid">
<div class="detail-field">
<label>Type</label>
<div id="am-detail-type">Identity</div>
</div>
<div class="detail-field">
<label>Cloud Provider</label>
<div id="am-detail-cloud">-</div>
</div>
</div>
<div id="am-token-container" class="hidden">
<h4 style="margin-bottom:10px; border-bottom:1px solid var(--border); padding-bottom:6px;">Token Details</h4>
<div class="detail-grid">
<div class="detail-field">
<label>Token Name</label>
<div id="am-token-name">-</div>
</div>
<div class="detail-field">
<label>Username</label>
<div id="am-token-username">-</div>
</div>
<div class="detail-field">
<label>Token Type</label>
<div id="am-token-type">-</div>
</div>
<div class="detail-field">
<label>Account Type</label>
<div id="am-token-account-type">-</div>
</div>
<div class="detail-field">
<label>User ID</label>
<div id="am-token-user">-</div>
</div>
<div class="detail-field">
<label>Company</label>
<div id="am-token-company">-</div>
</div>
<div class="detail-field">
<label>Created At</label>
<div id="am-token-created">-</div>
</div>
<div class="detail-field">
<label>Location</label>
<div id="am-token-location">-</div>
</div>
<div class="detail-field">
<label>Last Used</label>
<div id="am-token-last-used">-</div>
</div>
<div class="detail-field">
<label>Email</label>
<div id="am-token-email">-</div>
</div>
<div class="detail-field">
<label>Expires At</label>
<div id="am-token-expires">-</div>
</div>
<div class="detail-field">
<label>Profile URL</label>
<div id="am-token-url">-</div>
</div>
<div class="detail-field">
<label>Instance Version</label>
<div id="am-token-version">-</div>
</div>
<div class="detail-field">
<label>Enterprise</label>
<div id="am-token-enterprise">-</div>
</div>
</div>
<div id="am-token-scopes" style="display:flex; flex-wrap:wrap; gap:6px;"></div>
</div>
<div id="am-perms-container" class="hidden">
<h4 style="margin-bottom:10px; border-bottom:1px solid var(--border); padding-bottom:6px;">Permissions</h4>
<div id="am-perms-list" style="display:flex; flex-wrap:wrap; gap:6px;"></div>
</div>
</div>
</div>
</div>
</section>
</section>
<section id="view-findings" class="view hidden">
<section class="panel">
<div class="panel__header">
<div class="panel__title">
<h3>Findings</h3>
<p>Detailed list of detected secrets</p>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<button class="btn" id="download-json" type="button">Download JSON</button>
<button class="btn" id="download-csv" type="button">Export CSV</button>
<input type="text" id="search-input" class="search-input" placeholder="Search rule, path, snippet">
<select id="validation-filter" class="rows-select">
<option value="all" selected>All validation states</option>
<option value="active">Active Credential</option>
<option value="inactive">Inactive Credential</option>
<option value="not_attempted">Not Attempted</option>
</select>
<span class="rows-label">Rows</span>
<select id="rows-select" class="rows-select">
<option value="10" selected>10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
<div class="pager">
<button id="page-prev" class="pager-btn" type="button">&lsaquo;</button>
<span id="page-info">0 of 0</span>
<button id="page-next" class="pager-btn" type="button">&rsaquo;</button>
</div>
</div>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th class="sortable" data-field="rule">
Rule <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="location">
Location <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="validation">
Validation <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="confidence">
Confidence <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="line">
Line <span class="sort-indicator"></span>
</th>
</tr>
</thead>
<tbody id="findings-body"></tbody>
</table>
</div>
</section>
<section id="finding-detail" class="panel hidden">
<div class="panel__header">
<div class="panel__title">
<h3>Finding Details</h3>
</div>
<button class="btn" onclick="document.getElementById('finding-detail').classList.add('hidden')">Close</button>
</div>
<div style="padding:20px 22px 22px;">
<div class="detail-grid">
<div class="detail-field">
<label>Rule ID</label>
<div id="fd-rule-id"></div>
</div>
<div class="detail-field">
<label>Fingerprint</label>
<div id="fd-fingerprint"></div>
</div>
<div class="detail-field">
<label>Entropy</label>
<div id="fd-entropy"></div>
</div>
<div class="detail-field">
<label>Git Commit</label>
<div id="fd-commit"></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>
</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 downloadPdfBtn = document.getElementById("download-pdf");
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 (downloadPdfBtn) {
downloadPdfBtn.addEventListener("click", generatePdfReport);
}
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 (amEmptyNotice) {
amEmptyNotice.classList.toggle("hidden", hasAccess);
}
if (!hasAccess) {
setAccessMapCollapsed(true, { auto: true });
return;
}
if (previouslyAutoCollapsed) {
setAccessMapCollapsed(false);
}
}
function escapeHtml(unsafe) {
if (unsafe === undefined || unsafe === null) return "";
return unsafe
.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function parsePossiblyMultiJson(text) {
try {
return JSON.parse(text);
} catch (e) {
const trimmed = text.trim();
const parts = trimmed.split(/\r?\n(?=\s*{)/g).filter(Boolean);
if (parts.length > 1) {
try {
return JSON.parse("[" + parts.join(",") + "]");
} catch (e2) {}
}
}
return null;
}
function collectReportData(entries) {
const findings = [];
const accessMap = [];
let mainReport = null;
let statsReport = null;
const items = Array.isArray(entries) ? entries : [entries];
items.forEach((item) => {
if (!item || typeof item !== "object") return;
if (Array.isArray(item.findings) || Array.isArray(item.access_map)) {
if (!mainReport) {
mainReport = item;
}
}
if (Array.isArray(item.findings)) {
findings.push(...item.findings);
}
if (Array.isArray(item.access_map)) {
accessMap.push(...item.access_map);
}
if (item.rule && item.finding) {
findings.push(item);
}
if (item.provider && item.account) {
accessMap.push(item);
}
if (
typeof item.scan_duration !== "undefined" ||
typeof item.scanDuration !== "undefined" ||
typeof item.bytes_scanned !== "undefined" ||
item.kingfisher ||
item.stats ||
item.summary
) {
statsReport = item;
}
});
return { findings, accessMap, mainReport, statsReport };
}
function processFile(file) {
loaderText.textContent = 'Processing "' + file.name + '"…';
loader.classList.remove("hidden");
errorMsg.classList.add("hidden");
errorMsg.textContent = "";
setTimeout(() => {
const reader = new FileReader();
reader.onload = (e) => {
try {
parseAndRender(e.target.result);
} catch (err) {
console.error(err);
errorMsg.textContent = "Error parsing file: " + err.message;
errorMsg.classList.remove("hidden");
loader.classList.add("hidden");
}
};
reader.readAsText(file);
}, 30);
}
async function loadCliReport() {
try {
loaderText.textContent = "Loading report from CLI…";
loader.classList.remove("hidden");
const response = await fetch("/report", { cache: "no-store" });
if (response.status === 404) {
loader.classList.add("hidden");
return;
}
if (!response.ok) {
throw new Error("Server returned status " + response.status);
}
const text = await response.text();
parseAndRender(text);
} catch (err) {
loader.classList.add("hidden");
errorMsg.textContent = "Failed to load report from CLI: " + err.message;
errorMsg.classList.remove("hidden");
}
}
function resetViewer(promptFilePicker = false) {
findings = [];
accessMap = [];
filteredAccessMapView = [];
rawData = null;
currentFilter = "";
validationFilter = "all";
pageSize = 10;
currentPage = 1;
sortField = "rule";
sortDirection = "asc";
searchInput.value = "";
validationSelect.value = "all";
rowsSelect.value = "10";
treeSearch.value = "";
document.getElementById("am-empty-state").classList.remove("hidden");
document.getElementById("am-detail-view").classList.add("hidden");
setAccessMapCollapsed(true, { auto: true });
if (amToggle) amToggle.disabled = true;
if (amEmptyNotice) amEmptyNotice.classList.add("hidden");
renderAccessMapTree();
renderFindingsTable();
updateMetrics();
setActiveView("view-dashboard");
dashboard.classList.add("hidden");
uploadSection.classList.remove("hidden");
errorMsg.classList.add("hidden");
errorMsg.textContent = "";
loader.classList.add("hidden");
loaderText.textContent = "Processing report...";
fileInput.value = "";
if (promptFilePicker) {
fileInput.click();
}
}
function parseAndRender(text) {
const t0 = performance.now();
findings = [];
accessMap = [];
rawData = null;
let parsed = parsePossiblyMultiJson(text);
if (parsed === null) {
const lines = text.split(/\r?\n/);
const entries = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line[0] !== "{") continue;
try {
const obj = JSON.parse(line);
entries.push(obj);
} catch (errLine) {
console.warn("Skipping invalid JSON line", i);
}
}
const collected = collectReportData(entries);
findings = collected.findings;
accessMap = collected.accessMap;
if (collected.mainReport || collected.statsReport) {
rawData = Object.assign({}, collected.mainReport || {}, collected.statsReport || {});
}
} else {
const collected = collectReportData(parsed);
findings = collected.findings;
accessMap = collected.accessMap;
if (collected.mainReport || collected.statsReport || parsed) {
rawData = Object.assign({}, collected.mainReport || {}, collected.statsReport || {});
}
}
currentPage = 1;
currentFilter = "";
validationFilter = "all";
searchInput.value = "";
validationSelect.value = "all";
setActiveView("view-dashboard");
updateMetrics();
renderAccessMapTree();
renderFindingsTable();
uploadSection.classList.add("hidden");
dashboard.classList.remove("hidden");
const elapsed = performance.now() - t0;
loaderText.textContent = "Done in " + elapsed.toFixed(1) + " ms";
setTimeout(() => loader.classList.add("hidden"), 250);
}
function updateMetrics() {
const totalFindings = findings.length;
document.getElementById("stat-total").textContent = totalFindings.toString();
const highSev = findings.filter((f) => {
const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : "";
return conf.toLowerCase() === "high";
}).length;
document.getElementById("stat-high").textContent = highSev.toString();
const validationCounts = calculateValidationCounts();
document.getElementById("stat-active").textContent = (validationCounts.active || 0).toString();
document.getElementById("stat-identities").textContent = (accessMap || []).length.toString();
const durEl = document.getElementById("stat-duration");
const scanSeconds = resolveScanDurationSeconds(rawData);
durEl.textContent = formatDurationText(scanSeconds);
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 status = (finding.validation && finding.validation.status
? String(finding.validation.status)
: "").toLowerCase();
if (filterLower) {
if (!ruleName.includes(filterLower) && !path.includes(filterLower) && !snippet.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() {
const counts = { active: 0, inactive: 0, not_attempted: 0, unknown: 0 };
findings.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 resolveScanDurationSeconds(data) {
if (!data) return null;
const candidates = [
data.scan_duration,
data.scanDuration,
data.duration_seconds,
data.duration,
data.stats && data.stats.scan_duration,
data.stats && data.stats.scanDuration,
data.summary && data.summary.scan_duration,
];
for (const candidate of candidates) {
const parsed = parseDuration(candidate);
if (parsed != null) return parsed;
}
return null;
}
function parseDuration(value) {
if (value === undefined || value === null) return null;
if (typeof value === "number" && Number.isFinite(value)) return value;
const numeric = Number(value);
if (Number.isFinite(numeric)) return numeric;
if (typeof value === "string") {
const match = value.match(/([\d.]+)\s*s/i);
if (match) {
const parsed = Number(match[1]);
if (Number.isFinite(parsed)) return parsed;
}
}
return null;
}
function formatDurationText(seconds) {
if (seconds === null || seconds === undefined) return "-";
const value = Number(seconds);
if (!Number.isFinite(value)) return "-";
if (value < 1) return value.toFixed(3) + "s";
return value.toFixed(2) + "s";
}
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 generatePdfReport() {
if (!rawData) {
alert("Load a report before downloading a PDF report.");
return;
}
const statusOrder = { active: 0, inactive: 1, not_attempted: 2, unknown: 3 };
const findingsForReport = Array.isArray(findings) ? findings.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();
const durationSeconds = resolveScanDurationSeconds(rawData);
const durationText = formatDurationText(durationSeconds);
const hasAccess = Array.isArray(accessMap) && accessMap.length > 0;
const statusImage = statusChartCanvas ? statusChartCanvas.toDataURL("image/png") : "";
const highConfidence = findings.filter((f) => {
const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : "";
return conf.toLowerCase() === "high";
}).length;
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 snippet = (finding.snippet || "").toString().replace(/\s+/g, " ").trim();
return `
<tr>
<td>${escapeHtml(rule.name || rule.id || "")}</td>
<td>${escapeHtml(finding.path || "")}</td>
<td>${escapeHtml(statusRaw)}</td>
<td>${escapeHtml(finding.confidence || "")}</td>
<td>${finding.line != null ? escapeHtml(finding.line) : ""}</td>
<td>${escapeHtml(snippet.slice(0, 200))}</td>
</tr>
`;
})
.join("")
: '<tr><td colspan="6">No findings available.</td></tr>';
const accessListHtml = hasAccess
? accessMap
.map((entry) => {
const groups = Array.isArray(entry.groups) ? entry.groups : [];
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>
<ul class="access-groups">
${groupList || "<li>No resources recorded.</li>"}
</ul>
</li>
`;
})
.join("")
: "";
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>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 24px; 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; }
th, td { border: 1px solid #d1d5db; padding: 8px 10px; font-size: 13px; text-align: left; }
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())}</div>
<div class="section">
<h2>Dashboard</h2>
<div class="grid">
<div class="stat"><div class="label">Total Findings</div><div class="value">${findings.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 class="stat"><div class="label">Scan Duration</div><div class="value">${durationText}</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>Access Map</h2>
${accessSectionContent}
</div>
<div class="section">
<h2>Findings (Active first)</h2>
<table>
<thead>
<tr>
<th>Rule</th>
<th>File Path</th>
<th>Status</th>
<th>Confidence</th>
<th>Line</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 PDF 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 || "";
document.getElementById("fd-fingerprint").textContent = finding.fingerprint || "";
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 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 = "";
}
}
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 identityNameMatches = Boolean(filterLower) && account.toLowerCase().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.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>