2025-12-04 22:02:30 -08:00
<!doctype html>
2025-12-12 17:21:17 -08:00
< html lang = "en" data-theme = "dark" >
2025-12-04 22:02:30 -08:00
< head >
< meta charset = "UTF-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" / >
< title > Kingfisher Access Map Viewer< / title >
< style >
:root {
2025-12-12 17:21:17 -08:00
color-scheme: dark;
2026-04-16 10:26:07 -07:00
--brand: #00ed64;
--brand-dark: #13aa52;
--brand-soft: rgba(0, 237, 100, 0.12);
--brand-soft-strong: rgba(0, 237, 100, 0.18);
--bg: #08110e;
--surface: #0f1716;
--surface-muted: #121d1b;
--surface-strong: #15211f;
--text-main: #f5fbf7;
--text-muted: #a8bbb1;
--border: #1f332c;
--border-strong: #315244;
--shadow-sm: 0 10px 30px rgba(0, 0, 0, 0.14);
--shadow-md: 0 18px 48px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 28px 72px rgba(0, 0, 0, 0.28);
2025-12-12 17:21:17 -08:00
--critical: #fca5a5;
--success: #34d399;
2026-04-16 10:26:07 -07:00
--radius: 16px;
--hover: #182522;
--table-header: #12201d;
--code-bg: #0d1617;
--code-border: #223733;
--code-border-strong: rgba(0, 237, 100, 0.24);
2025-12-12 21:51:57 -08:00
--code-text: #e2e8f0;
2026-04-16 10:26:07 -07:00
--code-accent: rgba(0, 237, 100, 0.07);
--link-strong: #8fffd0;
--link-strong-hover: #d9fff0;
--link-underline: rgba(143, 255, 208, 0.32);
2025-12-12 17:21:17 -08:00
}
:root[data-theme="light"] {
color-scheme: light;
2026-04-16 10:26:07 -07:00
--brand: #13aa52;
--brand-dark: #0f7f3d;
--brand-soft: rgba(19, 170, 82, 0.1);
--brand-soft-strong: rgba(19, 170, 82, 0.16);
--bg: #eef4f0;
2025-12-04 22:02:30 -08:00
--surface: #ffffff;
2026-04-16 10:26:07 -07:00
--surface-strong: #f5f9f7;
--surface-muted: #f8fbf9;
--text-main: #15211d;
--text-muted: #5f746a;
--border: #dbe7df;
--border-strong: #bfd5c7;
--shadow-sm: 0 8px 24px rgba(15, 23, 42, 0.06);
--shadow-md: 0 14px 34px rgba(15, 23, 42, 0.1);
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.12);
2025-12-04 22:02:30 -08:00
--critical: #dc2626;
--success: #059669;
2026-04-16 10:26:07 -07:00
--hover: #edf5f0;
--table-header: #f5f9f7;
--code-bg: #f7fbf9;
--code-border: #dbe7df;
--code-border-strong: rgba(19, 170, 82, 0.18);
2025-12-12 21:51:57 -08:00
--code-text: #0f172a;
2026-04-16 10:26:07 -07:00
--code-accent: rgba(19, 170, 82, 0.08);
--link-strong: #0f7f3d;
--link-strong-hover: #0b5d2d;
--link-underline: rgba(15, 127, 61, 0.28);
2025-12-04 22:02:30 -08:00
}
* { box-sizing: border-box; }
body {
margin: 0;
2026-04-16 10:26:07 -07:00
background:
radial-gradient(circle at top left, rgba(0, 237, 100, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.06), transparent 30%),
linear-gradient(180deg, var(--bg) 0%, var(--bg) 100%);
2025-12-04 22:02:30 -08:00
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;
2026-04-16 10:26:07 -07:00
min-height: 100vh;
2025-12-04 22:02:30 -08:00
}
/* Header */
.hero {
2026-04-16 10:57:40 -07:00
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), var(--surface);
color: var(--text-main);
padding: 10px 24px;
min-height: 64px;
2025-12-04 22:02:30 -08:00
display: flex;
align-items: center;
justify-content: space-between;
2026-04-16 10:26:07 -07:00
gap: 16px;
2026-04-16 10:57:40 -07:00
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
2025-12-04 22:02:30 -08:00
position: sticky;
top: 0;
z-index: 50;
2026-04-16 10:26:07 -07:00
backdrop-filter: blur(10px);
2025-12-04 22:02:30 -08:00
}
.hero__brand {
display: flex;
align-items: center;
2026-04-16 10:26:07 -07:00
gap: 14px;
min-width: 0;
2025-12-04 22:02:30 -08:00
}
.hero__icon {
2026-04-16 10:26:07 -07:00
width: 40px;
height: 40px;
2026-04-16 10:57:40 -07:00
background: linear-gradient(180deg, rgba(19, 170, 82, 0.95), rgba(15, 127, 61, 0.95));
color: #ffffff;
2026-04-16 10:26:07 -07:00
border-radius: 12px;
2025-12-04 22:02:30 -08:00
display: grid;
place-items: center;
2026-04-16 10:57:40 -07:00
border: 1px solid rgba(19, 170, 82, 0.25);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 10px 18px rgba(15, 23, 42, 0.12);
2026-04-16 10:26:07 -07:00
font-weight: 700;
2025-12-04 22:02:30 -08:00
font-size: 18px;
}
.hero__title {
2026-04-16 10:26:07 -07:00
font-size: 20px;
font-weight: 700;
2025-12-04 22:02:30 -08:00
letter-spacing: -0.02em;
}
.hero__subtitle {
font-size: 13px;
2026-04-16 10:57:40 -07:00
color: var(--text-muted);
2026-04-16 10:26:07 -07:00
margin-left: 12px;
padding-left: 12px;
2026-04-16 10:57:40 -07:00
border-left: 1px solid var(--border);
2025-12-04 22:02:30 -08:00
}
2025-12-17 11:57:35 -08:00
.layout {
display: grid;
2026-04-16 10:57:40 -07:00
grid-template-columns: 124px 1fr;
gap: 20px;
2025-12-17 11:57:35 -08:00
align-items: start;
}
.sidebar {
2026-04-16 10:57:40 -07:00
background: linear-gradient(180deg, #0b1832 0%, #0e2143 100%);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 20px;
2026-04-16 10:26:07 -07:00
box-shadow: var(--shadow-md);
2026-04-16 10:57:40 -07:00
padding: 14px 10px;
2025-12-17 11:57:35 -08:00
position: sticky;
2026-04-16 10:57:40 -07:00
top: 84px;
2025-12-17 11:57:35 -08:00
display: flex;
flex-direction: column;
2026-04-16 10:57:40 -07:00
align-items: center;
gap: 18px;
2026-04-16 10:26:07 -07:00
}
.sidebar-section-label {
2026-04-16 10:57:40 -07:00
display: none;
2026-04-16 10:26:07 -07:00
}
.sidebar-nav {
display: flex;
flex-direction: column;
2026-04-16 10:57:40 -07:00
gap: 8px;
width: 100%;
align-items: center;
2025-12-17 11:57:35 -08:00
}
.nav-group {
display: flex;
flex-direction: column;
2026-04-16 10:57:40 -07:00
gap: 10px;
width: 100%;
align-items: center;
2025-12-17 11:57:35 -08:00
}
.nav-button {
width: 100%;
2026-04-16 10:57:40 -07:00
text-align: center;
padding: 10px 6px 9px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: transparent;
color: rgba(255, 255, 255, 0.84);
2025-12-17 11:57:35 -08:00
font-weight: 600;
cursor: pointer;
display: flex;
2026-04-16 10:57:40 -07:00
flex-direction: column;
2025-12-17 11:57:35 -08:00
align-items: center;
2026-04-16 10:57:40 -07:00
gap: 8px;
box-shadow: none;
2026-04-16 10:26:07 -07:00
transition: border 0.15s ease, background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
2025-12-17 11:57:35 -08:00
}
.nav-button:hover {
2026-04-16 10:57:40 -07:00
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.08);
2026-04-16 10:26:07 -07:00
transform: translateY(-1px);
2025-12-17 11:57:35 -08:00
}
.nav-button.active {
2026-04-16 10:57:40 -07:00
background: rgba(255, 255, 255, 0.08);
border-color: rgba(143, 255, 208, 0.28);
color: #ffffff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
2026-04-16 10:26:07 -07:00
}
:root[data-theme="light"] .nav-button.active {
2026-04-16 10:57:40 -07:00
color: #ffffff;
2025-12-17 11:57:35 -08:00
}
.nav-icon {
2026-04-16 10:57:40 -07:00
width: 34px;
height: 34px;
border-radius: 10px;
2026-04-16 10:26:07 -07:00
display: grid;
place-items: center;
2026-04-16 10:57:40 -07:00
font-size: 15px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
2026-04-16 10:26:07 -07:00
opacity: 0.95;
}
2026-04-16 10:57:40 -07:00
.nav-icon svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
2026-04-16 10:26:07 -07:00
.nav-button__content {
display: flex;
flex-direction: column;
2026-04-16 10:57:40 -07:00
gap: 0;
2026-04-16 10:26:07 -07:00
min-width: 0;
2026-04-16 10:57:40 -07:00
align-items: center;
width: 100%;
2026-04-16 10:26:07 -07:00
}
.nav-button__title {
2026-04-16 10:57:40 -07:00
font-size: 9px;
2026-04-16 10:26:07 -07:00
font-weight: 700;
2026-04-16 10:57:40 -07:00
text-transform: uppercase;
letter-spacing: 0.04em;
2026-04-16 10:26:07 -07:00
color: inherit;
2026-04-16 10:57:40 -07:00
line-height: 1.2;
text-align: center;
max-width: 100%;
white-space: nowrap;
2026-04-16 10:26:07 -07:00
}
.nav-button__sub {
2026-04-16 10:57:40 -07:00
display: none;
2025-12-17 11:57:35 -08:00
}
.view {
display: flex;
flex-direction: column;
gap: 18px;
}
.view-stack {
display: flex;
flex-direction: column;
2026-04-16 10:57:40 -07:00
gap: 20px;
}
.workspace-topbar {
display: grid;
grid-template-columns: minmax(320px, 1.1fr) minmax(420px, 1fr);
2025-12-17 11:57:35 -08:00
gap: 18px;
2026-04-16 10:57:40 -07:00
align-items: start;
}
.workspace-overview,
.workspace-actions {
border: 1px solid var(--border);
border-radius: var(--radius);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
box-shadow: var(--shadow-sm);
}
.workspace-overview {
padding: 18px 20px;
}
.workspace-overview__eyebrow {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.workspace-overview__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-top: 6px;
}
.workspace-overview__title {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--text-main);
}
.workspace-overview__meta {
margin-top: 6px;
font-size: 13px;
color: var(--text-muted);
max-width: 680px;
word-break: break-word;
}
.workspace-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: var(--surface-muted);
border: 1px solid var(--border);
font-size: 11px;
font-weight: 700;
color: var(--text-main);
white-space: nowrap;
}
.workspace-overview__stats {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.workspace-overview__stat {
padding: 12px 14px;
border-radius: 14px;
background: var(--surface-muted);
border: 1px solid var(--border);
}
.workspace-overview__value {
font-size: 24px;
font-weight: 800;
letter-spacing: -0.04em;
color: var(--text-main);
}
.workspace-overview__label {
margin-top: 3px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
font-weight: 700;
}
.workspace-actions {
padding: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
2025-12-17 11:57:35 -08:00
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
top: auto;
2026-04-16 10:57:40 -07:00
flex-direction: row;
justify-content: flex-start;
padding: 10px;
overflow-x: auto;
overflow-y: hidden;
border-radius: 16px;
}
.sidebar-nav,
.nav-group {
flex-direction: row;
width: max-content;
}
.nav-button {
min-width: 112px;
max-width: 112px;
}
.workspace-topbar {
grid-template-columns: 1fr;
}
.workspace-actions {
grid-template-columns: 1fr;
2025-12-17 11:57:35 -08:00
}
}
2025-12-16 21:24:47 -08:00
.info-banner {
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(0, 237, 100, 0.08), transparent), var(--surface);
2025-12-16 21:24:47 -08:00
border-bottom: 1px solid var(--border);
color: var(--text-muted);
text-align: center;
2026-04-16 10:26:07 -07:00
padding: 11px 16px;
2025-12-16 21:24:47 -08:00
font-size: 13px;
2026-04-16 10:26:07 -07:00
letter-spacing: 0.01em;
2025-12-16 21:24:47 -08:00
}
2025-12-04 22:02:30 -08:00
.page {
max-width: 1600px;
2026-04-16 10:26:07 -07:00
margin: 20px auto 32px;
2025-12-04 22:02:30 -08:00
padding: 0 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.panel {
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 40%), var(--surface);
2025-12-04 22:02:30 -08:00
border: 1px solid var(--border);
border-radius: var(--radius);
2026-04-16 10:26:07 -07:00
box-shadow: var(--shadow-md);
2025-12-04 22:02:30 -08:00
overflow: hidden;
}
.panel__header {
2026-04-16 10:26:07 -07:00
padding: 18px 22px;
2025-12-04 22:02:30 -08:00
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent), var(--surface-strong);
2025-12-04 22:02:30 -08:00
}
2026-04-16 10:26:07 -07:00
.panel__title h3 { margin: 0; font-size: 17px; font-weight: 700; letter-spacing: -0.02em; }
.panel__title p { margin: 5px 0 0; font-size: 13px; color: var(--text-muted); }
2025-12-04 22:02:30 -08:00
/* Upload */
2026-04-16 10:57:40 -07:00
.upload-shell {
padding: 22px;
display: flex;
flex-direction: column;
gap: 16px;
}
2025-12-04 22:02:30 -08:00
.upload-area {
2026-04-16 10:57:40 -07:00
padding: 44px 30px 36px;
2025-12-04 22:02:30 -08:00
text-align: center;
2026-04-16 10:26:07 -07:00
background:
radial-gradient(circle at top center, rgba(0, 237, 100, 0.12), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent),
var(--surface-muted);
2025-12-04 22:02:30 -08:00
cursor: pointer;
transition: all 0.2s ease;
2026-04-16 10:26:07 -07:00
border: 1px dashed var(--border-strong);
2025-12-04 22:02:30 -08:00
border-radius: var(--radius);
2026-04-16 10:26:07 -07:00
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
2025-12-04 22:02:30 -08:00
}
.upload-area:hover,
.upload-area.active {
2026-04-16 10:26:07 -07:00
background:
radial-gradient(circle at top center, rgba(0, 237, 100, 0.18), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent),
var(--surface-muted);
2025-12-04 22:02:30 -08:00
border-color: var(--brand);
2026-04-16 10:26:07 -07:00
transform: translateY(-1px);
2025-12-04 22:02:30 -08:00
}
2026-04-16 09:56:56 -07:00
.upload-icon {
width: 68px;
height: 68px;
margin: 0 auto 16px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 34px;
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(0, 237, 100, 0.18), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(0, 237, 100, 0.22);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.18);
opacity: 1;
2026-04-16 09:56:56 -07:00
}
.upload-text {
font-size: 28px;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-main);
max-width: 420px;
margin: 0 auto;
}
.upload-sub {
color: var(--text-muted);
margin-top: 8px;
font-size: 15px;
line-height: 1.5;
max-width: 560px;
margin-left: auto;
margin-right: auto;
}
.upload-action-row {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
flex-wrap: wrap;
2026-04-16 10:57:40 -07:00
margin-top: 20px;
2026-04-16 09:56:56 -07:00
}
.upload-primary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
border-radius: 999px;
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, var(--brand), var(--brand-dark));
2026-04-16 09:56:56 -07:00
color: #fff;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.01em;
2026-04-16 10:26:07 -07:00
box-shadow: 0 14px 28px rgba(0, 237, 100, 0.18);
2026-04-16 09:56:56 -07:00
}
.upload-action-hint {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.upload-help-grid {
display: grid;
2026-04-16 10:57:40 -07:00
grid-template-columns: repeat(2, minmax(0, 1fr));
2026-04-16 09:56:56 -07:00
gap: 12px;
text-align: left;
}
.upload-help-card {
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
2026-04-16 09:56:56 -07:00
border: 1px solid var(--border);
2026-04-16 10:26:07 -07:00
border-radius: 14px;
2026-04-16 10:57:40 -07:00
padding: 14px 15px 13px;
2026-04-16 09:56:56 -07:00
box-shadow: var(--shadow-sm);
2026-04-16 10:57:40 -07:00
min-height: 0;
2026-04-16 09:56:56 -07:00
}
.upload-help-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--brand-dark);
margin-bottom: 6px;
}
.upload-help-text {
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
}
.upload-help-text strong {
color: var(--text-main);
font-weight: 700;
}
2025-12-04 22:02:30 -08:00
2026-04-16 10:57:40 -07:00
@media (max-width: 720px) {
.upload-help-grid {
grid-template-columns: 1fr;
}
}
2025-12-04 22:02:30 -08:00
/* Metrics */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
2026-04-16 10:26:07 -07:00
padding: 22px;
gap: 18px;
2025-12-04 22:02:30 -08:00
}
.metric {
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 42%), var(--surface-muted);
2025-12-04 22:02:30 -08:00
border: 1px solid var(--border);
border-radius: var(--radius);
2026-04-16 10:26:07 -07:00
padding: 18px 18px 20px;
2025-12-04 22:02:30 -08:00
box-shadow: var(--shadow-sm);
2026-04-16 10:26:07 -07:00
position: relative;
overflow: hidden;
min-height: 112px;
}
.metric::before {
content: "";
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 3px;
background: rgba(255, 255, 255, 0.08);
2025-12-04 22:02:30 -08:00
}
.metric__label {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric__value {
2026-04-16 10:26:07 -07:00
font-size: 34px;
2025-12-04 22:02:30 -08:00
font-weight: 700;
2026-04-16 10:26:07 -07:00
margin-top: 10px;
letter-spacing: -0.04em;
}
.metric.critical::before { background: #fb7185; }
.metric.success::before { background: var(--brand); }
.metric:not(.critical):not(.success)::before { background: rgba(0, 237, 100, 0.38); }
.metric.critical .metric__label,
.metric.success .metric__label {
color: var(--text-main);
2025-12-04 22:02:30 -08:00
}
.metric.critical .metric__value { color: var(--critical); }
.metric.success .metric__value { color: var(--success); }
/* Access Map */
.am-container {
2026-04-16 10:26:07 -07:00
padding: 18px 22px 22px;
2025-12-12 17:21:17 -08:00
background: var(--surface);
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.am-toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
2026-04-16 10:26:07 -07:00
padding: 14px;
border: 1px solid var(--border);
border-radius: 14px;
margin-bottom: 18px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface-muted);
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.am-toolbar input {
flex: 1;
min-width: 200px;
2026-04-16 10:26:07 -07:00
padding: 10px 12px;
border-radius: 10px;
2025-12-04 22:02:30 -08:00
border: 1px solid var(--border);
font-size: 13px;
2026-04-16 10:26:07 -07:00
background: var(--surface);
2025-12-12 17:21:17 -08:00
color: var(--text-main);
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.am-toolbar input:focus {
2025-12-04 22:02:30 -08:00
outline: none;
border-color: var(--brand);
}
2026-02-13 18:35:36 -08:00
.am-stats {
display: flex;
gap: 16px;
flex-wrap: wrap;
2026-04-16 10:26:07 -07:00
padding: 14px 16px;
background: linear-gradient(180deg, rgba(0, 237, 100, 0.06), transparent), var(--surface-muted);
2026-02-13 18:35:36 -08:00
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
2026-04-16 10:26:07 -07:00
box-shadow: var(--shadow-sm);
2026-02-13 18:35:36 -08:00
}
.am-stat {
display: flex;
align-items: center;
gap: 6px;
}
.am-stat-value {
font-weight: 700;
font-size: 16px;
color: var(--text-main);
}
.am-stat-label {
color: var(--text-muted);
}
2025-12-17 11:57:35 -08:00
.am-empty-hint {
2026-04-16 10:26:07 -07:00
padding: 14px 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface-muted);
2025-12-17 11:57:35 -08:00
border: 1px dashed var(--border);
2026-04-16 10:26:07 -07:00
border-radius: 12px;
2025-12-17 11:57:35 -08:00
color: var(--text-muted);
margin: 12px 20px 0;
}
2026-02-13 18:35:36 -08:00
/* Identity cards */
.am-card-list {
display: flex;
flex-direction: column;
gap: 12px;
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.id-card {
border: 1px solid var(--border);
border-radius: var(--radius);
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 44%), var(--surface);
2026-02-13 18:35:36 -08:00
overflow: hidden;
2026-04-16 10:26:07 -07:00
box-shadow: var(--shadow-sm);
transition: border-color 0.15s, transform 0.12s ease, box-shadow 0.15s ease;
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.id-card:hover {
border-color: var(--border-strong);
2026-04-16 10:26:07 -07:00
transform: translateY(-1px);
box-shadow: var(--shadow-md);
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.id-card__header {
2026-04-16 10:26:07 -07:00
padding: 16px 18px;
2025-12-04 22:02:30 -08:00
display: flex;
2026-02-13 18:35:36 -08:00
align-items: flex-start;
gap: 12px;
2025-12-04 22:02:30 -08:00
cursor: pointer;
user-select: none;
}
2026-02-13 18:35:36 -08:00
.id-card__header:hover {
2026-04-16 10:26:07 -07:00
background: rgba(255, 255, 255, 0.02);
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.id-card__avatar {
width: 36px;
height: 36px;
2026-04-16 10:26:07 -07:00
border-radius: 12px;
2025-12-04 22:02:30 -08:00
display: grid;
place-items: center;
2026-02-13 18:35:36 -08:00
font-size: 18px;
2025-12-04 22:02:30 -08:00
flex-shrink: 0;
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(0, 237, 100, 0.16), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(0, 237, 100, 0.16);
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.id-card__info {
flex: 1;
min-width: 0;
}
2025-12-04 22:02:30 -08:00
2026-02-13 18:35:36 -08:00
.id-card__name {
font-weight: 700;
font-size: 14px;
word-break: break-all;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.id-card__meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 4px;
font-size: 12px;
color: var(--text-muted);
}
.id-card__meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.id-card__perms-preview {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.id-card__toggle {
font-size: 11px;
color: var(--text-muted);
flex-shrink: 0;
margin-top: 4px;
}
.id-card__body {
2025-12-04 22:02:30 -08:00
display: none;
2026-02-13 18:35:36 -08:00
border-top: 1px solid var(--border);
2026-04-16 10:26:07 -07:00
padding: 18px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent), var(--surface-muted);
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.id-card__body.open {
2025-12-04 22:02:30 -08:00
display: block;
}
2026-02-13 18:35:36 -08:00
.id-card__section {
margin-bottom: 14px;
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.id-card__section:last-child {
margin-bottom: 0;
}
.id-card__section-title {
font-size: 12px;
2025-12-04 22:02:30 -08:00
font-weight: 700;
2026-02-13 18:35:36 -08:00
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: 8px;
2025-12-04 22:02:30 -08:00
display: flex;
align-items: center;
2026-02-13 18:35:36 -08:00
gap: 6px;
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.resource-grid {
2025-12-04 22:02:30 -08:00
display: grid;
2026-02-13 18:35:36 -08:00
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
}
.resource-chip {
display: flex;
flex-direction: column;
gap: 4px;
2026-04-16 10:26:07 -07:00
padding: 10px 12px;
2025-12-04 22:02:30 -08:00
border: 1px solid var(--border);
2026-04-16 10:26:07 -07:00
border-radius: 10px;
2026-02-13 18:35:36 -08:00
background: var(--surface);
font-size: 12px;
2026-04-16 10:26:07 -07:00
box-shadow: var(--shadow-sm);
2026-02-13 18:35:36 -08:00
}
.resource-chip__name {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
font-weight: 600;
word-break: break-all;
color: var(--text-main);
}
.resource-chip__name a {
font-size: 10px;
color: var(--brand);
margin-left: 4px;
2025-12-04 22:02:30 -08:00
}
2026-02-13 18:35:36 -08:00
.resource-chip__perms {
display: flex;
flex-wrap: wrap;
gap: 3px;
}
.token-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 6px 16px;
}
.token-field {
font-size: 12px;
}
.token-field strong {
color: var(--text-muted);
font-weight: 600;
}
.id-card__footer {
2026-04-16 10:26:07 -07:00
padding: 10px 16px;
2026-02-13 18:35:36 -08:00
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-muted);
2026-04-16 10:26:07 -07:00
background: rgba(255, 255, 255, 0.02);
2026-02-13 18:35:36 -08:00
}
.id-card__footer code {
background: var(--surface-muted);
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* keep badge styles for card headers */
2025-12-04 22:02:30 -08:00
.am-card-meta {
display: flex;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
flex-wrap: wrap;
}
.badge {
2026-04-16 10:26:07 -07:00
padding: 4px 9px;
2025-12-04 22:02:30 -08:00
border-radius: 999px;
2026-04-16 10:26:07 -07:00
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
2025-12-04 22:02:30 -08:00
border: 1px solid transparent;
}
.badge-aws { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
.badge-gcp { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
2026-01-01 22:24:57 -08:00
.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; }
2025-12-04 22:02:30 -08:00
.badge-perm { background: #ecfdf5; color: #16a34a; border-color: #bbf7d0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
2026-04-30 18:11:10 -07:00
.badge-perm--admin { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
.badge-perm--privesc { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
.badge-perm--risky { background: #fffbeb; color: #b45309; border-color: #fde68a; }
.badge-perm--readonly { background: #ecfdf5; color: #16a34a; border-color: #bbf7d0; }
/* Severity rollup chips on the card header */
.id-card__rollup {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.rollup-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
border: 1px solid transparent;
letter-spacing: 0.01em;
}
.rollup-chip--admin { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
.rollup-chip--privesc { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
.rollup-chip--risky { background: #fffbeb; color: #b45309; border-color: #fde68a; }
.rollup-chip--readonly { background: #ecfdf5; color: #16a34a; border-color: #bbf7d0; }
/* Identity discriminator subtitle */
.id-card__discriminator {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
/* Provider section header */
.am-provider-section { margin-bottom: 18px; }
.am-provider-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: linear-gradient(180deg, rgba(0, 237, 100, 0.04), transparent), var(--surface-muted);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
user-select: none;
margin-bottom: 8px;
font-size: 13px;
}
.am-provider-header:hover { border-color: var(--border-strong); }
.am-provider-header__caret { font-size: 11px; color: var(--text-muted); width: 10px; }
.am-provider-header__title { font-weight: 700; }
.am-provider-header__sub {
color: var(--text-muted);
font-size: 12px;
margin-left: auto;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.am-provider-section__cards { display: flex; flex-direction: column; gap: 12px; }
.am-provider-section.collapsed .am-provider-section__cards { display: none; }
.am-provider-section.collapsed .am-provider-header__caret { transform: rotate(-90deg); }
/* Group inside expanded card body */
.perm-group {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
padding: 12px;
margin-bottom: 10px;
}
.perm-group:last-child { margin-bottom: 0; }
.perm-group__resources {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.perm-group__resource {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
padding: 4px 8px;
border-radius: 8px;
background: var(--surface-muted);
border: 1px solid var(--border);
color: var(--text-main);
word-break: break-all;
}
.perm-group__resource a {
color: var(--brand);
margin-left: 4px;
font-size: 10px;
}
.perm-group__shared-note {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 8px;
font-style: italic;
}
/* Severity sub-section inside a permission group */
.perm-severity {
margin-top: 8px;
}
.perm-severity__title {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: 6px;
cursor: pointer;
user-select: none;
}
.perm-severity__title .perm-severity__caret { font-size: 10px; }
.perm-severity__pills { display: flex; flex-wrap: wrap; gap: 3px; }
.perm-severity.collapsed .perm-severity__pills { display: none; }
.perm-severity.collapsed .perm-severity__caret { transform: rotate(-90deg); }
/* Critical-only filter toggle */
.am-toolbar__toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
user-select: none;
}
.am-toolbar__toggle input { margin: 0; cursor: pointer; }
.am-toolbar__toggle.active {
border-color: #fca5a5;
color: #b91c1c;
background: #fef2f2;
}
2025-12-04 22:02:30 -08:00
.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;
}
2025-12-17 11:57:35 -08:00
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
padding: 0 20px 20px;
}
.chart-card {
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
2025-12-17 11:57:35 -08:00
border: 1px solid var(--border);
border-radius: var(--radius);
2026-04-16 10:26:07 -07:00
padding: 18px;
2025-12-17 11:57:35 -08:00
box-shadow: var(--shadow-sm);
display: flex;
gap: 12px;
align-items: center;
2026-04-23 14:42:10 -07:00
flex-wrap: wrap;
min-width: 0;
2025-12-17 11:57:35 -08:00
min-height: 220px;
2026-04-23 14:42:10 -07:00
overflow: hidden;
2026-04-16 10:26:07 -07:00
transition: transform 0.12s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
2026-04-23 14:42:10 -07:00
.chart-card > canvas {
flex-shrink: 0;
}
2026-04-16 10:26:07 -07:00
.chart-card:hover {
transform: translateY(-1px);
border-color: var(--border-strong);
box-shadow: var(--shadow-md);
2025-12-17 11:57:35 -08:00
}
.chart-legend {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: var(--text-main);
2026-04-23 14:42:10 -07:00
min-width: 0;
flex: 1 1 160px;
overflow-wrap: anywhere;
word-break: break-word;
2025-12-17 11:57:35 -08:00
}
.chart-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
2026-04-23 14:42:10 -07:00
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
2025-12-17 11:57:35 -08:00
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
border: 1px solid var(--border);
flex-shrink: 0;
}
2026-04-16 09:56:56 -07:00
#status-chart,
2026-04-16 10:26:07 -07:00
#source-chart,
canvas[id^="source-detector-chart-"] {
background: radial-gradient(circle at center, rgba(0, 237, 100, 0.05), transparent 58%), var(--surface-muted);
2025-12-17 11:57:35 -08:00
border: 1px solid var(--border);
2026-04-16 10:26:07 -07:00
border-radius: 14px;
2025-12-17 11:57:35 -08:00
box-shadow: var(--shadow-sm);
}
2026-04-16 09:56:56 -07:00
.dashboard-breakdown {
padding: 0 20px 20px;
}
.breakdown-card {
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
2026-04-16 09:56:56 -07:00
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.breakdown-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
2026-04-16 10:26:07 -07:00
padding: 16px 18px;
2026-04-16 09:56:56 -07:00
border-bottom: 1px solid var(--border);
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent), var(--surface-strong);
2026-04-16 09:56:56 -07:00
}
.breakdown-title {
font-size: 14px;
font-weight: 700;
color: var(--text-main);
}
.breakdown-sub {
font-size: 12px;
color: var(--text-muted);
margin-top: 3px;
}
.breakdown-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.breakdown-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-main);
font-weight: 600;
cursor: pointer;
user-select: none;
}
.breakdown-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.breakdown-table th,
.breakdown-table td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
.breakdown-table th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 700;
2026-04-16 10:26:07 -07:00
background: var(--table-header);
2026-04-16 09:56:56 -07:00
}
.breakdown-sort-btn {
border: 0;
background: transparent;
padding: 0;
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
color: inherit;
font: inherit;
text-transform: inherit;
letter-spacing: inherit;
font-weight: inherit;
cursor: pointer;
}
.breakdown-sort-indicator {
font-size: 11px;
color: var(--text-muted);
min-width: 10px;
text-align: center;
}
.breakdown-table tr:last-child td {
border-bottom: 0;
}
.breakdown-name {
font-weight: 700;
color: var(--text-main);
word-break: break-word;
}
.breakdown-count {
font-weight: 700;
color: var(--text-main);
white-space: nowrap;
}
.breakdown-empty {
padding: 24px 16px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
2025-12-04 22:02:30 -08:00
/* Findings table */
2026-04-16 10:26:07 -07:00
.table-container {
width: 100%;
overflow-x: auto;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.01), transparent), var(--surface);
}
2025-12-04 22:02:30 -08:00
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th {
text-align: left;
2026-04-16 10:26:07 -07:00
padding: 13px 16px;
2025-12-12 17:21:17 -08:00
background: var(--table-header);
2025-12-04 22:02:30 -08:00
border-bottom: 1px solid var(--border);
color: var(--text-muted);
2026-04-16 10:26:07 -07:00
font-weight: 700;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
position: sticky;
top: 0;
z-index: 1;
2025-12-04 22:02:30 -08:00
}
.table td {
2026-04-16 10:26:07 -07:00
padding: 14px 16px;
2025-12-04 22:02:30 -08:00
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.table tr:last-child td { border-bottom: none; }
2026-04-16 10:26:07 -07:00
.table tr:hover td {
background: linear-gradient(180deg, rgba(0, 237, 100, 0.06), transparent), var(--surface-muted);
cursor: pointer;
}
2025-12-04 22:02:30 -08:00
.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; }
2025-12-12 17:21:17 -08:00
.status-badge.unknown { background: var(--surface-muted); color: var(--text-muted); }
2025-12-04 22:02:30 -08:00
/* Finding detail snippet */
.snippet-box {
2025-12-12 21:51:57 -08:00
position: relative;
background: linear-gradient(90deg, var(--code-accent), transparent), var(--code-bg);
color: var(--code-text);
2025-12-04 22:02:30 -08:00
padding: 16px;
2025-12-12 21:51:57 -08:00
border-radius: 8px;
2025-12-04 22:02:30 -08:00
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
2025-12-17 11:57:35 -08:00
max-width: 100%;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
2025-12-12 17:21:17 -08:00
border: 1px solid var(--code-border);
2025-12-12 21:51:57 -08:00
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);
2025-12-04 22:02:30 -08:00
}
#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;
}
2026-01-31 22:58:53 -08:00
/* 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 */
2026-01-31 22:52:57 -08:00
#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;
}
2026-04-23 14:42:10 -07:00
/* Kingfisher enrichment callout - brand-colored so its origin is unmistakable */
.kf-enrichment-box {
margin-top: 16px;
padding: 14px 16px;
border-radius: 10px;
border: 1px solid var(--brand);
background: var(--brand-soft);
}
.kf-enrichment-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.kf-enrichment-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
background: linear-gradient(180deg, var(--brand), var(--brand-dark));
color: #ffffff;
font-weight: 700;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.kf-enrichment-strength {
font-size: 11px;
color: var(--brand-dark);
}
.kf-enrichment-row {
display: flex;
align-items: center;
gap: 10px;
margin: 6px 0 10px;
}
.kf-enrichment-row > label {
font-size: 12px;
font-weight: 600;
color: var(--brand-dark);
text-transform: uppercase;
letter-spacing: 0.06em;
}
#fd-kf-validation-res {
white-space: pre-wrap;
font-size: 12px;
color: var(--brand-dark);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: rgba(255, 255, 255, 0.55);
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 10px;
}
#fd-kf-validate-wrap label,
#fd-kf-revoke-wrap label {
font-size: 12px;
font-weight: 600;
color: var(--brand-dark);
text-transform: uppercase;
letter-spacing: 0.06em;
display: block;
margin-bottom: 6px;
}
#fd-kf-validate-wrap,
#fd-kf-revoke-wrap {
margin-bottom: 10px;
}
#fd-kf-validate-cmd,
#fd-kf-revoke-cmd {
flex: 1;
font-size: 12px;
color: var(--brand-dark);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: rgba(255, 255, 255, 0.55);
padding: 8px 12px;
border-radius: 4px;
word-break: break-all;
white-space: pre-wrap;
}
.kf-enrichment-footnote {
margin-top: 10px;
font-size: 11px;
color: var(--text-muted);
}
.chip-enriched {
display: inline-block;
background: var(--brand-soft);
color: var(--brand-dark);
border: 1px solid var(--brand);
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-left: 6px;
vertical-align: middle;
}
2026-01-31 22:58:53 -08:00
/* Shared command container styles */
.cmd-container {
display: flex;
align-items: flex-start;
gap: 10px;
}
2026-01-31 22:52:57 -08:00
.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;
}
2025-12-17 11:57:35 -08:00
.path-area {
width: 100%;
min-height: 48px;
2025-12-12 17:21:17 -08:00
background: var(--surface-muted);
2025-12-17 11:57:35 -08:00
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);
2025-12-04 22:02:30 -08:00
}
.link-mono a {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
2025-12-12 21:51:57 -08:00
color: var(--link-strong);
text-decoration: underline;
text-decoration-color: var(--link-underline);
text-decoration-thickness: 2px;
font-weight: 600;
2025-12-04 22:02:30 -08:00
word-break: break-all;
}
.link-mono a:hover {
2025-12-12 21:51:57 -08:00
color: var(--link-strong-hover);
text-decoration-color: currentColor;
2025-12-04 22:02:30 -08:00
}
/* Search & pagination */
.search-input {
2026-04-16 10:26:07 -07:00
padding: 10px 12px;
2025-12-04 22:02:30 -08:00
border: 1px solid var(--border);
2026-04-16 10:26:07 -07:00
border-radius: 10px;
2025-12-04 22:02:30 -08:00
width: 220px;
font-size: 13px;
2025-12-12 17:21:17 -08:00
background: var(--surface);
color: var(--text-main);
2026-04-16 10:26:07 -07:00
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
2025-12-04 22:02:30 -08:00
}
.rows-label {
font-size: 12px;
color: var(--text-muted);
}
.rows-select {
2026-04-16 10:26:07 -07:00
padding: 8px 10px;
border-radius: 10px;
2025-12-04 22:02:30 -08:00
border: 1px solid var(--border);
2025-12-12 17:21:17 -08:00
background: var(--surface);
2025-12-04 22:02:30 -08:00
font-size: 13px;
2025-12-12 17:21:17 -08:00
color: var(--text-main);
2026-04-16 10:26:07 -07:00
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
2025-12-04 22:02:30 -08:00
}
.pager {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.pager-btn {
2026-04-16 10:26:07 -07:00
width: 32px;
height: 32px;
2025-12-04 22:02:30 -08:00
border-radius: 999px;
border: 1px solid var(--border);
2025-12-12 17:21:17 -08:00
background: var(--surface);
2025-12-04 22:02:30 -08:00
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
2025-12-12 17:21:17 -08:00
color: var(--text-main);
2026-04-16 10:26:07 -07:00
transition: background 0.12s ease, border-color 0.12s ease, transform 0.12s ease;
}
.pager-btn:hover:not(:disabled) {
background: var(--hover);
border-color: var(--border-strong);
transform: translateY(-1px);
2025-12-04 22:02:30 -08:00
}
.pager-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* Buttons & loader */
.btn {
2026-04-16 10:26:07 -07:00
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
2025-12-04 22:02:30 -08:00
border: 1px solid var(--border);
2026-04-16 10:26:07 -07:00
padding: 9px 16px;
border-radius: 10px;
font-weight: 600;
2025-12-04 22:02:30 -08:00
cursor: pointer;
color: var(--text-main);
2026-04-16 10:26:07 -07:00
box-shadow: var(--shadow-sm);
transition: background 0.1s, border-color 0.1s, transform 0.1s ease, box-shadow 0.1s ease;
2025-12-04 22:02:30 -08:00
}
.btn:hover {
2025-12-12 17:21:17 -08:00
background: var(--surface-muted);
2026-04-16 10:26:07 -07:00
border-color: var(--border-strong);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
2025-12-04 22:02:30 -08:00
}
2026-01-14 21:45:55 -08:00
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
2025-12-17 11:57:35 -08:00
#theme-toggle {
2026-04-16 10:57:40 -07:00
background: var(--surface-muted);
color: var(--text-main);
border-color: var(--border);
2025-12-17 11:57:35 -08:00
font-weight: 700;
2026-04-16 10:57:40 -07:00
box-shadow: var(--shadow-sm);
2025-12-17 11:57:35 -08:00
}
#theme-toggle:hover {
2026-04-16 10:57:40 -07:00
background: var(--hover);
border-color: var(--border-strong);
color: var(--text-main);
2025-12-17 11:57:35 -08:00
}
:root[data-theme="light"] #theme-toggle {
2026-04-16 10:26:07 -07:00
background: #ffffff;
color: var(--brand-dark);
border-color: var(--border-strong);
2025-12-17 11:57:35 -08:00
}
:root[data-theme="light"] #theme-toggle:hover {
2026-04-16 10:26:07 -07:00
background: var(--surface-strong);
border-color: var(--brand-soft-strong);
}
#reset-btn {
background: linear-gradient(180deg, var(--brand), var(--brand-dark));
border-color: transparent;
color: #04210f;
font-weight: 800;
box-shadow: 0 16px 30px rgba(0, 237, 100, 0.2);
}
#reset-btn:hover {
background: linear-gradient(180deg, #34f08a, var(--brand));
border-color: transparent;
color: #04210f;
2025-12-17 11:57:35 -08:00
}
2025-12-04 22:02:30 -08:00
.hidden { display: none !important; }
2026-04-16 10:26:07 -07:00
.btn:focus-visible,
.nav-button:focus-visible,
.search-input:focus-visible,
.rows-select:focus-visible,
.am-toolbar input:focus-visible,
.pager-btn:focus-visible {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(0, 237, 100, 0.18);
}
2026-02-13 18:19:18 -08:00
/* Blast Radius (finding detail) */
.blast-radius {
margin-top: 20px;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.blast-radius__header {
padding: 12px 16px;
background: var(--surface-strong);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.blast-radius__header h4 {
margin: 0;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.blast-radius__body {
padding: 16px;
}
.risk-rationale {
padding: 12px 16px;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
margin-bottom: 14px;
}
.risk-rationale.critical {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.risk-rationale.high {
background: #fff7ed;
border: 1px solid #fed7aa;
color: #9a3412;
}
.risk-rationale.medium {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
}
.risk-rationale.low {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
}
.risk-rationale.none {
background: var(--surface-muted);
border: 1px solid var(--border);
color: var(--text-muted);
}
:root[data-theme="dark"] .risk-rationale.critical {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
:root[data-theme="dark"] .risk-rationale.high {
background: rgba(249, 115, 22, 0.1);
border-color: rgba(249, 115, 22, 0.3);
color: #fdba74;
}
:root[data-theme="dark"] .risk-rationale.medium {
background: rgba(234, 179, 8, 0.1);
border-color: rgba(234, 179, 8, 0.3);
color: #fde68a;
}
:root[data-theme="dark"] .risk-rationale.low {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
color: #86efac;
}
:root[data-theme="dark"] .risk-rationale.none {
background: var(--surface-muted);
border-color: var(--border);
color: var(--text-muted);
}
.blast-identity {
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-muted);
margin-bottom: 10px;
}
.blast-identity__header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
}
.blast-resource-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.blast-resource-item {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
font-size: 13px;
}
.blast-resource-name {
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
word-break: break-all;
}
.blast-perms {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.blast-perm-tag {
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: #ecfdf5;
color: #16a34a;
border: 1px solid #bbf7d0;
}
:root[data-theme="dark"] .blast-perm-tag {
background: rgba(34, 197, 94, 0.1);
color: #86efac;
border-color: rgba(34, 197, 94, 0.3);
}
/* Scan metadata bar */
.scan-meta-bar {
display: flex;
flex-wrap: wrap;
gap: 16px;
2026-04-16 10:26:07 -07:00
padding: 16px 18px;
background: linear-gradient(180deg, rgba(0, 237, 100, 0.08), transparent), var(--surface-muted);
2026-02-13 18:19:18 -08:00
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
color: var(--text-muted);
2026-04-16 10:26:07 -07:00
box-shadow: var(--shadow-sm);
2026-02-13 18:19:18 -08:00
}
.scan-meta-item {
display: flex;
align-items: center;
gap: 6px;
}
.scan-meta-item strong {
color: var(--text-main);
font-weight: 600;
}
/* Report type selector */
.report-type-group {
display: flex;
flex-direction: column;
2026-04-16 10:26:07 -07:00
gap: 8px;
padding: 12px 13px;
border: 1px solid var(--border);
border-radius: 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent), var(--surface-muted);
margin-top: 0;
box-shadow: none;
}
2026-04-16 10:57:40 -07:00
.workspace-actions .report-type-group {
height: 100%;
justify-content: flex-start;
}
2026-04-16 10:26:07 -07:00
.report-type-group + .report-type-group {
margin-top: 0;
2026-02-13 18:19:18 -08:00
}
.report-type-group label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 600;
}
2026-04-16 10:26:07 -07:00
.sidebar-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar-check {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
text-transform: none !important;
letter-spacing: normal !important;
color: var(--text-main) !important;
cursor: pointer;
font-weight: 500 !important;
}
.sidebar-help {
font-size: 11px;
color: var(--text-muted);
line-height: 1.45;
}
#import-note,
#fd-import-note {
box-shadow: var(--shadow-sm);
border-radius: 14px !important;
}
2025-12-04 22:02:30 -08:00
.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;
2025-12-12 17:21:17 -08:00
border: 3px solid var(--border);
2025-12-04 22:02:30 -08:00
border-top-color: #0e7c56;
animation: spin 0.8s linear infinite;
}
.progress-track {
width: 220px;
height: 6px;
border-radius: 999px;
2025-12-12 17:21:17 -08:00
background: var(--border);
2025-12-04 22:02:30 -08:00
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" >
2025-12-12 17:21:17 -08:00
< 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);" >
2025-12-04 22:02:30 -08:00
< 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;" >
2026-04-16 10:26:07 -07:00
< span class = "hero__title" > Kingfisher Report Viewer< / span >
2025-12-04 22:02:30 -08:00
< span class = "hero__subtitle" > Access Map & Findings< / span >
< / div >
< / div >
2026-04-16 09:56:56 -07:00
< div style = "display:flex; gap:10px; align-items:center; flex-wrap:wrap; justify-content:flex-end;" >
2026-04-22 23:39:19 -07:00
< a class = "btn" href = "https://github.com/mongodb/kingfisher" target = "_blank" rel = "noopener noreferrer" > GitHub< / a >
2025-12-12 17:21:17 -08:00
< button class = "btn" id = "theme-toggle" type = "button" > Light Mode< / button >
2026-04-16 09:56:56 -07:00
< button class = "btn" id = "reset-btn" type = "button" > Clear and Load New Report(s)< / button >
2025-12-05 22:24:16 -08:00
< / div >
2025-12-04 22:02:30 -08:00
< / header >
2026-04-16 10:26:07 -07:00
< div class = "info-banner" > Runs entirely in your browser. Reports stay local and are never uploaded.< / div >
2025-12-16 21:24:47 -08:00
2025-12-04 22:02:30 -08:00
< main class = "page" >
2026-04-16 10:57:40 -07:00
< section id = "upload-section" class = "panel" style = "max-width: 920px; margin: 0 auto;" >
2025-12-04 22:02:30 -08:00
< div class = "panel__header" >
< div class = "panel__title" >
2026-04-23 14:42:10 -07:00
< h3 > Load Report< / h3 >
2026-04-16 06:44:12 -07:00
< p > Analyze Kingfisher JSON / JSONL output, plus Gitleaks and TruffleHog JSON reports< / p >
2025-12-04 22:02:30 -08:00
< / div >
< / div >
2026-04-16 10:57:40 -07:00
< div class = "upload-shell" >
2025-12-04 22:02:30 -08:00
< div class = "upload-area" id = "drop-zone" >
< div class = "upload-icon" > 📄< / div >
2026-04-16 09:56:56 -07:00
< div class = "upload-text" > Load one or more reports< / div >
< div class = "upload-sub" > Drop files anywhere in this card, or click to choose them from disk.< / div >
< div class = "upload-action-row" >
< span class = "upload-primary" > Choose Reports< / span >
< span class = "upload-action-hint" > JSON and JSONL only< / span >
< / div >
2026-04-15 17:13:10 -07:00
< input type = "file" id = "file-input" hidden accept = ".json,.jsonl" multiple >
2026-04-23 14:42:10 -07:00
< div class = "upload-options" style = "margin-top:14px; display:flex; justify-content:center; font-size:12px; color:var(--text-muted);" >
< label id = "dedup-toggle-label" style = "display:inline-flex; align-items:center; gap:6px; cursor:pointer;" >
< input type = "checkbox" id = "dedup-toggle" checked >
< span > Deduplicate findings (remove same-tool fingerprint repeats)< / span >
< / label >
< / div >
2025-12-04 22:02:30 -08:00
< / div >
2026-04-16 10:57:40 -07:00
< div class = "upload-help-grid" >
< div class = "upload-help-card" >
< div class = "upload-help-label" > Formats< / div >
2026-04-23 14:42:10 -07:00
< div class = "upload-help-text" > < strong > Kingfisher< / strong > JSON/JSONL, < strong > Gitleaks< / strong > OSS JSON, and < strong > TruffleHog< / strong > OSS JSON/JSONL.< / div >
2026-04-16 10:57:40 -07:00
< / div >
< div class = "upload-help-card" >
< div class = "upload-help-label" > Merge More< / div >
< div class = "upload-help-text" > Choose multiple files at once, or drag in more report files after loading to merge them into the same view.< / div >
< / div >
< div class = "upload-help-card" >
< div class = "upload-help-label" > Deduplication< / div >
2026-04-23 14:42:10 -07:00
< div class = "upload-help-text" > < strong > Kingfisher< / strong > reports are already deduplicated. For imported < strong > Gitleaks< / strong > and < strong > TruffleHog< / strong > reports, the viewer attempts deduplication (by default) by finding fingerprint: native fingerprints when present, otherwise a synthetic fingerprint built from the tool, detector, and secret identity, with path/line/snippet fallback when needed.< / div >
2026-04-16 10:57:40 -07:00
< / div >
< div class = "upload-help-card" >
< div class = "upload-help-label" > Privacy< / div >
< div class = "upload-help-text" > Everything stays in your browser. Files are not uploaded anywhere.< / div >
< / div >
2026-04-23 14:42:10 -07:00
< div class = "upload-help-card" >
< div class = "upload-help-label" > Attribution< / div >
< div class = "upload-help-text" > Reads JSON output from the open-source TruffleHog and Gitleaks CLIs. Kingfisher is not affiliated with or endorsed by Truffle Security Co. or the Gitleaks project. TruffleHog and Gitleaks are trademarks of their respective owners.< / div >
< / div >
2026-04-16 10:57:40 -07:00
< / div >
2025-12-04 22:02:30 -08:00
< 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 >
2025-12-17 11:57:35 -08:00
< div id = "dashboard" class = "hidden" >
< div class = "layout" >
< aside class = "sidebar" >
2026-04-16 10:26:07 -07:00
< div class = "sidebar-nav" >
< div class = "sidebar-section-label" > Navigate< / div >
< div class = "nav-group" >
2026-04-16 10:57:40 -07:00
< button class = "nav-button active" data-view-target = "view-dashboard" title = "Dashboard" >
< span class = "nav-icon" aria-hidden = "true" >
< svg viewBox = "0 0 24 24" >
< path d = "M4 19h16" > < / path >
< path d = "M7 16V10" > < / path >
< path d = "M12 16V6" > < / path >
< path d = "M17 16v-4" > < / path >
< / svg >
< / span >
2026-04-16 10:26:07 -07:00
< span class = "nav-button__content" >
< span class = "nav-button__title" > Dashboard< / span >
< span class = "nav-button__sub" > Overview and report mix< / span >
< / span >
< / button >
2026-04-16 10:57:40 -07:00
< button class = "nav-button" data-view-target = "view-access" title = "Access Map" >
< span class = "nav-icon" aria-hidden = "true" >
< svg viewBox = "0 0 24 24" >
< circle cx = "12" cy = "12" r = "8" > < / circle >
< path d = "M4 12h16" > < / path >
< path d = "M12 4a12 12 0 0 1 0 16" > < / path >
< path d = "M12 4a12 12 0 0 0 0 16" > < / path >
< / svg >
< / span >
2026-04-16 10:26:07 -07:00
< span class = "nav-button__content" >
< span class = "nav-button__title" > Access Map< / span >
< span class = "nav-button__sub" > Identity reach and permissions< / span >
< / span >
< / button >
2026-04-16 10:57:40 -07:00
< button class = "nav-button" data-view-target = "view-findings" title = "Findings" >
< span class = "nav-icon" aria-hidden = "true" >
< svg viewBox = "0 0 24 24" >
< path d = "M8 3h6l4 4v14H8z" > < / path >
< path d = "M14 3v5h4" > < / path >
< path d = "M10 12h6" > < / path >
< path d = "M10 16h6" > < / path >
< / svg >
< / span >
2026-04-16 10:26:07 -07:00
< span class = "nav-button__content" >
< span class = "nav-button__title" > Findings< / span >
< span class = "nav-button__sub" > Browse detections and details< / span >
< / span >
< / button >
< / div >
2026-02-13 18:19:18 -08:00
< / div >
2026-04-16 10:57:40 -07:00
< / aside >
2026-04-16 10:26:07 -07:00
2026-04-16 10:57:40 -07:00
< div class = "view-stack" >
< section class = "workspace-topbar" >
< div class = "workspace-overview" >
< div class = "workspace-overview__eyebrow" > Report overview< / div >
< div class = "workspace-overview__header" >
< div >
< div class = "workspace-overview__title" id = "sidebar-summary-source" > No report loaded< / div >
< div class = "workspace-overview__meta" id = "sidebar-summary-target" > Load a report to explore findings and blast radius.< / div >
< / div >
< div class = "workspace-pill" > Access map: < span id = "sidebar-summary-access" > No< / span > < / div >
< / div >
< div class = "workspace-overview__stats" >
< div class = "workspace-overview__stat" >
< div class = "workspace-overview__value" id = "sidebar-summary-total" > 0< / div >
< div class = "workspace-overview__label" > Findings< / div >
< / div >
< div class = "workspace-overview__stat" >
< div class = "workspace-overview__value" id = "sidebar-summary-active" > 0< / div >
< div class = "workspace-overview__label" > Active< / div >
< / div >
< div class = "workspace-overview__stat" >
< div class = "workspace-overview__value" id = "sidebar-summary-identities" > 0< / div >
< div class = "workspace-overview__label" > Identities< / div >
< / div >
< / div >
2026-04-16 10:26:07 -07:00
< / div >
2026-04-16 10:57:40 -07:00
< div class = "workspace-actions" >
< div class = "report-type-group" >
< label > For Researchers< / label >
< button class = "btn" id = "download-risk-report" type = "button" style = "width:100%;" > Risk Report< / button >
< label class = "sidebar-check" >
< input type = "checkbox" id = "risk-active-only" > Active credentials only
< / label >
< div class = "sidebar-help" > Findings + blast radius per credential. Suitable for bug bounty submissions.< / div >
< / div >
< div class = "report-type-group" >
< label > For Defenders< / label >
< button class = "btn" id = "download-scan-report" type = "button" style = "width:100%;" > Scan Report< / button >
< div style = "display:flex; flex-direction:column; gap:6px; margin-top:4px;" >
< select id = "report-scope" class = "rows-select" >
< option value = "all" selected > All findings< / option >
< option value = "filtered" > Filtered findings< / option >
< / select >
< / div >
< label class = "sidebar-check" >
< input type = "checkbox" id = "scan-active-only" > Active credentials only
< / label >
< div class = "sidebar-help" > Scan summary for tickets and remediation tracking.< / div >
< button class = "btn" id = "download-access-report" type = "button" style = "width:100%; margin-top:4px;" disabled > Access Map Report< / button >
2026-04-16 10:26:07 -07:00
< / div >
2026-01-14 21:45:55 -08:00
< / div >
2026-04-16 10:57:40 -07:00
< / section >
2025-12-17 11:57:35 -08:00
< section id = "view-dashboard" class = "view" >
2026-02-13 18:19:18 -08:00
< div id = "scan-meta-section" class = "scan-meta-bar hidden" >
< div class = "scan-meta-item" > < strong > Report Generated:< / strong > < span id = "meta-timestamp" > —< / span > < / div >
< div class = "scan-meta-item" id = "meta-target-wrap" style = "display:none;" > < strong > Target:< / strong > < span id = "meta-target" > —< / span > < / div >
< div class = "scan-meta-item" id = "meta-duration-wrap" style = "display:none;" > < strong > Duration:< / strong > < span id = "meta-duration" > —< / span > < / div >
2026-04-16 06:44:12 -07:00
< div class = "scan-meta-item" id = "meta-source-wrap" style = "display:none;" > < strong > Source:< / strong > < span id = "meta-source" > —< / span > < / div >
2026-02-13 18:19:18 -08:00
< div class = "scan-meta-item" id = "meta-version-wrap" style = "display:none;" > < strong > Version:< / strong > < span id = "meta-version" > —< / span > < / div >
< / div >
2026-04-16 06:44:12 -07:00
< div id = "import-note" class = "hidden" style = "margin-bottom:18px; padding:14px 16px; border-radius:10px; border:1px solid #fcd34d; background:#fffbeb; color:#92400e; font-size:13px; line-height:1.6;" > < / div >
2025-12-17 11:57:35 -08:00
< 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 >
2026-04-23 14:42:10 -07:00
< div class = "metric hidden" id = "stat-enriched-card" title = "Imported findings that match a Kingfisher finding on commit + file + line" >
< div class = "metric__label" > Enriched by Kingfisher< / div >
< div class = "metric__value" id = "stat-enriched" > 0< / div >
< / div >
< / div >
< / section >
< section class = "panel hidden" id = "dedup-panel" >
< div class = "panel__header" >
< div class = "panel__title" >
< h3 > Duplicates Removed by Tool< / h3 >
< p id = "dedup-info" > Same-tool fingerprint matches removed by the viewer. Uncheck “ Deduplicate findings” on upload to keep every emitted row.< / p >
< / div >
< / div >
< div class = "metrics-grid" >
< div class = "metric" id = "dedup-card-kingfisher" >
< div class = "metric__label" > Kingfisher duplicates removed< / div >
< div class = "metric__value" id = "dedup-count-kingfisher" > 0< / div >
< / div >
< div class = "metric" id = "dedup-card-trufflehog" >
< div class = "metric__label" > TruffleHog duplicates removed< / div >
< div class = "metric__value" id = "dedup-count-trufflehog" > 0< / div >
< / div >
< div class = "metric" id = "dedup-card-gitleaks" >
< div class = "metric__label" > Gitleaks duplicates removed< / div >
< div class = "metric__value" id = "dedup-count-gitleaks" > 0< / div >
< / div >
2025-12-17 11:57:35 -08:00
< / div >
< / section >
2025-12-04 22:02:30 -08:00
2025-12-17 11:57:35 -08:00
< section class = "panel" >
< div class = "panel__header" >
< div class = "panel__title" >
< h3 > Finding Status Distribution< / h3 >
2026-04-16 09:56:56 -07:00
< p > Validation state and report source mix< / p >
2025-12-17 11:57:35 -08:00
< / div >
2025-12-04 22:02:30 -08:00
< / div >
2025-12-17 11:57:35 -08:00
< 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 >
2026-04-16 09:56:56 -07:00
< div class = "chart-card" >
< canvas id = "source-chart" width = "260" height = "220" > < / canvas >
< div class = "chart-legend" id = "source-legend" >
<!-- filled by JS -->
< / div >
< / div >
< / div >
< / section >
< section class = "panel" >
< div class = "panel__header" >
< div class = "panel__title" >
< h3 > Detector Families< / h3 >
< p > Collapsed Kingfisher rule families plus imported detector names< / p >
< / div >
< / div >
< div class = "dashboard-breakdown" style = "margin-bottom: 18px;" >
< div class = "breakdown-card" >
< div class = "breakdown-header" >
< div >
< div class = "breakdown-title" > Detector distribution by source< / div >
< div class = "breakdown-sub" > Top detector families for each loaded tool, grouped into a chart per source.< / div >
< / div >
< / div >
< div class = "chart-grid" id = "source-detector-charts" >
<!-- filled by JS -->
< / div >
< / div >
< / div >
< div class = "dashboard-breakdown" >
< div class = "breakdown-card" >
< div class = "breakdown-header" >
< div >
< div class = "breakdown-title" > Most frequent findings< / div >
< div class = "breakdown-sub" > Use this to spot the noisiest rule families and imported detectors quickly.< / div >
< / div >
< div class = "breakdown-controls" >
< div class = "pager" >
< button id = "detector-breakdown-prev" class = "pager-btn" type = "button" > ‹ < / button >
< span id = "detector-breakdown-page-info" > 0 of 0< / span >
< button id = "detector-breakdown-next" class = "pager-btn" type = "button" > › < / button >
< / div >
< label class = "breakdown-toggle" for = "detector-breakdown-active-only" >
< input id = "detector-breakdown-active-only" type = "checkbox" >
< span > Active only< / span >
< / label >
< / div >
< / div >
< div id = "detector-breakdown" > < / div >
< / div >
2025-12-17 11:57:35 -08:00
< / 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 >
2026-02-13 18:35:36 -08:00
< p > Credentials and what they can reach< / p >
2025-12-17 11:57:35 -08:00
< / div >
< div style = "display:flex; gap:10px; align-items:center;" >
2026-02-13 18:35:36 -08:00
< button class = "btn" id = "copy-access-map" type = "button" > Copy JSON< / button >
2025-12-17 11:57:35 -08:00
< / div >
< / div >
2026-02-13 18:35:36 -08:00
< div id = "am-empty-notice" class = "am-empty-hint hidden" > No Access Map entries in this report. Run a scan with < code > --access-map< / code > to map credential blast radius.< / div >
2025-12-17 11:57:35 -08:00
< div class = "am-container" id = "am-container" >
2026-02-13 18:35:36 -08:00
< div id = "am-stats-bar" class = "am-stats hidden" > < / div >
2026-04-30 18:11:10 -07:00
< div class = "am-toolbar" style = "display:flex; gap:10px; align-items:center;" >
< input id = "tree-search" type = "text" placeholder = "Filter by identity, resource, or permission…" style = "flex:1;" >
< label id = "am-critical-toggle" class = "am-toolbar__toggle" title = "Hide read-only permissions and identities with no admin / privilege-escalation / risky permissions." >
< input type = "checkbox" id = "am-critical-checkbox" >
< span > Critical only< / span >
< / label >
2025-12-17 11:57:35 -08:00
< / div >
2026-02-13 18:35:36 -08:00
< div id = "am-card-list" class = "am-card-list" >
< div style = "color:var(--text-muted); font-size:13px; text-align:center; padding:32px 0;" >
No access map data found in report.
2025-12-04 22:02:30 -08:00
< / div >
< / div >
< / div >
2025-12-17 11:57:35 -08:00
< / 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 >
2025-12-04 22:02:30 -08:00
< / div >
2025-12-17 11:57:35 -08:00
< 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" >
2026-04-16 09:56:56 -07:00
< select id = "source-filter" class = "rows-select" >
< option value = "all" selected > All sources< / option >
< option value = "kingfisher" > Kingfisher< / option >
< option value = "gitleaks" > Gitleaks< / option >
< option value = "trufflehog" > TruffleHog< / option >
< / select >
2025-12-17 11:57:35 -08:00
< 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 >
2026-04-15 17:13:10 -07:00
< option value = "canary" > Canary Token (Skipped)< / option >
2025-12-17 11:57:35 -08:00
< / 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 >
2025-12-04 22:02:30 -08:00
< / div >
< / div >
2025-12-17 11:57:35 -08:00
< div class = "table-container" >
< table class = "table" >
< thead >
< tr >
< th class = "sortable" data-field = "rule" >
Rule < span class = "sort-indicator" > < / span >
< / th >
2026-04-23 14:42:10 -07:00
< th class = "sortable" data-field = "source" >
Source < span class = "sort-indicator" > < / span >
< / th >
2025-12-17 11:57:35 -08:00
< 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 >
2025-12-04 22:02:30 -08:00
< / div >
2025-12-17 11:57:35 -08:00
< / section >
2025-12-04 22:02:30 -08:00
2025-12-17 11:57:35 -08:00
< 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 >
2026-01-14 21:45:55 -08:00
< div class = "detail-field" >
< label > Validation Status< / label >
< div id = "fd-validation-status" > < / div >
< / div >
2026-04-16 06:44:12 -07:00
< div class = "detail-field" >
< label > Report Source< / label >
< div id = "fd-source" > < / div >
< / div >
2025-12-17 11:57:35 -08:00
< div class = "detail-field" >
< label > Git Commit< / label >
< div id = "fd-commit" > < / div >
< / div >
2026-01-15 10:41:55 -08:00
< div class = "detail-field" id = "fd-committer-email-wrapper" >
< label > Committer Email< / label >
< div id = "fd-committer-email" > < / div >
< / div >
2025-12-17 11:57:35 -08:00
< 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 >
2025-12-04 22:02:30 -08:00
2025-12-17 11:57:35 -08:00
< 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 >
2025-12-04 22:02:30 -08:00
2026-04-16 06:44:12 -07:00
< div id = "fd-import-note" class = "hidden" style = "margin:14px 0 0; padding:12px 14px; border-radius:8px; border:1px solid #fcd34d; background:#fffbeb; color:#92400e; font-size:12px; line-height:1.6;" > < / div >
2026-04-23 14:42:10 -07:00
< div id = "fd-kf-enrichment" class = "hidden kf-enrichment-box" >
< div class = "kf-enrichment-header" >
< span class = "kf-enrichment-badge" > Enriched by Kingfisher< / span >
< span id = "fd-kf-enrichment-strength" class = "kf-enrichment-strength" > < / span >
< / div >
< div class = "kf-enrichment-row" >
< label > Kingfisher Validation< / label >
< span id = "fd-kf-validation" > < / span >
< / div >
< pre id = "fd-kf-validation-res" class = "hidden" > < / pre >
< div id = "fd-kf-validate-wrap" class = "hidden" >
< label > Validate with Kingfisher< / label >
< div class = "cmd-container" >
< code id = "fd-kf-validate-cmd" > < / code >
< button class = "copy-btn" id = "fd-kf-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-kf-revoke-wrap" class = "hidden" >
< label > Revoke< / label >
< div class = "cmd-container" >
< code id = "fd-kf-revoke-cmd" > < / code >
< button class = "copy-btn" id = "fd-kf-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 class = "kf-enrichment-footnote" >
Matched on < span id = "fd-kf-match-key" > < / span > · Kingfisher rule < span id = "fd-kf-rule" > < / span >
< / div >
< / div >
2025-12-17 11:57:35 -08:00
< div id = "fd-validation-box" class = "hidden" >
< label > Live Validation Response< / label >
< pre id = "fd-validation-res" > < / pre >
< / div >
2026-01-31 22:52:57 -08:00
2026-01-31 22:58:53 -08:00
< 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 >
2026-01-31 22:52:57 -08:00
< div id = "fd-revoke-box" class = "hidden" >
< label > Revoke Command< / label >
2026-01-31 22:58:53 -08:00
< div class = "cmd-container" >
2026-01-31 22:52:57 -08:00
< 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 >
2026-02-13 18:19:18 -08:00
< div id = "fd-blast-radius" class = "blast-radius hidden" >
< div class = "blast-radius__header" >
< h4 >
< span > Blast Radius< / span >
< span id = "fd-blast-count" class = "badge" style = "font-size:11px;" > < / span >
< / h4 >
< button class = "btn" id = "fd-export-risk-report" type = "button" style = "font-size:12px; padding:6px 12px;" >
Export Risk Report
< / button >
< / div >
< div class = "blast-radius__body" >
< div id = "fd-risk-rationale" class = "risk-rationale none" > < / div >
< div id = "fd-blast-entries" > < / div >
< / div >
< / div >
2025-12-17 11:57:35 -08:00
< / div >
< / section >
< / section >
2025-12-04 22:02:30 -08:00
< / div >
2025-12-17 11:57:35 -08:00
< / div >
2025-12-04 22:02:30 -08:00
< / div >
< / main >
< script >
let rawData = null;
let findings = [];
let accessMap = [];
2026-04-23 14:42:10 -07:00
let duplicatesRemoved = { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 };
2025-12-05 22:24:16 -08:00
let filteredAccessMapView = [];
2025-12-04 22:02:30 -08:00
let currentFilter = "";
let validationFilter = "all";
2026-04-16 09:56:56 -07:00
let sourceFilter = "all";
2025-12-04 22:02:30 -08:00
let pageSize = 10;
let currentPage = 1;
let sortField = "rule";
let sortDirection = "asc";
2026-04-16 09:56:56 -07:00
let detectorBreakdownSortField = "count";
let detectorBreakdownSortDirection = "desc";
let detectorBreakdownActiveOnly = false;
let detectorBreakdownPage = 1;
2025-12-17 11:57:35 -08:00
let autoCollapsedAccessMap = false;
2026-02-13 18:19:18 -08:00
let currentDetailFinding = null;
let scanMetadata = {};
2025-12-04 22:02:30 -08:00
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");
2026-02-13 18:19:18 -08:00
const downloadRiskReportBtn = document.getElementById("download-risk-report");
const downloadScanReportBtn = document.getElementById("download-scan-report");
2026-01-14 21:45:55 -08:00
const reportScopeSelect = document.getElementById("report-scope");
const downloadAccessReportBtn = document.getElementById("download-access-report");
2026-02-13 18:19:18 -08:00
const exportFindingRiskBtn = document.getElementById("fd-export-risk-report");
2026-04-16 09:56:56 -07:00
const sourceDetectorCharts = document.getElementById("source-detector-charts");
const detectorBreakdownActiveOnlyToggle = document.getElementById("detector-breakdown-active-only");
const detectorBreakdownPrev = document.getElementById("detector-breakdown-prev");
const detectorBreakdownNext = document.getElementById("detector-breakdown-next");
const detectorBreakdownPageInfo = document.getElementById("detector-breakdown-page-info");
2025-12-04 22:02:30 -08:00
const searchInput = document.getElementById("search-input");
2026-04-16 09:56:56 -07:00
const sourceSelect = document.getElementById("source-filter");
2025-12-04 22:02:30 -08:00
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");
2025-12-05 22:24:16 -08:00
const downloadJsonBtn = document.getElementById("download-json");
const downloadCsvBtn = document.getElementById("download-csv");
2025-12-04 22:02:30 -08:00
const treeSearch = document.getElementById("tree-search");
const amContainer = document.getElementById("am-container");
2026-02-13 18:35:36 -08:00
const amToggle = document.getElementById("am-toggle"); // may be null now
2025-12-05 22:24:16 -08:00
const copyAccessMapButton = document.getElementById("copy-access-map");
2025-12-17 11:57:35 -08:00
const amEmptyNotice = document.getElementById("am-empty-notice");
2025-12-12 17:21:17 -08:00
const themeToggle = document.getElementById("theme-toggle");
2025-12-17 11:57:35 -08:00
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");
2026-04-16 09:56:56 -07:00
const sourceChartCanvas = document.getElementById("source-chart");
const sourceLegend = document.getElementById("source-legend");
const detectorBreakdown = document.getElementById("detector-breakdown");
2025-12-17 11:57:35 -08:00
const viewRegistry = {
"view-dashboard": document.getElementById("view-dashboard"),
"view-access": document.getElementById("view-access"),
"view-findings": document.getElementById("view-findings"),
};
2025-12-12 17:21:17 -08:00
2026-04-23 14:42:10 -07:00
const THEME_KEY = "viewer-theme";
2026-04-20 17:54:51 -07:00
const systemThemeQuery = window.matchMedia("(prefers-color-scheme: light)");
function getStoredThemePreference() {
const stored = localStorage.getItem(THEME_KEY);
return stored === "light" || stored === "dark" ? stored : null;
}
function getPreferredTheme() {
const stored = getStoredThemePreference();
if (stored) return stored;
return systemThemeQuery.matches ? "light" : "dark";
}
2025-12-12 17:21:17 -08:00
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";
}
2026-04-20 17:54:51 -07:00
return normalized;
2025-12-12 17:21:17 -08:00
}
2026-04-20 17:54:51 -07:00
function rerenderThemeSensitiveViews() {
if (!hasLoadedReportData()) return;
const validationCounts = calculateValidationCounts();
renderStatusChart(validationCounts);
renderSourceChart(calculateSourceCounts());
renderSourceDetectorCharts();
}
setTheme(getPreferredTheme());
systemThemeQuery.addEventListener("change", () => {
if (getStoredThemePreference()) return;
setTheme(getPreferredTheme());
rerenderThemeSensitiveViews();
});
2025-12-04 22:02:30 -08:00
2026-04-23 14:42:10 -07:00
// Dedup toggle: checked means "deduplicate findings" (default ON).
// localStorage stores "0" only when the user has explicitly turned dedup off.
const DEDUP_PREF_KEY = "viewer-dedup";
const dedupToggle = document.getElementById("dedup-toggle");
const dedupToggleLabel = document.getElementById("dedup-toggle-label");
if (dedupToggle) {
try {
if (localStorage.getItem(DEDUP_PREF_KEY) === "0") {
dedupToggle.checked = false;
}
} catch (e) { /* localStorage unavailable; ignore */ }
dedupToggle.addEventListener("change", () => {
try {
if (dedupToggle.checked) localStorage.removeItem(DEDUP_PREF_KEY);
else localStorage.setItem(DEDUP_PREF_KEY, "0");
} catch (e) { /* ignore */ }
});
// Prevent clicks on the toggle from bubbling to the drop-zone file picker.
const stopBubble = (e) => e.stopPropagation();
dedupToggle.addEventListener("click", stopBubble);
if (dedupToggleLabel) dedupToggleLabel.addEventListener("click", stopBubble);
}
2026-04-16 09:56:56 -07:00
function hasLoadedReportData() {
return findings.length > 0 || accessMap.length > 0 || rawData !== null;
}
function openReplaceFilePicker() {
fileInput.value = "";
fileInput.click();
}
dropZone.addEventListener("click", () => openReplaceFilePicker());
2025-12-04 22:02:30 -08:00
fileInput.addEventListener("change", (e) => {
2026-04-16 09:56:56 -07:00
if (e.target.files.length) {
processFiles(Array.from(e.target.files), { append: false });
}
2025-12-04 22:02:30 -08:00
});
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");
2026-04-16 09:56:56 -07:00
if (e.dataTransfer.files.length) {
processFiles(Array.from(e.dataTransfer.files), { append: hasLoadedReportData() });
}
2025-12-04 22:02:30 -08:00
});
2025-12-17 11:57:35 -08:00
navButtons.forEach((btn) => {
btn.addEventListener("click", () => setActiveView(btn.dataset.viewTarget));
});
2025-12-05 22:24:16 -08:00
const resetButton = document.getElementById("reset-btn");
resetButton.addEventListener("click", () => {
const confirmReset = confirm(
2026-04-16 09:56:56 -07:00
"Clearing and loading a new report will discard the currently loaded data from the viewer (your files stay on disk). Continue?",
2025-12-05 22:24:16 -08:00
);
if (confirmReset) {
resetViewer(true);
}
});
2025-12-04 22:02:30 -08:00
searchInput.addEventListener("input", (e) => {
currentFilter = e.target.value || "";
currentPage = 1;
renderFindingsTable();
});
validationSelect.addEventListener("change", (e) => {
validationFilter = e.target.value || "all";
currentPage = 1;
renderFindingsTable();
});
2026-04-16 09:56:56 -07:00
sourceSelect.addEventListener("change", (e) => {
sourceFilter = e.target.value || "all";
currentPage = 1;
renderFindingsTable();
});
if (detectorBreakdownActiveOnlyToggle) {
detectorBreakdownActiveOnlyToggle.addEventListener("change", (e) => {
detectorBreakdownActiveOnly = Boolean(e.target.checked);
detectorBreakdownPage = 1;
renderDetectorBreakdown();
});
}
if (detectorBreakdownPrev) {
detectorBreakdownPrev.addEventListener("click", () => {
if (detectorBreakdownPage > 1) {
detectorBreakdownPage--;
renderDetectorBreakdown();
}
});
}
if (detectorBreakdownNext) {
detectorBreakdownNext.addEventListener("click", () => {
detectorBreakdownPage++;
renderDetectorBreakdown();
});
}
2025-12-04 22:02:30 -08:00
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 || "");
});
2026-04-30 18:11:10 -07:00
const criticalCheckbox = document.getElementById("am-critical-checkbox");
const criticalToggleWrap = document.getElementById("am-critical-toggle");
if (criticalCheckbox) {
criticalCheckbox.checked = isCriticalOnly();
if (criticalToggleWrap) {
criticalToggleWrap.classList.toggle("active", criticalCheckbox.checked);
}
criticalCheckbox.addEventListener("change", () => {
setCriticalOnly(criticalCheckbox.checked);
if (criticalToggleWrap) {
criticalToggleWrap.classList.toggle("active", criticalCheckbox.checked);
}
renderAccessMapTree(treeSearch.value || "");
});
}
2026-02-13 18:35:36 -08:00
if (amToggle) {
amToggle.addEventListener("click", () => {
const willCollapse = !amContainer.classList.contains("hidden");
setAccessMapCollapsed(willCollapse);
});
}
2025-12-04 22:02:30 -08:00
2025-12-05 22:24:16 -08:00
downloadJsonBtn.addEventListener("click", downloadFindingsJson);
downloadCsvBtn.addEventListener("click", downloadFindingsCsv);
copyAccessMapButton.addEventListener("click", copyFilteredAccessMap);
2026-02-13 18:19:18 -08:00
if (downloadRiskReportBtn) {
downloadRiskReportBtn.addEventListener("click", () => {
const activeOnly = document.getElementById("risk-active-only");
generateRiskReport({ activeOnly: activeOnly & & activeOnly.checked });
});
}
if (downloadScanReportBtn) {
downloadScanReportBtn.addEventListener("click", () => {
2026-01-14 21:45:55 -08:00
const scope = reportScopeSelect ? reportScopeSelect.value : "all";
2026-02-13 18:19:18 -08:00
const activeOnly = document.getElementById("scan-active-only");
generateScanReport(scope, { activeOnly: activeOnly & & activeOnly.checked });
2026-01-14 21:45:55 -08:00
});
}
if (downloadAccessReportBtn) {
downloadAccessReportBtn.addEventListener("click", generateAccessMapReport);
2025-12-17 11:57:35 -08:00
}
2026-02-13 18:19:18 -08:00
if (exportFindingRiskBtn) {
exportFindingRiskBtn.addEventListener("click", () => {
if (currentDetailFinding) {
exportSingleFindingRiskReport(currentDetailFinding);
}
});
}
2025-12-12 17:21:17 -08:00
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);
2026-04-20 17:54:51 -07:00
rerenderThemeSensitiveViews();
2025-12-12 17:21:17 -08:00
});
}
2025-12-05 22:24:16 -08:00
loadCliReport();
2025-12-04 22:02:30 -08:00
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();
});
});
2025-12-17 11:57:35 -08:00
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");
2026-02-15 14:29:42 -08:00
if (amToggle) amToggle.textContent = "Expand";
2025-12-17 11:57:35 -08:00
autoCollapsedAccessMap = auto;
} else {
amContainer.classList.remove("hidden");
2026-02-15 14:29:42 -08:00
if (amToggle) amToggle.textContent = "Collapse";
2025-12-17 11:57:35 -08:00
autoCollapsedAccessMap = false;
}
}
function syncAccessMapUi(hasAccess) {
const previouslyAutoCollapsed = autoCollapsedAccessMap;
if (amToggle) amToggle.disabled = !hasAccess;
2026-01-14 21:45:55 -08:00
if (downloadAccessReportBtn) downloadAccessReportBtn.disabled = !hasAccess;
2025-12-17 11:57:35 -08:00
if (amEmptyNotice) {
2026-04-16 06:44:12 -07:00
amEmptyNotice.innerHTML = getAccessMapEmptyMessage();
2025-12-17 11:57:35 -08:00
amEmptyNotice.classList.toggle("hidden", hasAccess);
}
if (!hasAccess) {
setAccessMapCollapsed(true, { auto: true });
return;
}
if (previouslyAutoCollapsed) {
setAccessMapCollapsed(false);
}
}
2025-12-04 22:02:30 -08:00
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();
2026-04-22 23:39:19 -07:00
const parts = trimmed.split(/\r?\n(?=\s*[{\[])/g).filter(Boolean);
2025-12-04 22:02:30 -08:00
if (parts.length > 1) {
try {
return JSON.parse("[" + parts.join(",") + "]");
} catch (e2) {}
}
}
return null;
}
2026-01-01 22:24:57 -08:00
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 };
}
2026-04-16 06:44:12 -07:00
function firstNonEmpty() {
for (let i = 0; i < arguments.length ; i + + ) {
const value = arguments[i];
if (value === undefined || value === null) continue;
if (typeof value === "string" & & value.trim() === "") continue;
return value;
}
return "";
}
function toArray(value) {
if (Array.isArray(value)) return value;
if (value === undefined || value === null || value === "") return [];
return [value];
}
function toNumberOrNull(value) {
if (value === undefined || value === null || value === "") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function slugifyIdentifier(value, fallback = "unknown") {
const slug = String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || fallback;
}
function stableHash53(input) {
const text = String(input || "");
let h1 = 0xdeadbeef ^ text.length;
let h2 = 0x41c6ce57 ^ text.length;
for (let i = 0; i < text.length ; i + + ) {
const ch = text.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return String(4294967296 * (2097151 & h2) + (h1 >>> 0));
}
function buildSyntheticFingerprint(sourceTool, parts) {
return stableHash53([sourceTool].concat(parts || []).map((part) => String(part || "")).join("\u241f"));
}
function findDeepValue(input, candidateKeys, depth = 0) {
if (depth > 6 || input === undefined || input === null) return undefined;
const keySet = new Set((candidateKeys || []).map((key) => String(key).toLowerCase()));
if (Array.isArray(input)) {
for (const item of input) {
const found = findDeepValue(item, candidateKeys, depth + 1);
if (found !== undefined & & found !== null & & found !== "") return found;
}
return undefined;
}
if (typeof input !== "object") return undefined;
for (const [key, value] of Object.entries(input)) {
if (keySet.has(String(key).toLowerCase()) & & value !== undefined & & value !== null & & value !== "") {
return value;
}
}
for (const value of Object.values(input)) {
const found = findDeepValue(value, candidateKeys, depth + 1);
if (found !== undefined & & found !== null & & found !== "") return found;
}
return undefined;
}
function isHttpUrl(value) {
return typeof value === "string" & & /^https?:\/\//i.test(value);
}
function buildViewerImportMetadata(toolName, options = {}) {
return {
imported: Boolean(options.imported),
tools: Array.isArray(options.tools) & & options.tools.length ? options.tools : [toolName],
access_map_supported: options.accessMapSupported !== false,
validate_command_supported: options.validateCommandSupported !== false,
revoke_command_supported: options.revokeCommandSupported !== false,
notes: Array.isArray(options.notes) ? options.notes.slice() : [],
};
}
function mergeViewerImportMetadata(baseMeta, incomingMeta) {
const base = baseMeta & & typeof baseMeta === "object" ? baseMeta : buildViewerImportMetadata("Kingfisher", {});
const incoming = incomingMeta & & typeof incomingMeta === "object" ? incomingMeta : buildViewerImportMetadata("Kingfisher", {});
const mergedTools = [...new Set(toArray(base.tools).concat(toArray(incoming.tools)).map((tool) => String(tool)).filter(Boolean))];
const mergedNotes = [...new Set(toArray(base.notes).concat(toArray(incoming.notes)).map((note) => String(note)).filter(Boolean))];
return {
imported: Boolean(base.imported || incoming.imported),
tools: mergedTools.length ? mergedTools : ["Kingfisher"],
access_map_supported: Boolean(base.access_map_supported & & incoming.access_map_supported),
validate_command_supported: Boolean(base.validate_command_supported & & incoming.validate_command_supported),
revoke_command_supported: Boolean(base.revoke_command_supported & & incoming.revoke_command_supported),
notes: mergedNotes,
};
}
function detectReportFormat(payload) {
const stack = Array.isArray(payload) ? payload.slice() : [payload];
while (stack.length > 0) {
const item = stack.shift();
if (!item || typeof item !== "object") continue;
if (Array.isArray(item.results)) {
stack.unshift(...item.results);
continue;
}
if (Array.isArray(item.findings) || Array.isArray(item.access_map) || (item.rule & & item.finding) || (item.provider & & item.account)) {
return "kingfisher";
}
if (
Object.prototype.hasOwnProperty.call(item, "DetectorName") ||
Object.prototype.hasOwnProperty.call(item, "Verified") ||
Object.prototype.hasOwnProperty.call(item, "Raw") ||
Object.prototype.hasOwnProperty.call(item, "RawV2") ||
Object.prototype.hasOwnProperty.call(item, "Redacted")
) {
return "trufflehog";
}
if (
Object.prototype.hasOwnProperty.call(item, "RuleID") ||
Object.prototype.hasOwnProperty.call(item, "File") ||
Object.prototype.hasOwnProperty.call(item, "StartLine") ||
Object.prototype.hasOwnProperty.call(item, "Secret") ||
Object.prototype.hasOwnProperty.call(item, "Match")
) {
return "gitleaks";
}
}
return "kingfisher";
}
function unwrapImportedEntries(payload, format) {
if (Array.isArray(payload)) return payload;
if (payload & & typeof payload === "object") {
if (format === "trufflehog" & & Array.isArray(payload.results)) return payload.results;
if (format === "gitleaks" & & Array.isArray(payload.results)) return payload.results;
}
return payload ? [payload] : [];
}
2026-04-24 00:14:56 -07:00
function normalizeRepositoryWebUrl(repoUrl) {
const raw = String(repoUrl || "").trim();
if (!raw || /^file:\/\//i.test(raw)) return "";
if (/^https?:\/\//i.test(raw)) return raw.replace(/\.git$/i, "").replace(/\/+$/g, "");
const sshMatch = raw.match(/^git@([^:]+):(.+)$/i);
if (sshMatch) {
return `https://${sshMatch[1]}/${sshMatch[2].replace(/\.git$/i, "").replace(/^\/+/, "")}`.replace(/\/+$/g, "");
}
const sshUrlMatch = raw.match(/^ssh:\/\/git@([^/]+)\/(.+)$/i);
if (sshUrlMatch) {
return `https://${sshUrlMatch[1]}/${sshUrlMatch[2].replace(/\.git$/i, "").replace(/^\/+/, "")}`.replace(/\/+$/g, "");
}
return "";
}
function buildGitFileUrl(repoUrl, commitId, filePath, line) {
const base = normalizeRepositoryWebUrl(repoUrl);
const commit = String(commitId || "").trim();
const normalizedPath = String(filePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
const lineNumber = toNumberOrNull(line);
if (!base || !commit || !normalizedPath) return "";
try {
const parsed = new URL(base);
const host = (parsed.hostname || "").toLowerCase();
if (host === "dev.azure.com" || host.endsWith(".visualstudio.com")) {
const encodedPath = encodeURIComponent(normalizedPath);
return lineNumber
? `${base}/commit/${commit}?path=/${encodedPath}& line=${lineNumber}`
: `${base}/commit/${commit}?path=/${encodedPath}`;
}
if (host === "bitbucket.org") {
const anchor = encodeURIComponent(normalizedPath).replace(/%2F/g, "/");
return lineNumber
? `${base}/commits/${commit}#L${anchor}F${lineNumber}`
: `${base}/commits/${commit}`;
}
} catch (_) {
return "";
}
return lineNumber
? `${base}/blob/${commit}/${normalizedPath}#L${lineNumber}`
: `${base}/blob/${commit}/${normalizedPath}`;
}
function buildGitMetadata(commitId, email, fileUrl, repositoryUrl) {
2026-04-16 06:44:12 -07:00
const gitMetadata = {};
if (commitId || email) {
gitMetadata.commit = {};
if (commitId) gitMetadata.commit.id = String(commitId);
if (email) gitMetadata.commit.committer = { email: String(email) };
}
if (fileUrl) {
gitMetadata.file = { url: String(fileUrl) };
}
2026-04-24 00:14:56 -07:00
if (repositoryUrl) {
gitMetadata.repository = { url: String(repositoryUrl) };
}
2026-04-16 06:44:12 -07:00
return Object.keys(gitMetadata).length ? gitMetadata : null;
}
function buildImportedSummary(toolName, normalizedFindings) {
const counts = calculateValidationCounts(normalizedFindings.map((entry) => ({
finding: entry.finding,
})));
return {
findings: normalizedFindings.length,
active_findings: counts.active || 0,
inactive_findings: counts.inactive || 0,
unknown_validation_findings: counts.unknown || 0,
access_map_identities: 0,
confidence_level: toolName === "Gitleaks" ? "imported" : "imported",
custom_rules_used: false,
successful_validations: counts.active || 0,
failed_validations: counts.inactive || 0,
skipped_validations: counts.not_attempted || 0,
};
}
function normalizeGitleaksFinding(item) {
if (!item || typeof item !== "object") return null;
const ruleId = String(firstNonEmpty(item.RuleID, item.ruleID, item.RuleId, item.Rule) || "imported.gitleaks.unknown");
const description = String(firstNonEmpty(item.Description, item.description, item.RuleID, item.Rule) || ruleId);
const path = String(firstNonEmpty(item.File, item.file, item.SymlinkFile, item.symlinkFile) || "");
const line = toNumberOrNull(firstNonEmpty(item.StartLine, item.startLine, item.lineNumber));
const columnStart = toNumberOrNull(firstNonEmpty(item.StartColumn, item.startColumn));
const columnEnd = toNumberOrNull(firstNonEmpty(item.EndColumn, item.endColumn));
const snippet = String(firstNonEmpty(item.Secret, item.secret, item.Match, item.match, item.line, description) || "");
const entropy = firstNonEmpty(item.Entropy, item.entropy, item.OffenderEntropy, item.offenderEntropy);
const secretIdentity = String(firstNonEmpty(item.Secret, item.secret, item.Match, item.match) || "");
const fingerprint = String(
firstNonEmpty(item.Fingerprint, item.fingerprint) ||
(secretIdentity
? buildSyntheticFingerprint("gitleaks", [ruleId, secretIdentity])
: buildSyntheticFingerprint("gitleaks", [ruleId, path, line, columnStart, snippet]))
);
2026-04-24 00:14:56 -07:00
const repoUrl = firstNonEmpty(item.RepoURL, item.repoURL, item.Repo, item.repo);
const directUrl = firstNonEmpty(item.LeakURL, item.leakURL, item.Url, item.url);
const fileUrl = isHttpUrl(directUrl)
? directUrl
: buildGitFileUrl(repoUrl, firstNonEmpty(item.Commit, item.commit), path, line);
2026-04-16 06:44:12 -07:00
const gitMetadata = buildGitMetadata(
firstNonEmpty(item.Commit, item.commit),
firstNonEmpty(item.Email, item.email),
2026-04-24 00:14:56 -07:00
fileUrl,
normalizeRepositoryWebUrl(repoUrl)
2026-04-16 06:44:12 -07:00
);
return {
rule: {
id: "imported.gitleaks." + slugifyIdentifier(ruleId),
name: description,
},
finding: {
snippet,
fingerprint,
confidence: "medium",
entropy: entropy !== "" ? String(entropy) : "",
validation: {
status: "Not Attempted",
response: "",
},
language: "Imported",
line,
column_start: columnStart,
column_end: columnEnd,
path,
git_metadata: gitMetadata,
viewer_import: {
source_tool: "Gitleaks",
source_rule_id: ruleId,
source_rule_name: description,
},
},
};
}
function normalizeTruffleHogFinding(item) {
if (!item || typeof item !== "object") return null;
const detectorName = String(firstNonEmpty(item.DetectorName, item.detectorName, item.DetectorDescription, item.detectorDescription) || "Unknown Detector");
const detectorDescription = String(firstNonEmpty(item.DetectorDescription, item.detectorDescription, detectorName) || detectorName);
const sourceMetadata = item.SourceMetadata || item.sourceMetadata || {};
2026-04-24 00:14:56 -07:00
const gitSource = findDeepValue(sourceMetadata, ["git"]) || {};
2026-04-16 06:44:12 -07:00
const path = String(firstNonEmpty(
findDeepValue(sourceMetadata, ["file", "path", "filename"]),
item.File,
item.file
) || "");
const line = toNumberOrNull(firstNonEmpty(
findDeepValue(sourceMetadata, ["line", "line_number", "linenumber", "startline"]),
item.Line,
item.line
));
const sourceUrl = firstNonEmpty(
findDeepValue(sourceMetadata, ["url", "link", "htmlurl", "html_url", "rawurl", "raw_url"]),
item.Url,
item.url
);
2026-04-24 00:14:56 -07:00
const repositoryUrl = firstNonEmpty(
findDeepValue(gitSource, ["repository", "repositoryurl", "repo", "repo_url", "remote", "remoteurl", "remote_url"]),
findDeepValue(sourceMetadata, ["repository", "repositoryurl", "repo", "repo_url", "remote", "remoteurl", "remote_url"]),
item.Repository,
item.repository
);
2026-04-16 06:44:12 -07:00
const commitId = firstNonEmpty(
findDeepValue(sourceMetadata, ["commit", "commitid", "hash", "sha"]),
item.Commit,
item.commit
);
const email = firstNonEmpty(
findDeepValue(sourceMetadata, ["email", "authoremail", "committeremail"]),
item.Email,
item.email
);
const snippet = String(firstNonEmpty(item.Redacted, item.redacted, item.RawV2, item.rawV2, item.Raw, item.raw, detectorDescription) || "");
const secretIdentity = String(firstNonEmpty(item.RawV2, item.rawV2, item.Raw, item.raw, item.Redacted, item.redacted) || "");
const fingerprint = String(
firstNonEmpty(item.Fingerprint, item.fingerprint) ||
(secretIdentity
? buildSyntheticFingerprint("trufflehog", [detectorName, secretIdentity])
: buildSyntheticFingerprint("trufflehog", [detectorName, path, line, snippet]))
);
const verified = Boolean(item.Verified);
const verificationError = String(firstNonEmpty(item.VerificationError, item.verificationError) || "");
2026-04-24 00:14:56 -07:00
const normalizedRepoUrl = normalizeRepositoryWebUrl(repositoryUrl);
const fileUrl = isHttpUrl(sourceUrl) ? sourceUrl : buildGitFileUrl(normalizedRepoUrl, commitId, path, line);
const gitMetadata = buildGitMetadata(commitId, email, fileUrl, normalizedRepoUrl);
2026-04-16 06:44:12 -07:00
return {
rule: {
id: "imported.trufflehog." + slugifyIdentifier(detectorName),
name: detectorName,
},
finding: {
snippet,
fingerprint,
confidence: verified ? "high" : "medium",
entropy: "",
validation: {
status: verified ? "Active Credential" : "Not Attempted",
response: verificationError,
},
language: "Imported",
line,
column_start: null,
column_end: null,
path,
git_metadata: gitMetadata,
viewer_import: {
source_tool: "TruffleHog",
source_rule_id: detectorName,
source_rule_name: detectorName,
source_rule_description: detectorDescription,
},
},
};
}
function normalizeImportedPayload(payload, toolName) {
const format = toolName === "TruffleHog" ? "trufflehog" : "gitleaks";
const entries = unwrapImportedEntries(payload, format);
const normalizedFindings = entries
.map((item) => format === "trufflehog" ? normalizeTruffleHogFinding(item) : normalizeGitleaksFinding(item))
.filter(Boolean);
const firstEntry = entries.find((item) => item & & typeof item === "object") || {};
const notes = toolName === "TruffleHog"
2026-04-23 14:42:10 -07:00
? ["Imported from the open-source TruffleHog JSON output. Only findings marked verified are shown as Active; others are Not Attempted. Re-scan with Kingfisher --access-map for validation, revoke commands, and blast radius. Not affiliated with Truffle Security Co."]
: ["Imported from the open-source Gitleaks JSON output. No validation, revoke commands, or access-map data. Re-scan with Kingfisher --access-map for full enrichment. Not affiliated with the Gitleaks project."];
2026-04-16 06:44:12 -07:00
const target = String(firstNonEmpty(
firstEntry.SourceName,
firstEntry.sourceName,
firstEntry.Repo,
firstEntry.repo,
firstEntry.Repository,
firstEntry.repository
) || "");
return {
f: normalizedFindings,
am: [],
rd: {
findings: normalizedFindings,
access_map: [],
metadata: {
generated_at: new Date().toISOString(),
scan_timestamp: new Date().toISOString(),
target,
kingfisher_version: "",
summary: buildImportedSummary(toolName, normalizedFindings),
viewer_import: buildViewerImportMetadata(toolName, {
imported: true,
accessMapSupported: false,
validateCommandSupported: false,
revokeCommandSupported: false,
notes,
}),
},
},
};
}
function normalizeKingfisherPayload(payload) {
const collected = collectReportData(payload);
const rd = collected.mainReport || collected.statsReport
? Object.assign({}, collected.mainReport || {}, collected.statsReport || {})
: {};
rd.metadata = Object.assign({}, rd.metadata || {});
rd.metadata.viewer_import = mergeViewerImportMetadata(
buildViewerImportMetadata("Kingfisher", {
imported: false,
accessMapSupported: true,
validateCommandSupported: true,
revokeCommandSupported: true,
notes: [],
}),
rd.metadata.viewer_import
);
return { f: collected.findings, am: collected.accessMap, rd };
}
function normalizeReportPayload(payload) {
2026-04-22 23:39:19 -07:00
if (Array.isArray(payload)) {
const topFormat = detectReportFormat(payload);
if (topFormat === "gitleaks") {
return normalizeImportedPayload(payload, "Gitleaks");
}
if (topFormat === "trufflehog") {
return normalizeImportedPayload(payload, "TruffleHog");
}
const entryFormats = [];
let hasImported = false;
for (const entry of payload) {
if (entry === null || typeof entry !== "object") {
entryFormats.push(null);
continue;
}
const fmt = detectReportFormat(entry);
entryFormats.push(fmt);
if (fmt === "gitleaks" || fmt === "trufflehog") {
hasImported = true;
}
}
if (!hasImported) {
return normalizeKingfisherPayload(payload);
}
const combined = { f: [], am: [], rd: null };
for (let i = 0; i < payload.length ; i + + ) {
const entry = payload[i];
const fmt = entryFormats[i];
if (entry === null || typeof entry !== "object") continue;
let partial;
if (fmt === "trufflehog") {
partial = normalizeImportedPayload(entry, "TruffleHog");
} else if (fmt === "gitleaks") {
partial = normalizeImportedPayload(entry, "Gitleaks");
} else {
partial = normalizeKingfisherPayload(entry);
}
if (!partial) continue;
if (Array.isArray(partial.f)) combined.f.push(...partial.f);
if (Array.isArray(partial.am)) combined.am.push(...partial.am);
combined.rd = mergeRawReportData(combined.rd, partial.rd);
}
return combined;
}
2026-04-16 06:44:12 -07:00
const format = detectReportFormat(payload);
if (format === "trufflehog") {
return normalizeImportedPayload(payload, "TruffleHog");
}
if (format === "gitleaks") {
return normalizeImportedPayload(payload, "Gitleaks");
}
return normalizeKingfisherPayload(payload);
}
function mergeRawReportData(base, incoming) {
if (!base) return incoming || null;
if (!incoming) return base;
const merged = Object.assign({}, base, incoming);
merged.metadata = Object.assign({}, base.metadata || {}, incoming.metadata || {});
merged.metadata.viewer_import = mergeViewerImportMetadata(
base.metadata & & base.metadata.viewer_import,
incoming.metadata & & incoming.metadata.viewer_import
);
if (!merged.metadata.target) {
merged.metadata.target = firstNonEmpty(base.metadata & & base.metadata.target, incoming.metadata & & incoming.metadata.target) || "";
}
if (!merged.metadata.scan_timestamp) {
merged.metadata.scan_timestamp = firstNonEmpty(base.metadata & & base.metadata.scan_timestamp, incoming.metadata & & incoming.metadata.scan_timestamp) || new Date().toISOString();
}
if (!merged.metadata.generated_at) {
merged.metadata.generated_at = firstNonEmpty(base.metadata & & base.metadata.generated_at, incoming.metadata & & incoming.metadata.generated_at) || new Date().toISOString();
}
return merged;
}
2026-04-16 09:56:56 -07:00
function processFiles(files, { append = false } = {}) {
2026-04-15 17:13:10 -07:00
const validFiles = files.filter((f) => {
const name = f.name.toLowerCase();
return name.endsWith(".json") || name.endsWith(".jsonl");
});
if (validFiles.length === 0) {
errorMsg.textContent = "No JSON or JSONL files found in the selection.";
errorMsg.classList.remove("hidden");
return;
}
const label = validFiles.length === 1
2026-04-16 09:56:56 -07:00
? (append ? 'Importing "' + validFiles[0].name + '"…' : 'Processing "' + validFiles[0].name + '"…')
: (append ? "Importing " + validFiles.length + " additional files…" : "Processing " + validFiles.length + " files…");
2026-04-15 17:13:10 -07:00
loaderText.textContent = label;
2025-12-04 22:02:30 -08:00
loader.classList.remove("hidden");
errorMsg.classList.add("hidden");
errorMsg.textContent = "";
setTimeout(() => {
2026-04-15 17:13:10 -07:00
let completed = 0;
const texts = new Array(validFiles.length);
validFiles.forEach((file, idx) => {
const reader = new FileReader();
reader.onload = (e) => {
texts[idx] = e.target.result;
completed++;
if (completed === validFiles.length) {
try {
2026-04-16 09:56:56 -07:00
parseAndRenderMultiple(texts, { append });
2026-04-15 17:13:10 -07:00
} catch (err) {
console.error(err);
errorMsg.textContent = "Error parsing files: " + err.message;
errorMsg.classList.remove("hidden");
loader.classList.add("hidden");
}
}
};
reader.onerror = () => {
texts[idx] = null;
completed++;
if (completed === validFiles.length) {
const validTexts = texts.filter(Boolean);
if (validTexts.length === 0) {
errorMsg.textContent = "Failed to read any files.";
errorMsg.classList.remove("hidden");
loader.classList.add("hidden");
} else {
try {
2026-04-16 09:56:56 -07:00
parseAndRenderMultiple(validTexts, { append });
2026-04-15 17:13:10 -07:00
} catch (err) {
console.error(err);
errorMsg.textContent = "Error parsing files: " + err.message;
errorMsg.classList.remove("hidden");
loader.classList.add("hidden");
}
}
}
};
reader.readAsText(file);
});
2025-12-04 22:02:30 -08:00
}, 30);
}
2025-12-05 22:24:16 -08:00
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;
2026-02-13 18:19:18 -08:00
currentDetailFinding = null;
scanMetadata = {};
2026-04-23 14:42:10 -07:00
duplicatesRemoved = { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 };
2025-12-05 22:24:16 -08:00
currentFilter = "";
validationFilter = "all";
2026-04-16 09:56:56 -07:00
sourceFilter = "all";
2025-12-05 22:24:16 -08:00
pageSize = 10;
currentPage = 1;
sortField = "rule";
sortDirection = "asc";
2026-04-16 09:56:56 -07:00
detectorBreakdownActiveOnly = false;
detectorBreakdownPage = 1;
2025-12-05 22:24:16 -08:00
searchInput.value = "";
validationSelect.value = "all";
2026-04-16 09:56:56 -07:00
sourceSelect.value = "all";
2025-12-05 22:24:16 -08:00
rowsSelect.value = "10";
treeSearch.value = "";
2026-04-16 09:56:56 -07:00
if (detectorBreakdownActiveOnlyToggle) detectorBreakdownActiveOnlyToggle.checked = false;
2025-12-05 22:24:16 -08:00
2025-12-17 11:57:35 -08:00
setAccessMapCollapsed(true, { auto: true });
if (amToggle) amToggle.disabled = true;
if (amEmptyNotice) amEmptyNotice.classList.add("hidden");
2026-02-13 18:35:36 -08:00
const amStatsBar = document.getElementById("am-stats-bar");
if (amStatsBar) amStatsBar.classList.add("hidden");
2025-12-05 22:24:16 -08:00
renderAccessMapTree();
renderFindingsTable();
updateMetrics();
2026-02-13 18:19:18 -08:00
const scanMetaSection = document.getElementById("scan-meta-section");
if (scanMetaSection) scanMetaSection.classList.add("hidden");
2026-04-16 06:44:12 -07:00
const importNote = document.getElementById("import-note");
if (importNote) {
importNote.classList.add("hidden");
importNote.innerHTML = "";
}
2026-02-13 18:19:18 -08:00
const blastSection = document.getElementById("fd-blast-radius");
if (blastSection) blastSection.classList.add("hidden");
const findingDetail = document.getElementById("finding-detail");
if (findingDetail) findingDetail.classList.add("hidden");
2025-12-17 11:57:35 -08:00
setActiveView("view-dashboard");
2025-12-05 22:24:16 -08:00
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) {
2026-04-16 09:56:56 -07:00
openReplaceFilePicker();
2025-12-05 22:24:16 -08:00
}
}
2026-04-16 09:56:56 -07:00
function parseAndRenderMultiple(texts, { append = false } = {}) {
2026-04-15 17:13:10 -07:00
const t0 = performance.now();
2026-04-16 09:56:56 -07:00
const nextFindings = append ? findings.slice() : [];
const nextAccessMap = append ? accessMap.slice() : [];
let nextRawData = append ? rawData : null;
2026-04-15 17:13:10 -07:00
for (let i = 0; i < texts.length ; i + + ) {
const text = texts[i];
if (!text) continue;
const { f, am, rd } = parseSingleText(text);
2026-04-16 09:56:56 -07:00
nextFindings.push(...f);
nextAccessMap.push(...am);
nextRawData = mergeRawReportData(nextRawData, rd);
2026-04-15 17:13:10 -07:00
}
2026-04-23 14:42:10 -07:00
// Deduplicate findings by fingerprint (skip when disabled via UI toggle)
const dedupDisabledMulti = isDedupDisabled();
let dedupResultMulti;
if (dedupDisabledMulti) {
dedupResultMulti = { findings: nextFindings.slice(), dropped: { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 } };
} else {
dedupResultMulti = deduplicateFindings(nextFindings);
}
findings = dedupResultMulti.findings;
duplicatesRemoved = dedupResultMulti.dropped;
// Layer Kingfisher validation/revoke data onto matched imported findings
findings = enrichImportedFindings(findings);
2026-04-15 17:13:10 -07:00
// Deduplicate access map entries by fingerprint
2026-04-16 09:56:56 -07:00
accessMap = deduplicateAccessMap(nextAccessMap);
rawData = nextRawData;
2026-04-15 17:13:10 -07:00
finalizeRender(t0);
}
2025-12-04 22:02:30 -08:00
function parseAndRender(text) {
const t0 = performance.now();
findings = [];
accessMap = [];
rawData = null;
2026-04-15 17:13:10 -07:00
const { f, am, rd } = parseSingleText(text);
2026-04-23 14:42:10 -07:00
const dedupDisabledSingle = isDedupDisabled();
let dedupResultSingle;
if (dedupDisabledSingle) {
dedupResultSingle = { findings: f.slice(), dropped: { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 } };
} else {
dedupResultSingle = deduplicateFindings(f);
}
findings = dedupResultSingle.findings;
duplicatesRemoved = dedupResultSingle.dropped;
findings = enrichImportedFindings(findings);
2026-04-15 17:13:10 -07:00
accessMap = deduplicateAccessMap(am);
rawData = rd;
finalizeRender(t0);
}
function parseSingleText(text) {
2025-12-04 22:02:30 -08:00
let parsed = parsePossiblyMultiJson(text);
if (parsed === null) {
2026-01-01 22:24:57 -08:00
const lines = text.split(/\r?\n/);
const entries = [];
2025-12-04 22:02:30 -08:00
for (let i = 0; i < lines.length ; i + + ) {
const line = lines[i].trim();
2026-04-22 23:39:19 -07:00
if (!line || (line[0] !== "{" & & line[0] !== "[")) continue;
2025-12-04 22:02:30 -08:00
try {
const obj = JSON.parse(line);
2026-01-01 22:24:57 -08:00
entries.push(obj);
2025-12-04 22:02:30 -08:00
} catch (errLine) {
console.warn("Skipping invalid JSON line", i);
}
}
2026-04-16 06:44:12 -07:00
return normalizeReportPayload(entries);
2025-12-04 22:02:30 -08:00
}
2026-04-16 06:44:12 -07:00
return normalizeReportPayload(parsed);
2026-04-15 17:13:10 -07:00
}
2026-04-23 14:42:10 -07:00
function isDedupDisabled() {
// Toggle is default-checked; only disabled when the user has unchecked it.
const el = document.getElementById("dedup-toggle");
return !!(el & & !el.checked);
}
2026-04-15 17:13:10 -07:00
function deduplicateFindings(list) {
const seen = new Set();
const result = [];
2026-04-23 14:42:10 -07:00
const dropped = { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 };
2026-04-15 17:13:10 -07:00
for (const f of list) {
const fp = f.finding & & f.finding.fingerprint ? f.finding.fingerprint : "";
2026-04-23 14:42:10 -07:00
if (fp & & seen.has(fp)) {
const tool = getFindingSourceTool(f.finding || {});
dropped[tool] = (dropped[tool] || 0) + 1;
dropped.total += 1;
continue;
}
2026-04-15 17:13:10 -07:00
if (fp) seen.add(fp);
result.push(f);
}
2026-04-23 14:42:10 -07:00
return { findings: result, dropped };
}
function normalizePathForMatch(p) {
if (!p) return "";
return String(p).replace(/^\.\//, "").replace(/^\/+|\/+$/g, "").toLowerCase();
}
// Two paths match if they're equal, or one is a path-suffix of the other.
// Handles Kingfisher absolute filesystem paths vs TruffleHog/Gitleaks
// repo-relative paths for the same file.
function pathsCompatible(a, b) {
if (!a || !b) return false;
if (a === b) return true;
if (a.endsWith("/" + b)) return true;
if (b.endsWith("/" + a)) return true;
return false;
}
function enrichImportedFindings(findingList) {
// Bucket native Kingfisher findings by line (and commit+line) so we can
// run a cheap path-compatibility check at lookup time.
const byLine = new Map();
const byCommitLine = new Map();
for (const f of findingList) {
if (!f || !f.finding) continue;
if (f.finding.viewer_import) continue;
const fg = f.finding;
const path = normalizePathForMatch(fg.path);
const line = fg.line != null ? String(fg.line) : "";
if (!path || !line) continue;
const entry = { path, f };
if (!byLine.has(line)) byLine.set(line, []);
byLine.get(line).push(entry);
const commit = fg.git_metadata & & fg.git_metadata.commit
? String(fg.git_metadata.commit.id || "") : "";
if (commit) {
const k = commit + "|" + line;
if (!byCommitLine.has(k)) byCommitLine.set(k, []);
byCommitLine.get(k).push(entry);
}
}
for (const f of findingList) {
if (!f || !f.finding || !f.finding.viewer_import) continue;
const fg = f.finding;
const path = normalizePathForMatch(fg.path);
const line = fg.line != null ? String(fg.line) : "";
if (!path || !line) continue;
const commit = fg.git_metadata & & fg.git_metadata.commit
? String(fg.git_metadata.commit.id || "") : "";
let match = null;
let matchStrength = null;
if (commit) {
const bucket = byCommitLine.get(commit + "|" + line) || [];
for (const e of bucket) {
if (pathsCompatible(e.path, path)) { match = e.f; matchStrength = "commit"; break; }
}
}
if (!match) {
const bucket = byLine.get(line) || [];
for (const e of bucket) {
if (pathsCompatible(e.path, path)) { match = e.f; matchStrength = "path-line"; break; }
}
}
if (!match) continue;
const kf = match.finding || {};
fg.kingfisher_enrichment = {
match_strength: matchStrength,
kingfisher_rule_id: (match.rule || {}).id || "",
kingfisher_rule_name: (match.rule || {}).name || "",
kingfisher_fingerprint: kf.fingerprint || "",
validation: kf.validation || null,
validate_command: kf.validate_command || "",
revoke_command: kf.revoke_command || "",
confidence: kf.confidence || "",
};
2026-04-24 00:14:56 -07:00
if (kf.git_metadata & & (!fg.git_metadata || !fg.git_metadata.file || !fg.git_metadata.file.url)) {
fg.git_metadata = Object.assign({}, fg.git_metadata || {});
if (kf.git_metadata.file & & kf.git_metadata.file.url) {
fg.git_metadata.file = Object.assign({}, fg.git_metadata.file || {}, {
url: kf.git_metadata.file.url,
});
}
if (kf.git_metadata.repository & & kf.git_metadata.repository.url) {
fg.git_metadata.repository = Object.assign({}, fg.git_metadata.repository || {}, {
url: kf.git_metadata.repository.url,
});
}
}
2026-04-23 14:42:10 -07:00
}
return findingList;
2026-04-15 17:13:10 -07:00
}
function deduplicateAccessMap(list) {
const seen = new Set();
const result = [];
for (const entry of list) {
const fp = entry.fingerprint || "";
if (fp & & seen.has(fp)) continue;
if (fp) seen.add(fp);
result.push(entry);
}
return result;
}
function finalizeRender(t0) {
2025-12-04 22:02:30 -08:00
currentPage = 1;
currentFilter = "";
validationFilter = "all";
2026-04-16 09:56:56 -07:00
sourceFilter = "all";
2025-12-04 22:02:30 -08:00
searchInput.value = "";
validationSelect.value = "all";
2026-04-16 09:56:56 -07:00
sourceSelect.value = "all";
2025-12-04 22:02:30 -08:00
2026-02-13 18:19:18 -08:00
extractScanMetadata();
2025-12-17 11:57:35 -08:00
setActiveView("view-dashboard");
2025-12-04 22:02:30 -08:00
updateMetrics();
2026-02-13 18:19:18 -08:00
renderScanMetadata();
2025-12-04 22:02:30 -08:00
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);
}
2026-04-16 06:44:12 -07:00
function formatViewerImportSource(viewerImport) {
const tools = toArray(viewerImport & & viewerImport.tools).map((tool) => String(tool)).filter(Boolean);
if (tools.length === 0) return "";
return viewerImport & & viewerImport.imported ? "Imported: " + tools.join(" + ") : tools.join(" + ");
}
function getAccessMapEmptyMessage() {
if (scanMetadata.imported) {
return "Imported reports do not include Kingfisher access-map data. Re-scan with < code > kingfisher scan --access-map< / code > for blast-radius mapping.";
}
return "No Access Map entries in this report. Run a scan with < code > --access-map< / code > to map credential blast radius.";
}
function getImportNoticeHtml(importNotes) {
const notes = toArray(importNotes).map((note) => String(note)).filter(Boolean);
if (!notes.length) return "";
return `< strong > Imported report limits:< / strong > ${notes.map((note) => escapeHtml(note)).join(" ")}`;
}
2026-02-13 18:19:18 -08:00
function extractScanMetadata() {
scanMetadata = {};
if (!rawData || typeof rawData !== "object") {
scanMetadata.timestamp = new Date().toLocaleString();
return;
}
// Try to extract metadata from the raw report data
const data = rawData;
2026-02-15 14:29:42 -08:00
const reportMeta = data.metadata & & typeof data.metadata === "object" ? data.metadata : {};
2026-04-16 06:44:12 -07:00
const viewerImport = reportMeta.viewer_import & & typeof reportMeta.viewer_import === "object"
? reportMeta.viewer_import
: buildViewerImportMetadata("Kingfisher", {});
2026-02-15 14:29:42 -08:00
scanMetadata.timestamp =
reportMeta.scan_timestamp ||
reportMeta.generated_at ||
data.timestamp ||
data.scan_timestamp ||
data.generated_at ||
new Date().toLocaleString();
2026-02-13 18:19:18 -08:00
// Target info
2026-02-15 14:29:42 -08:00
scanMetadata.target =
reportMeta.target ||
data.target ||
data.scan_target ||
data.repository ||
data.repo ||
2026-02-13 18:19:18 -08:00
(data.stats & & data.stats.target) ||
(data.summary & & data.summary.target) || "";
// Duration
const duration = data.scan_duration || data.scanDuration ||
(data.stats & & data.stats.duration) ||
(data.summary & & data.summary.duration) || "";
if (duration) {
scanMetadata.duration = typeof duration === "number" ? (duration / 1000).toFixed(1) + "s" : String(duration);
}
2026-04-16 06:44:12 -07:00
scanMetadata.reportSource = formatViewerImportSource(viewerImport);
scanMetadata.imported = Boolean(viewerImport.imported);
scanMetadata.importNotes = toArray(viewerImport.notes).map((note) => String(note)).filter(Boolean);
scanMetadata.capabilities = {
accessMapSupported: viewerImport.access_map_supported !== false,
validateCommandSupported: viewerImport.validate_command_supported !== false,
revokeCommandSupported: viewerImport.revoke_command_supported !== false,
};
2026-02-13 18:19:18 -08:00
// Version
2026-02-15 14:29:42 -08:00
scanMetadata.version =
reportMeta.kingfisher_version ||
data.version ||
data.kingfisher_version ||
2026-02-13 18:19:18 -08:00
(data.kingfisher & & data.kingfisher.version) || "";
2026-02-15 14:29:42 -08:00
scanMetadata.latestVersion =
reportMeta.latest_version_available ||
(data.kingfisher & & data.kingfisher.latest_version) || "";
scanMetadata.updateCheckStatus =
reportMeta.update_check_status ||
(data.kingfisher & & data.kingfisher.update_check_status) || "";
// Sanitized command-line arguments (when present)
const cliArgs = reportMeta.command_line_args;
if (Array.isArray(cliArgs) & & cliArgs.length > 0) {
scanMetadata.commandLineArgs = cliArgs.map((arg) => String(arg));
}
const reportSummary = reportMeta.summary & & typeof reportMeta.summary === "object"
? reportMeta.summary
: null;
if (reportSummary) {
scanMetadata.summary = {
findings: Number(reportSummary.findings || 0),
active: Number(reportSummary.active_findings || 0),
inactive: Number(reportSummary.inactive_findings || 0),
unknown: Number(reportSummary.unknown_validation_findings || 0),
identities: Number(reportSummary.access_map_identities || 0),
rulesApplied: Number(reportSummary.rules_applied || 0),
confidenceLevel: reportSummary.confidence_level ? String(reportSummary.confidence_level) : "",
customRulesUsed: Boolean(reportSummary.custom_rules_used),
successfulValidations: Number(reportSummary.successful_validations || 0),
failedValidations: Number(reportSummary.failed_validations || 0),
skippedValidations: Number(reportSummary.skipped_validations || 0),
blobsScanned: Number(reportSummary.blobs_scanned || 0),
bytesScanned: Number(reportSummary.bytes_scanned || 0),
scanDurationSeconds: Number(reportSummary.scan_duration_seconds || 0),
};
}
2026-02-13 18:19:18 -08:00
// Bytes scanned
const bytes = data.bytes_scanned ||
(data.stats & & data.stats.bytes_scanned) ||
(data.summary & & data.summary.bytes_scanned) || 0;
if (bytes > 0) {
scanMetadata.bytesScanned = bytes >= 1048576
? (bytes / 1048576).toFixed(1) + " MB"
: bytes >= 1024
? (bytes / 1024).toFixed(1) + " KB"
: bytes + " B";
}
}
function renderScanMetadata() {
const section = document.getElementById("scan-meta-section");
if (!section) return;
const ts = document.getElementById("meta-timestamp");
const targetWrap = document.getElementById("meta-target-wrap");
const target = document.getElementById("meta-target");
const durationWrap = document.getElementById("meta-duration-wrap");
const duration = document.getElementById("meta-duration");
2026-04-16 06:44:12 -07:00
const sourceWrap = document.getElementById("meta-source-wrap");
const source = document.getElementById("meta-source");
2026-02-13 18:19:18 -08:00
const versionWrap = document.getElementById("meta-version-wrap");
const version = document.getElementById("meta-version");
2026-04-16 06:44:12 -07:00
const importNote = document.getElementById("import-note");
2026-02-13 18:19:18 -08:00
section.classList.remove("hidden");
if (ts) ts.textContent = scanMetadata.timestamp || new Date().toLocaleString();
if (scanMetadata.target) {
targetWrap.style.display = "";
target.textContent = scanMetadata.target;
} else {
targetWrap.style.display = "none";
}
if (scanMetadata.duration) {
durationWrap.style.display = "";
duration.textContent = scanMetadata.duration;
} else {
durationWrap.style.display = "none";
}
2026-04-16 06:44:12 -07:00
if (scanMetadata.reportSource) {
sourceWrap.style.display = "";
source.textContent = scanMetadata.reportSource;
} else {
sourceWrap.style.display = "none";
}
2026-02-13 18:19:18 -08:00
if (scanMetadata.version) {
versionWrap.style.display = "";
version.textContent = scanMetadata.version;
} else {
versionWrap.style.display = "none";
}
2026-04-16 06:44:12 -07:00
if (importNote) {
const importHtml = getImportNoticeHtml(scanMetadata.importNotes);
if (importHtml) {
importNote.classList.remove("hidden");
importNote.innerHTML = importHtml;
} else {
importNote.classList.add("hidden");
importNote.innerHTML = "";
}
}
2026-04-16 10:26:07 -07:00
updateSidebarSummary();
}
function updateSidebarSummary() {
const sourceEl = document.getElementById("sidebar-summary-source");
const targetEl = document.getElementById("sidebar-summary-target");
const totalEl = document.getElementById("sidebar-summary-total");
const activeEl = document.getElementById("sidebar-summary-active");
const identitiesEl = document.getElementById("sidebar-summary-identities");
const accessEl = document.getElementById("sidebar-summary-access");
if (!sourceEl || !targetEl || !totalEl || !activeEl || !identitiesEl || !accessEl) return;
const totalFindings = findings.length;
const validationCounts = calculateValidationCounts();
const totalIdentities = (accessMap || []).length;
const sourceText = scanMetadata.reportSource || (totalFindings ? "Kingfisher" : "No report loaded");
const targetText = scanMetadata.target ||
(totalFindings ? "Ready to inspect findings, validation state, and blast radius." : "Load a report to explore findings and blast radius.");
sourceEl.textContent = sourceText;
targetEl.textContent = targetText;
totalEl.textContent = totalFindings.toString();
activeEl.textContent = String(validationCounts.active || 0);
identitiesEl.textContent = totalIdentities.toString();
accessEl.textContent = totalIdentities > 0 ? "Yes" : "No";
2026-02-13 18:19:18 -08:00
}
2025-12-04 22:02:30 -08:00
function updateMetrics() {
2025-12-17 11:57:35 -08:00
const totalFindings = findings.length;
document.getElementById("stat-total").textContent = totalFindings.toString();
2025-12-04 22:02:30 -08:00
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();
2025-12-17 11:57:35 -08:00
const validationCounts = calculateValidationCounts();
document.getElementById("stat-active").textContent = (validationCounts.active || 0).toString();
2025-12-04 22:02:30 -08:00
document.getElementById("stat-identities").textContent = (accessMap || []).length.toString();
2026-04-23 14:42:10 -07:00
const enrichedCount = findings.reduce(
(n, f) => n + (f & & f.finding & & f.finding.kingfisher_enrichment ? 1 : 0),
0
);
const enrichedEl = document.getElementById("stat-enriched");
const enrichedCard = document.getElementById("stat-enriched-card");
if (enrichedEl) enrichedEl.textContent = enrichedCount.toString();
if (enrichedCard) {
if (enrichedCount > 0) enrichedCard.classList.remove("hidden");
else enrichedCard.classList.add("hidden");
}
const dr = duplicatesRemoved || { total: 0, kingfisher: 0, trufflehog: 0, gitleaks: 0 };
const dedupPanel = document.getElementById("dedup-panel");
const kfCount = document.getElementById("dedup-count-kingfisher");
const thCount = document.getElementById("dedup-count-trufflehog");
const glCount = document.getElementById("dedup-count-gitleaks");
if (kfCount) kfCount.textContent = (dr.kingfisher || 0).toLocaleString();
if (thCount) thCount.textContent = (dr.trufflehog || 0).toLocaleString();
if (glCount) glCount.textContent = (dr.gitleaks || 0).toLocaleString();
if (dedupPanel) {
if (dr.total > 0) dedupPanel.classList.remove("hidden");
else dedupPanel.classList.add("hidden");
}
2025-12-17 11:57:35 -08:00
renderStatusChart(validationCounts);
2026-04-16 09:56:56 -07:00
renderSourceChart(calculateSourceCounts());
renderSourceDetectorCharts();
renderDetectorBreakdown();
2026-04-16 10:26:07 -07:00
updateSidebarSummary();
2026-04-16 09:56:56 -07:00
}
function getFindingSourceTool(finding) {
const importInfo = finding & & finding.viewer_import & & typeof finding.viewer_import === "object"
? finding.viewer_import
: null;
const sourceTool = importInfo & & importInfo.source_tool
? String(importInfo.source_tool).trim().toLowerCase()
: "";
if (sourceTool === "gitleaks") return "gitleaks";
if (sourceTool === "trufflehog") return "trufflehog";
return "kingfisher";
2025-12-04 22:02:30 -08:00
}
function getFilteredSortedFindings() {
const filterLower = currentFilter.toLowerCase();
const validation = validationFilter;
2026-04-16 09:56:56 -07:00
const source = sourceFilter;
2025-12-04 22:02:30 -08:00
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();
2026-01-14 17:19:02 -08:00
const fingerprint = (finding.fingerprint || "").toLowerCase();
2026-04-16 09:56:56 -07:00
const sourceTool = getFindingSourceTool(finding);
2025-12-04 22:02:30 -08:00
const status = (finding.validation & & finding.validation.status
? String(finding.validation.status)
: "").toLowerCase();
if (filterLower) {
2026-01-14 17:19:02 -08:00
if (!ruleName.includes(filterLower) & & !path.includes(filterLower) & & !snippet.includes(filterLower) & & !fingerprint.includes(filterLower)) {
2025-12-04 22:02:30 -08:00
return false;
}
}
const normalizedStatus = normalizeValidationStatus(status);
2026-04-16 09:56:56 -07:00
if (source !== "all" & & sourceTool !== source) {
return false;
}
2025-12-04 22:02:30 -08:00
if (validation === "active") {
return normalizedStatus === "active";
} else if (validation === "inactive") {
return normalizedStatus === "inactive";
} else if (validation === "not_attempted") {
return normalizedStatus === "not_attempted";
2026-04-15 17:13:10 -07:00
} else if (validation === "canary") {
return normalizedStatus === "canary";
2025-12-04 22:02:30 -08:00
}
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;
2026-04-23 14:42:10 -07:00
case "source":
va = getFindingSourceTool(fa);
vb = getFindingSourceTool(fb);
break;
2025-12-04 22:02:30 -08:00
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";
}
2026-04-15 17:13:10 -07:00
if (normalized === "canary token (skipped)" || normalized === "canary") {
return "canary";
}
2025-12-04 22:02:30 -08:00
return "unknown";
}
2026-01-14 21:45:55 -08:00
function calculateValidationCounts(list = findings) {
2026-04-15 17:13:10 -07:00
const counts = { active: 0, inactive: 0, not_attempted: 0, canary: 0, unknown: 0 };
2026-01-14 21:45:55 -08:00
(list || []).forEach((f) => {
2025-12-17 11:57:35 -08:00
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;
}
2026-04-16 09:56:56 -07:00
function calculateSourceCounts(list = findings) {
const counts = { kingfisher: 0, gitleaks: 0, trufflehog: 0 };
(list || []).forEach((f) => {
const finding = f & & f.finding ? f.finding : {};
const source = getFindingSourceTool(finding);
counts[source] = (counts[source] || 0) + 1;
});
return counts;
}
2025-12-17 11:57:35 -08:00
2026-04-16 09:56:56 -07:00
function normalizeRuleFamilyId(ruleId) {
const normalized = String(ruleId || "").trim();
if (!normalized) return "";
return normalized.replace(/\.\d+$/, "").replace(/^kingfisher\./, "");
}
2025-12-17 11:57:35 -08:00
2026-04-16 09:56:56 -07:00
function getFindingDetectorLabel(entry) {
const rule = entry & & entry.rule ? entry.rule : {};
const finding = entry & & entry.finding ? entry.finding : {};
const source = getFindingSourceTool(finding);
if (source === "kingfisher") {
return normalizeRuleFamilyId(rule.id) || String(rule.name || rule.id || "Unknown");
}
const importInfo = finding.viewer_import & & typeof finding.viewer_import === "object"
? finding.viewer_import
: null;
return String(
(importInfo & & (importInfo.source_rule_name || importInfo.source_rule_id)) ||
rule.name ||
rule.id ||
"Unknown"
);
}
function getDetectorBreakdownEntries(list = findings) {
const grouped = new Map();
const workingList = detectorBreakdownActiveOnly
? (list || []).filter((entry) => {
const finding = entry & & entry.finding ? entry.finding : {};
const normalizedStatus = normalizeValidationStatus(
finding.validation & & finding.validation.status ? finding.validation.status : ""
);
return normalizedStatus === "active";
})
: (list || []);
workingList.forEach((entry) => {
const finding = entry & & entry.finding ? entry.finding : {};
const source = getFindingSourceTool(finding);
const normalizedStatus = normalizeValidationStatus(
finding.validation & & finding.validation.status ? finding.validation.status : ""
);
const label = getFindingDetectorLabel(entry);
const key = source + "::" + label;
if (!grouped.has(key)) {
grouped.set(key, {
key,
source,
label,
count: 0,
active: 0,
});
}
const current = grouped.get(key);
current.count += 1;
if (normalizedStatus === "active") current.active += 1;
});
const entries = Array.from(grouped.values());
const dirFactor = detectorBreakdownSortDirection === "asc" ? 1 : -1;
entries.sort((a, b) => {
let va;
let vb;
switch (detectorBreakdownSortField) {
case "label":
va = (a.label || "").toLowerCase();
vb = (b.label || "").toLowerCase();
break;
case "source":
va = getSourceDisplayName(a.source).toLowerCase();
vb = getSourceDisplayName(b.source).toLowerCase();
break;
case "active":
va = Number(a.active || 0);
vb = Number(b.active || 0);
break;
case "count":
default:
va = Number(a.count || 0);
vb = Number(b.count || 0);
break;
}
if (typeof va === "number" & & typeof vb === "number") {
if (va < vb ) return -1 * dirFactor ;
if (va > vb) return 1 * dirFactor;
return a.label.localeCompare(b.label);
}
if (va < vb ) return -1 * dirFactor ;
if (va > vb) return 1 * dirFactor;
return Number(b.count || 0) - Number(a.count || 0);
});
return entries;
}
function getChartColor(index) {
const palette = [
"#0ea5e9",
"#8b5cf6",
"#22c55e",
"#f97316",
"#ec4899",
"#eab308",
"#14b8a6",
"#ef4444",
2025-12-17 11:57:35 -08:00
];
2026-04-16 09:56:56 -07:00
return palette[index % palette.length];
}
function getSourceDetectorChartEntries(list = findings) {
const groupedBySource = new Map();
(list || []).forEach((entry) => {
const finding = entry & & entry.finding ? entry.finding : {};
const source = getFindingSourceTool(finding);
const label = getFindingDetectorLabel(entry);
if (!groupedBySource.has(source)) groupedBySource.set(source, new Map());
const detectorCounts = groupedBySource.get(source);
detectorCounts.set(label, (detectorCounts.get(label) || 0) + 1);
});
return ["kingfisher", "gitleaks", "trufflehog"]
.filter((source) => groupedBySource.has(source))
.map((source) => {
const countsMap = groupedBySource.get(source);
const sortedEntries = Array.from(countsMap.entries())
.map(([label, count]) => ({ label, count }))
.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return a.label.localeCompare(b.label);
});
const topEntries = sortedEntries.slice(0, 6);
const otherCount = sortedEntries.slice(6).reduce((sum, entry) => sum + entry.count, 0);
if (otherCount > 0) topEntries.push({ label: "Other", count: otherCount });
const dataPoints = topEntries.map((entry, index) => ({
key: entry.label,
label: entry.label,
color: entry.label === "Other" ? "#94a3b8" : getChartColor(index),
}));
const counts = {};
topEntries.forEach((entry) => {
counts[entry.label] = entry.count;
});
return {
source,
sourceLabel: getSourceDisplayName(source),
detectorCount: sortedEntries.length,
counts,
dataPoints,
};
});
}
function toggleDetectorBreakdownSort(field) {
if (detectorBreakdownSortField === field) {
detectorBreakdownSortDirection = detectorBreakdownSortDirection === "asc" ? "desc" : "asc";
} else {
detectorBreakdownSortField = field;
detectorBreakdownSortDirection = field === "label" || field === "source" ? "asc" : "desc";
}
detectorBreakdownPage = 1;
renderDetectorBreakdown();
}
function getDetectorBreakdownSortIndicator(field) {
if (detectorBreakdownSortField !== field) return "";
return detectorBreakdownSortDirection === "asc" ? "▲" : "▼";
}
function getSourceBadgeClass(source) {
if (source === "gitleaks") return "badge-github";
if (source === "trufflehog") return "badge-gcp";
return "badge-aws";
}
function getSourceDisplayName(source) {
if (source === "gitleaks") return "Gitleaks";
if (source === "trufflehog") return "TruffleHog";
return "Kingfisher";
}
function renderDonutChart(canvas, legendEl, dataPoints, counts, emptyLabel) {
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
2025-12-17 11:57:35 -08:00
const total = dataPoints.reduce((sum, entry) => sum + (counts[entry.key] || 0), 0);
2026-04-16 09:56:56 -07:00
ctx.clearRect(0, 0, canvas.width, canvas.height);
2025-12-17 11:57:35 -08:00
const style = getComputedStyle(document.documentElement);
const surfaceColor = style.getPropertyValue("--surface") || "#0d1424";
const textColor = style.getPropertyValue("--text-main") || "#e5e7eb";
2026-04-16 09:56:56 -07:00
const radius = Math.min(canvas.width, canvas.height) / 2 - 10;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
2025-12-17 11:57:35 -08:00
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";
2026-04-16 09:56:56 -07:00
ctx.fillText(emptyLabel, centerX, centerY);
2025-12-17 11:57:35 -08:00
} 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();
2026-04-16 09:56:56 -07:00
if (legendEl) {
legendEl.innerHTML = "";
2025-12-17 11:57:35 -08:00
dataPoints.forEach((entry) => {
2026-04-16 09:56:56 -07:00
if ((counts[entry.key] || 0) === 0) return;
2025-12-17 11:57:35 -08:00
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);
2026-04-16 09:56:56 -07:00
legendEl.appendChild(row);
2025-12-17 11:57:35 -08:00
});
}
}
2026-04-16 09:56:56 -07:00
function renderStatusChart(counts) {
const palette = {
active: "#22c55e",
inactive: "#f97316",
not_attempted: "#38bdf8",
canary: "#a855f7",
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: "canary", label: "Canary Token", color: palette.canary },
{ key: "unknown", label: "Unknown", color: palette.unknown },
];
renderDonutChart(statusChartCanvas, statusLegend, dataPoints, counts, "No validation data yet");
}
function renderSourceChart(counts) {
const dataPoints = [
{ key: "kingfisher", label: "Kingfisher", color: "#0e7c56" },
{ key: "gitleaks", label: "Gitleaks", color: "#2563eb" },
{ key: "trufflehog", label: "TruffleHog", color: "#7c3aed" },
];
const loadedSources = dataPoints.filter((entry) => (counts[entry.key] || 0) > 0);
renderDonutChart(
sourceChartCanvas,
sourceLegend,
loadedSources,
counts,
"No source data yet",
);
}
function renderSourceDetectorCharts() {
if (!sourceDetectorCharts) return;
const chartEntries = getSourceDetectorChartEntries(findings);
if (!chartEntries.length) {
sourceDetectorCharts.innerHTML = '< div class = "breakdown-empty" > No findings loaded yet.< / div > ';
return;
}
sourceDetectorCharts.innerHTML = "";
chartEntries.forEach((entry, index) => {
const card = document.createElement("div");
card.className = "chart-card";
const canvas = document.createElement("canvas");
canvas.width = 260;
canvas.height = 220;
canvas.id = `source-detector-chart-${index}`;
const legendWrap = document.createElement("div");
legendWrap.className = "chart-legend";
const heading = document.createElement("div");
heading.style.marginBottom = "10px";
const titleRow = document.createElement("div");
titleRow.className = "chart-legend-item";
titleRow.style.fontWeight = "700";
titleRow.textContent = `${entry.sourceLabel} detector mix`;
const subtitleRow = document.createElement("div");
subtitleRow.className = "chart-legend-item";
subtitleRow.style.color = "var(--text-muted)";
subtitleRow.textContent = `${entry.detectorCount} detector families${entry.detectorCount > 6 ? ", top 6 plus Other" : ""}`;
heading.appendChild(titleRow);
heading.appendChild(subtitleRow);
legendWrap.appendChild(heading);
const legend = document.createElement("div");
legend.className = "chart-legend";
card.appendChild(canvas);
legendWrap.appendChild(legend);
card.appendChild(legendWrap);
sourceDetectorCharts.appendChild(card);
renderDonutChart(canvas, legend, entry.dataPoints, entry.counts, "No detector data yet");
});
}
function renderDetectorBreakdown() {
if (!detectorBreakdown) return;
const entries = getDetectorBreakdownEntries(findings);
const pageSize = 25;
const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
detectorBreakdownPage = Math.min(Math.max(1, detectorBreakdownPage), totalPages);
if (detectorBreakdownPrev) detectorBreakdownPrev.disabled = detectorBreakdownPage < = 1;
if (detectorBreakdownNext) detectorBreakdownNext.disabled = detectorBreakdownPage >= totalPages;
if (!entries.length) {
if (detectorBreakdownPageInfo) detectorBreakdownPageInfo.textContent = "0 of 0";
detectorBreakdown.innerHTML = `< div class = "breakdown-empty" > ${
detectorBreakdownActiveOnly ? "No active findings loaded yet." : "No findings loaded yet."
}< / div > `;
return;
}
const pageStart = (detectorBreakdownPage - 1) * pageSize;
const pageEntries = entries.slice(pageStart, pageStart + pageSize);
const rangeStart = pageStart + 1;
const rangeEnd = Math.min(pageStart + pageEntries.length, entries.length);
if (detectorBreakdownPageInfo) {
detectorBreakdownPageInfo.textContent = `${rangeStart}-${rangeEnd} of ${entries.length}`;
}
const rows = pageEntries.map((entry) => `
< tr >
< td >
< div class = "breakdown-name" > ${escapeHtml(entry.label)}< / div >
< / td >
< td > < span class = "badge ${getSourceBadgeClass(entry.source)}" > ${escapeHtml(getSourceDisplayName(entry.source))}< / span > < / td >
< td class = "breakdown-count" > ${entry.count}< / td >
< td class = "breakdown-count" > ${entry.active}< / td >
< / tr >
`).join("");
detectorBreakdown.innerHTML = `
< table class = "breakdown-table" >
< thead >
< tr >
< th > < button class = "breakdown-sort-btn" type = "button" onclick = "toggleDetectorBreakdownSort('label')" > Detector < span class = "breakdown-sort-indicator" > ${getDetectorBreakdownSortIndicator("label")}< / span > < / button > < / th >
< th > < button class = "breakdown-sort-btn" type = "button" onclick = "toggleDetectorBreakdownSort('source')" > Source < span class = "breakdown-sort-indicator" > ${getDetectorBreakdownSortIndicator("source")}< / span > < / button > < / th >
< th > < button class = "breakdown-sort-btn" type = "button" onclick = "toggleDetectorBreakdownSort('count')" > ${detectorBreakdownActiveOnly ? "Active findings" : "Findings"} < span class = "breakdown-sort-indicator" > ${getDetectorBreakdownSortIndicator("count")}< / span > < / button > < / th >
< th > < button class = "breakdown-sort-btn" type = "button" onclick = "toggleDetectorBreakdownSort('active')" > Active < span class = "breakdown-sort-indicator" > ${getDetectorBreakdownSortIndicator("active")}< / span > < / button > < / th >
< / tr >
< / thead >
< tbody > ${rows}< / tbody >
< / table >
`;
}
2025-12-05 22:24:16 -08:00
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",
2026-04-24 00:14:56 -07:00
"source_tool",
2025-12-05 22:24:16 -08:00
"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 || "",
2026-04-24 00:14:56 -07:00
getFindingSourceDisplayName(finding),
2025-12-05 22:24:16 -08:00
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");
}
2026-01-14 21:45:55 -08:00
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("");
}
2026-02-13 18:19:18 -08:00
function generateScanReport(scope = "all", { activeOnly = false } = {}) {
if (!findings || findings.length === 0) {
alert("Load a report before downloading a Scan report.");
2025-12-17 11:57:35 -08:00
return;
}
2026-04-15 17:13:10 -07:00
const statusOrder = { active: 0, inactive: 1, canary: 2, not_attempted: 3, unknown: 4 };
2026-02-13 18:19:18 -08:00
let baseFindings =
2026-01-14 21:45:55 -08:00
scope === "filtered" ? getFilteredSortedFindings().slice() : (Array.isArray(findings) ? findings.slice() : []);
2026-02-13 18:19:18 -08:00
if (activeOnly) {
baseFindings = baseFindings.filter((f) => {
const fd = f.finding || {};
return normalizeValidationStatus(fd.validation & & fd.validation.status ? fd.validation.status : "") === "active";
});
if (baseFindings.length === 0) {
alert("No active credentials found for the selected scope. Uncheck 'Active credentials only' to include all findings.");
return;
}
}
2026-01-14 21:45:55 -08:00
const findingsForReport = baseFindings.slice();
2025-12-17 11:57:35 -08:00
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);
});
2026-01-14 21:45:55 -08:00
const counts = calculateValidationCounts(baseFindings);
2025-12-17 11:57:35 -08:00
const hasAccess = Array.isArray(accessMap) & & accessMap.length > 0;
2026-01-14 21:45:55 -08:00
const statusImage = scope === "all" & & statusChartCanvas ? statusChartCanvas.toDataURL("image/png") : "";
const highConfidence = baseFindings.filter((f) => {
2025-12-17 11:57:35 -08:00
const conf = f.finding & & f.finding.confidence ? String(f.finding.confidence) : "";
return conf.toLowerCase() === "high";
}).length;
2026-02-13 18:19:18 -08:00
const scopeLabel = (scope === "filtered" ? "Filtered findings" : "All findings") + (activeOnly ? " (active only)" : "");
// Gather unique rules for summary
const ruleSummary = {};
baseFindings.forEach((entry) => {
const rule = entry.rule || {};
const finding = entry.finding || {};
const key = rule.id || rule.name || "unknown";
if (!ruleSummary[key]) {
ruleSummary[key] = { name: rule.name || rule.id || "Unknown", count: 0, active: 0 };
}
ruleSummary[key].count++;
if (normalizeValidationStatus(finding.validation & & finding.validation.status ? finding.validation.status : "") === "active") {
ruleSummary[key].active++;
}
});
const ruleSummaryRows = Object.values(ruleSummary)
.sort((a, b) => b.active - a.active || b.count - a.count)
.map((r) => `< tr > < td > ${escapeHtml(r.name)}< / td > < td > ${r.count}< / td > < td style = "color:${r.active > 0 ? " # dc2626 " : " # 6b7280 " } ; font-weight: $ { r . active > 0 ? "700" : "400"};">${r.active}< / td > < / tr > `)
.join("");
2025-12-17 11:57:35 -08:00
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";
2026-02-13 18:19:18 -08:00
const normalizedStatus = normalizeValidationStatus(statusRaw);
2026-01-14 21:45:55 -08:00
const findingId = getFindingIdFromFinding(finding);
const gitUrl = getFileUrlFromFinding(finding);
2026-04-24 00:14:56 -07:00
const sourceTool = getFindingSourceDisplayName(finding);
2026-02-13 18:19:18 -08:00
const statusColor = normalizedStatus === "active" ? "#dc2626" : normalizedStatus === "inactive" ? "#f97316" : "#6b7280";
2025-12-17 11:57:35 -08:00
return `
< tr >
< td > ${escapeHtml(rule.name || rule.id || "")}< / td >
2026-04-24 00:14:56 -07:00
< td > ${escapeHtml(sourceTool)}< / td >
2026-02-13 18:19:18 -08:00
< td style = "font-size:10px;font-family:monospace;" > ${escapeHtml(findingId ? findingId.substring(0, 12) : "")}< / td >
< td style = "font-size:10px;" > ${escapeHtml(finding.path || "")}< / td >
< td style = "color:${statusColor};font-weight:600;" > ${escapeHtml(statusRaw)}< / td >
< td > ${escapeHtml(finding.confidence || "")}< / td >
< td > ${finding.line != null ? finding.line : ""}< / td >
${gitUrl ? `< td style = "font-size:10px;" > < a href = "${escapeHtml(gitUrl)}" style = "color:#1d4ed8;" > ${escapeHtml(gitUrl.length > 50 ? gitUrl.substring(0, 50) + "..." : gitUrl)}< / a > < / td > ` : "< td > < / td > "}
2025-12-17 11:57:35 -08:00
< / tr >
`;
})
.join("")
2026-04-24 00:14:56 -07:00
: '< tr > < td colspan = "8" > No findings available.< / td > < / tr > ';
2025-12-17 11:57:35 -08:00
2026-02-13 18:19:18 -08:00
// Scan metadata section
const metaLines = [];
if (scanMetadata.timestamp) metaLines.push(`< strong > Scan Date:< / strong > ${escapeHtml(scanMetadata.timestamp)}`);
if (scanMetadata.target) metaLines.push(`< strong > Target:< / strong > ${escapeHtml(scanMetadata.target)}`);
if (scanMetadata.duration) metaLines.push(`< strong > Duration:< / strong > ${escapeHtml(scanMetadata.duration)}`);
2026-04-16 06:44:12 -07:00
if (scanMetadata.reportSource) metaLines.push(`< strong > Source:< / strong > ${escapeHtml(scanMetadata.reportSource)}`);
2026-02-13 18:19:18 -08:00
if (scanMetadata.version) metaLines.push(`< strong > Version:< / strong > ${escapeHtml(scanMetadata.version)}`);
2026-02-15 14:29:42 -08:00
if (scanMetadata.latestVersion) metaLines.push(`< strong > Latest:< / strong > ${escapeHtml(scanMetadata.latestVersion)}`);
if (scanMetadata.updateCheckStatus) metaLines.push(`< strong > Update Check:< / strong > ${escapeHtml(scanMetadata.updateCheckStatus)}`);
if (scanMetadata.summary & & scanMetadata.summary.rulesApplied > 0) {
metaLines.push(`< strong > Rules Applied:< / strong > ${scanMetadata.summary.rulesApplied}`);
}
if (scanMetadata.summary & & scanMetadata.summary.confidenceLevel) {
metaLines.push(`< strong > Confidence:< / strong > ${escapeHtml(scanMetadata.summary.confidenceLevel)}`);
}
if (scanMetadata.summary) {
metaLines.push(`< strong > Custom Rules:< / strong > ${scanMetadata.summary.customRulesUsed ? "Yes" : "No"}`);
}
if (scanMetadata.summary & & scanMetadata.summary.successfulValidations) {
metaLines.push(`< strong > Successful Validations:< / strong > ${scanMetadata.summary.successfulValidations}`);
}
if (scanMetadata.summary & & scanMetadata.summary.failedValidations) {
metaLines.push(`< strong > Failed Validations:< / strong > ${scanMetadata.summary.failedValidations}`);
}
if (scanMetadata.summary & & scanMetadata.summary.skippedValidations) {
metaLines.push(`< strong > Skipped Validations:< / strong > ${scanMetadata.summary.skippedValidations}`);
}
if (scanMetadata.summary & & scanMetadata.summary.blobsScanned) {
metaLines.push(`< strong > Blobs Scanned:< / strong > ${scanMetadata.summary.blobsScanned}`);
}
if (scanMetadata.summary & & scanMetadata.summary.bytesScanned) {
metaLines.push(`< strong > Bytes Scanned:< / strong > ${escapeHtml(formatBytes(scanMetadata.summary.bytesScanned))}`);
}
if (scanMetadata.summary & & scanMetadata.summary.scanDurationSeconds) {
metaLines.push(`< strong > Scan Duration:< / strong > ${escapeHtml(scanMetadata.summary.scanDurationSeconds.toFixed(3) + "s")}`);
}
const cliArgsHtml = scanMetadata.commandLineArgs & & scanMetadata.commandLineArgs.length
? `< div style = "margin-bottom:16px;" > < div style = "font-size:12px;font-weight:700;color:#0f172a;margin-bottom:6px;" > Sanitized command-line arguments< / div > < pre style = "margin:0;padding:10px 12px;background:#0f172a;color:#e2e8f0;border-radius:8px;white-space:pre-wrap;word-break:break-word;font-size:11px;line-height:1.5;" > ${escapeHtml(scanMetadata.commandLineArgs.join(" "))}< / pre > < / div > `
: "";
2026-02-13 18:19:18 -08:00
const metaHtml = metaLines.length
2026-02-15 14:29:42 -08:00
? `< div style = "display:flex;flex-wrap:wrap;gap:16px;padding:10px 14px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;font-size:12px;margin-bottom:16px;" > ${metaLines.join('< span style = "color:#cbd5e1;" > |< / span > ')}< / div > ${cliArgsHtml}`
2025-12-17 11:57:35 -08:00
: "";
2026-02-13 18:19:18 -08:00
// Executive summary
let execSummary = "";
if (counts.active > 0) {
execSummary = `< div style = "padding:12px 16px;background:#fef2f2;border:1px solid #fecaca;border-radius:6px;font-size:13px;color:#991b1b;line-height:1.6;margin-bottom:16px;" >
< strong > Action Required:< / strong > This scan found < strong > ${counts.active} active credential${counts.active !== 1 ? "s" : ""}< / strong > that validated successfully.
These credentials are live and should be rotated or revoked immediately.
${highConfidence > 0 ? `${highConfidence} finding${highConfidence !== 1 ? "s" : ""} ${highConfidence !== 1 ? "are" : "is"} high confidence.` : ""}
${hasAccess ? `Access mapping identified ${accessMap.length} identit${accessMap.length !== 1 ? "ies" : "y"} with resource access.` : ""}
< / div > `;
} else if (baseFindings.length > 0) {
execSummary = `< div style = "padding:12px 16px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;font-size:13px;color:#92400e;line-height:1.6;margin-bottom:16px;" >
< strong > Review Recommended:< / strong > This scan found < strong > ${baseFindings.length} finding${baseFindings.length !== 1 ? "s" : ""}< / strong > .
${counts.inactive > 0 ? `${counts.inactive} credential${counts.inactive !== 1 ? "s were" : " was"} inactive at scan time.` : ""}
${counts.not_attempted > 0 ? `${counts.not_attempted} ${counts.not_attempted !== 1 ? "were" : "was"} not validated.` : ""}
2026-04-15 17:13:10 -07:00
${counts.canary > 0 ? `${counts.canary} ${counts.canary !== 1 ? "were" : "was"} canary token${counts.canary !== 1 ? "s" : ""} (skipped).` : ""}
2026-04-16 06:44:12 -07:00
${scanMetadata.imported ? `Imported reports do not include Kingfisher access-map data or command workflows.` : ""}
2026-02-13 18:19:18 -08:00
< / div > `;
} else {
execSummary = `< div style = "padding:12px 16px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:6px;font-size:13px;color:#166534;line-height:1.6;margin-bottom:16px;" >
< strong > Clean Scan:< / strong > No findings detected in this scan.
< / div > `;
}
2025-12-17 11:57:35 -08:00
2026-02-13 18:19:18 -08:00
const pdfHtml = `<!doctype html>
< html >
< head >
< meta charset = "UTF-8" >
< title > Kingfisher Scan Report< / title >
< style >
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 24px; color: #0f172a; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 20px 0 10px; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
.report-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 3px solid #0e7c56; }
.report-header h1 { color: #0e7c56; }
.report-date { color: #4b5563; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 18px; }
.stat { padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; background: #f8fafc; }
.stat .label { font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.04em; font-weight: 600; }
.stat .value { font-size: 22px; font-weight: 800; margin-top: 4px; }
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: #f1f5f9; font-weight: 700; color: #374151; }
.section { margin-bottom: 24px; page-break-inside: avoid; }
.chart-wrapper { display: flex; gap: 14px; align-items: center; margin-top: 12px; }
.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; }
@media print { body { padding: 12px; } .no-print { display: none; } }
< / style >
< / head >
< body >
< div class = "report-header" >
< h1 > Kingfisher Scan Report< / h1 >
< div class = "report-date" > ${escapeHtml(new Date().toLocaleString())}< br > Scope: ${escapeHtml(scopeLabel)}< / div >
< / div >
${metaHtml}
${execSummary}
< div class = "section" >
< h2 > Summary< / 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< / div > < div class = "value" style = "color:#dc2626;" > ${counts.active || 0}< / div > < / div >
< div class = "stat" > < div class = "label" > Inactive< / div > < div class = "value" style = "color:#f97316;" > ${counts.inactive || 0}< / div > < / div >
< div class = "stat" > < div class = "label" > Not Validated< / div > < div class = "value" > ${counts.not_attempted || 0}< / div > < / div >
< div class = "stat" > < div class = "label" > Identities< / div > < div class = "value" > ${(accessMap || []).length}< / div > < / div >
< / div >
< div class = "chart-wrapper" >
${statusImage ? `< img src = "${statusImage}" alt = "Status chart" style = "max-width:240px; 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 >
2026-04-15 17:13:10 -07:00
< span > < span class = "dot" style = "background:#a855f7" > < / span > Canary Token: ${counts.canary || 0}< / span >
2026-02-13 18:19:18 -08:00
< span > < span class = "dot" style = "background:#9ca3af" > < / span > Unknown: ${counts.unknown || 0}< / span >
< / div >
< / div >
< / div >
${Object.keys(ruleSummary).length > 1 ? `
< div class = "section" >
< h2 > Findings by Rule< / h2 >
< table style = "max-width:500px;" >
< thead > < tr > < th > Rule< / th > < th > Count< / th > < th > Active< / th > < / tr > < / thead >
< tbody > ${ruleSummaryRows}< / tbody >
< / table >
< / div >
` : ""}
< div class = "section" >
< h2 > Detailed Findings< / h2 >
< table >
< thead >
< tr >
< th style = "width:15%;" > Rule< / th >
2026-04-24 00:14:56 -07:00
< th style = "width:10%;" > Tool< / th >
2026-02-13 18:19:18 -08:00
< th style = "width:10%;" > Fingerprint< / th >
2026-04-24 00:14:56 -07:00
< th style = "width:18%;" > File Path< / th >
2026-02-13 18:19:18 -08:00
< th style = "width:12%;" > Status< / th >
< th style = "width:9%;" > Confidence< / th >
< th style = "width:5%;" > Line< / th >
2026-04-24 00:14:56 -07:00
< th style = "width:21%;" > Git URL< / th >
2026-02-13 18:19:18 -08:00
< / tr >
< / thead >
< tbody >
${findingsHtml}
< / tbody >
< / table >
< / div >
2026-01-14 21:45:55 -08:00
2026-02-13 18:19:18 -08:00
< div class = "no-print" style = "margin-top:24px;text-align:center;" >
< button onclick = "window.print()" style = "padding:10px 24px;font-size:14px;font-weight:600;background:#0e7c56;color:#fff;border:none;border-radius:8px;cursor:pointer;" >
Print / Save as PDF
< / button >
< / div >
< / body >
< / html > `;
2026-01-14 21:45:55 -08:00
const win = window.open("", "_blank", "width=1200,height=900");
if (!win) {
2026-02-13 18:19:18 -08:00
alert("Please allow pop-ups to view the Scan report.");
2026-01-14 21:45:55 -08:00
return;
}
win.document.write(pdfHtml);
win.document.close();
win.focus();
setTimeout(() => win.print(), 400);
}
function generateAccessMapReport() {
2026-02-13 18:19:18 -08:00
if (!Array.isArray(accessMap) || accessMap.length === 0) {
alert("No Access Map entries are available. Run a scan with --access-map first.");
2026-01-14 21:45:55 -08:00
return;
}
2026-02-13 18:19:18 -08:00
// Compute stats
let totalResources = 0;
let totalPermissions = 0;
const allPermSet = new Set();
const providerCounts = {};
accessMap.forEach((entry) => {
const prov = (entry.provider || "unknown").toUpperCase();
providerCounts[prov] = (providerCounts[prov] || 0) + 1;
(entry.groups || []).forEach((g) => {
totalResources += (g.resources || []).length;
(g.permissions || []).forEach((p) => allPermSet.add(p));
});
});
totalPermissions = allPermSet.size;
// Build per-identity cards
const identityCards = accessMap.map((entry, idx) => {
const groups = Array.isArray(entry.groups) ? entry.groups : [];
const findingId = getFindingIdFromAccessEntry(entry);
const tokenName = getTokenNameFromAccessEntry(entry);
const userId = getUserIdFromAccessEntry(entry);
const provider = (entry.provider || "Unknown").toUpperCase();
// Token details
let tokenHtml = "";
if (entry.token_details) {
const td = entry.token_details;
const fields = [];
if (td.username) fields.push(["Username", td.username]);
if (td.token_type) fields.push(["Token Type", td.token_type]);
if (td.account_type) fields.push(["Account Type", td.account_type]);
if (td.name) fields.push(["Token Name", td.name]);
if (td.email) fields.push(["Email", td.email]);
if (td.created_at) fields.push(["Created", td.created_at]);
if (td.expires_at) fields.push(["Expires", td.expires_at]);
if (td.last_used_at) fields.push(["Last Used", td.last_used_at]);
if (Array.isArray(td.scopes) & & td.scopes.length) fields.push(["Scopes", td.scopes.join(", ")]);
if (fields.length) {
tokenHtml = `< div style = "margin-top:10px;padding:10px 14px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;" >
< div style = "font-weight:700;font-size:12px;margin-bottom:6px;color:#0369a1;" > Token Details< / div >
< div style = "display:grid;grid-template-columns:1fr 1fr;gap:4px 16px;font-size:12px;" >
${fields.map(([k, v]) => `< div > < strong style = "color:#475569;" > ${escapeHtml(k)}:< / strong > ${escapeHtml(String(v))}< / div > `).join("")}
< / div >
< / div > `;
}
}
2026-01-14 21:45:55 -08:00
2026-02-13 18:19:18 -08:00
// Resource groups
const groupsHtml = groups.map((g) => {
const resources = g.resources || [];
const perms = g.permissions || [];
const resRows = resources.length
? resources.map((r) => `< div style = "padding:4px 8px;background:#fff;border:1px solid #e5e7eb;border-radius:4px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;word-break:break-all;" > ${escapeHtml(String(r))}< / div > `).join("")
: '< div style = "color:#9ca3af;font-size:12px;font-style:italic;" > No specific resources< / div > ';
const permTags = perms.length
? perms.map((p) => `< span style = "display:inline-block;padding:2px 7px;border-radius:4px;font-size:11px;font-weight:600;background:#ecfdf5;color:#16a34a;border:1px solid #bbf7d0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;" > ${escapeHtml(p)}< / span > `).join(" ")
: '< span style = "color:#9ca3af;font-size:12px;font-style:italic;" > No specific permissions< / span > ';
return `< div style = "margin-top:10px;" >
< div style = "font-weight:600;font-size:12px;color:#374151;margin-bottom:6px;" > Resources (${resources.length})< / div >
< div style = "display:flex;flex-direction:column;gap:4px;" > ${resRows}< / div >
< div style = "font-weight:600;font-size:12px;color:#374151;margin:10px 0 6px;" > Permissions (${perms.length})< / div >
< div style = "display:flex;flex-wrap:wrap;gap:4px;" > ${permTags}< / div >
< / div > `;
}).join("");
// Meta line
const metaParts = [];
if (findingId) metaParts.push(`< strong > Fingerprint:< / strong > < code style = "font-size:10px;" > ${escapeHtml(findingId.substring(0, 16))}< / code > `);
if (tokenName) metaParts.push(`< strong > Token:< / strong > ${escapeHtml(tokenName)}`);
if (userId) metaParts.push(`< strong > User:< / strong > ${escapeHtml(userId)}`);
const metaLine = metaParts.length
? `< div style = "font-size:12px;color:#64748b;margin-top:6px;" > ${metaParts.join(" · ")}< / div > `
: "";
return `< div style = "page-break-inside:avoid;border:1px solid #d1d5db;border-radius:8px;padding:16px;margin-bottom:12px;background:#fff;" >
< div style = "display:flex;align-items:center;gap:10px;margin-bottom:4px;" >
< span style = "font-size:16px;" > 👤< / span >
< div >
< div style = "font-size:15px;font-weight:700;" > ${escapeHtml(entry.account || "(identity)")}< / div >
< span style = "display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:700;background:#e0f2fe;color:#075985;margin-top:2px;" > ${escapeHtml(provider)}< / span >
< / div >
2026-01-14 21:45:55 -08:00
< / div >
2026-02-13 18:19:18 -08:00
${metaLine}
${tokenHtml}
${groupsHtml}
< / div > `;
}).join("");
// Provider summary
const providerSummaryRows = Object.entries(providerCounts)
.sort((a, b) => b[1] - a[1])
.map(([p, c]) => `< tr > < td > ${escapeHtml(p)}< / td > < td > ${c}< / td > < / tr > `)
.join("");
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: 0; padding: 24px; color: #0f172a; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 20px 0 10px; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
.report-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 3px solid #0e7c56; }
.report-header h1 { color: #0e7c56; }
.report-date { color: #4b5563; font-size: 13px; text-align: right; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 18px; }
.stat { padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; background: #f8fafc; }
.stat .label { font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.04em; font-weight: 600; }
.stat .value { font-size: 22px; font-weight: 800; margin-top: 4px; }
table { border-collapse: collapse; margin-top: 8px; }
th, td { border: 1px solid #d1d5db; padding: 6px 12px; font-size: 12px; text-align: left; }
th { background: #f1f5f9; font-weight: 700; color: #374151; }
.section { margin-bottom: 24px; }
code { background: #f1f5f9; padding: 1px 5px; border-radius: 4px; font-size: 11px; }
@media print { body { padding: 12px; } .no-print { display: none; } }
< / style >
< / head >
< body >
< div class = "report-header" >
< h1 > Kingfisher Access Map Report< / h1 >
< div class = "report-date" > ${escapeHtml(new Date().toLocaleString())}< / div >
< / div >
< div class = "section" >
< h2 > Summary< / h2 >
< div class = "grid" >
< div class = "stat" > < div class = "label" > Identities< / div > < div class = "value" > ${accessMap.length}< / div > < / div >
< div class = "stat" > < div class = "label" > Total Resources< / div > < div class = "value" > ${totalResources}< / div > < / div >
< div class = "stat" > < div class = "label" > Unique Permissions< / div > < div class = "value" > ${totalPermissions}< / div > < / div >
< div class = "stat" > < div class = "label" > Providers< / div > < div class = "value" > ${Object.keys(providerCounts).length}< / div > < / div >
< / div >
${Object.keys(providerCounts).length > 1 ? `
< table >
< thead > < tr > < th > Provider< / th > < th > Identities< / th > < / tr > < / thead >
< tbody > ${providerSummaryRows}< / tbody >
< / table >
` : ""}
< / div >
< div class = "section" >
< h2 > Identities & Access< / h2 >
${identityCards}
< / div >
< div class = "no-print" style = "margin-top:24px;text-align:center;" >
< button onclick = "window.print()" style = "padding:10px 24px;font-size:14px;font-weight:600;background:#0e7c56;color:#fff;border:none;border-radius:8px;cursor:pointer;" >
Print / Save as PDF
< / button >
< / div >
< / body >
< / html > `;
2025-12-17 11:57:35 -08:00
const win = window.open("", "_blank", "width=1200,height=900");
if (!win) {
2026-02-13 18:19:18 -08:00
alert("Please allow pop-ups to view the Access Map report.");
2025-12-17 11:57:35 -08:00
return;
}
win.document.write(pdfHtml);
win.document.close();
win.focus();
setTimeout(() => win.print(), 400);
}
2025-12-04 22:02:30 -08:00
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 =
2026-04-23 14:42:10 -07:00
'< tr > < td colspan = "6" style = "text-align:center; padding:24px; color:var(--text-muted);" > No findings match your filters.< / td > < / tr > ';
2025-12-04 22:02:30 -08:00
} 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";
2026-04-23 14:42:10 -07:00
const enrichedChip = finding.kingfisher_enrichment
? `< span class = "chip-enriched" title = "Enriched with Kingfisher validation and revoke data" > Enriched< / span > `
: "";
const sourceTool = getFindingSourceTool(finding);
const sourceBadge = `< span class = "badge ${getSourceBadgeClass(sourceTool)}" > ${escapeHtml(getSourceDisplayName(sourceTool))}< / span > `;
2025-12-04 22:02:30 -08:00
const tr = document.createElement("tr");
tr.innerHTML = `
< td >
2026-04-23 14:42:10 -07:00
< div style = "font-weight:600" > ${escapeHtml(ruleName)}${enrichedChip}< / div >
2025-12-04 22:02:30 -08:00
< div style = "font-size:11px; color:var(--text-muted)" > ${escapeHtml(rule.id || "")}< / div >
< / td >
2026-04-23 14:42:10 -07:00
< td > ${sourceBadge}< / td >
2025-12-04 22:02:30 -08:00
< 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 || {};
2026-04-16 06:44:12 -07:00
const importInfo = finding.viewer_import & & typeof finding.viewer_import === "object" ? finding.viewer_import : null;
2025-12-04 22:02:30 -08:00
const panel = document.getElementById("finding-detail");
panel.classList.remove("hidden");
panel.scrollIntoView({ behavior: "smooth", block: "start" });
document.getElementById("fd-rule-id").textContent = rule.id || "";
2026-01-14 17:19:02 -08:00
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);
}
}
2025-12-04 22:02:30 -08:00
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;
2026-01-15 10:41:55 -08:00
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 = "";
}
}
2026-01-14 21:45:55 -08:00
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 > `;
}
2026-04-16 06:44:12 -07:00
const sourceEl = document.getElementById("fd-source");
if (sourceEl) {
sourceEl.textContent = importInfo & & importInfo.source_tool ? importInfo.source_tool : (scanMetadata.reportSource || "Kingfisher");
}
2025-12-04 22:02:30 -08:00
const path = finding.path || "";
2025-12-17 11:57:35 -08:00
if (fdPathInput) {
fdPathInput.value = path || "—";
fdPathInput.style.height = "auto";
fdPathInput.style.height = Math.min(fdPathInput.scrollHeight, 260) + "px";
}
2025-12-04 22:02:30 -08:00
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 || "";
2026-04-16 06:44:12 -07:00
const importNoteEl = document.getElementById("fd-import-note");
if (importNoteEl) {
if (importInfo & & scanMetadata.imported) {
const toolName = escapeHtml(importInfo.source_tool || "Imported report");
const note = scanMetadata.importNotes & & scanMetadata.importNotes.length
? scanMetadata.importNotes.join(" ")
: "This finding was imported into the viewer and does not include Kingfisher-native access-map or command metadata.";
importNoteEl.classList.remove("hidden");
importNoteEl.innerHTML = `< strong > ${toolName} import:< / strong > ${escapeHtml(note)}`;
} else {
importNoteEl.classList.add("hidden");
importNoteEl.innerHTML = "";
}
}
2026-04-23 14:42:10 -07:00
renderKingfisherEnrichment(finding);
2025-12-04 22:02:30 -08:00
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 = "";
}
2026-01-31 22:52:57 -08:00
2026-01-31 22:58:53 -08:00
// Validate command section
const validateBox = document.getElementById("fd-validate-box");
const validateCmd = document.getElementById("fd-validate-cmd");
const validateCopyBtn = document.getElementById("fd-validate-copy");
2026-04-16 06:44:12 -07:00
if (finding.validate_command & & (!importInfo || scanMetadata.capabilities.validateCommandSupported)) {
2026-01-31 22:58:53 -08:00
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 = "";
}
2026-01-31 22:52:57 -08:00
// Revoke command section
const revokeBox = document.getElementById("fd-revoke-box");
const revokeCmd = document.getElementById("fd-revoke-cmd");
const revokeCopyBtn = document.getElementById("fd-revoke-copy");
2026-04-16 06:44:12 -07:00
if (finding.revoke_command & & (!importInfo || scanMetadata.capabilities.revokeCommandSupported)) {
2026-01-31 22:52:57 -08:00
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 = "";
}
2026-02-13 18:19:18 -08:00
// Blast radius
currentDetailFinding = f;
renderBlastRadius(f);
2025-12-04 22:02:30 -08:00
}
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;
2026-04-24 00:14:56 -07:00
const repoObj = finding.git_metadata.repository || {};
const repoUrl = repoObj.url || "";
const commitId = finding.git_metadata.commit & & finding.git_metadata.commit.id
? finding.git_metadata.commit.id
: "";
return buildGitFileUrl(repoUrl, commitId, finding.path || "", finding.line);
}
function getFindingSourceDisplayName(finding) {
return getSourceDisplayName(getFindingSourceTool(finding || {}));
2025-12-04 22:02:30 -08:00
}
2026-04-23 14:42:10 -07:00
function wireKfEnrichmentCopyButton(wrapId, codeId, btnId, cmd) {
const wrap = document.getElementById(wrapId);
const code = document.getElementById(codeId);
const btn = document.getElementById(btnId);
if (!wrap || !code || !btn) return;
if (!cmd) {
wrap.classList.add("hidden");
code.textContent = "";
btn.onclick = null;
return;
}
wrap.classList.remove("hidden");
code.textContent = cmd;
btn.onclick = async () => {
try {
await navigator.clipboard.writeText(cmd);
btn.classList.add("copied");
const label = btn.querySelector("span");
if (label) label.textContent = "Copied!";
setTimeout(() => {
btn.classList.remove("copied");
const l = btn.querySelector("span");
if (l) l.textContent = "Copy";
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
}
function renderKingfisherEnrichment(finding) {
const box = document.getElementById("fd-kf-enrichment");
if (!box) return;
const enr = finding & & finding.kingfisher_enrichment;
if (!enr) {
box.classList.add("hidden");
return;
}
box.classList.remove("hidden");
const strengthEl = document.getElementById("fd-kf-enrichment-strength");
if (strengthEl) {
strengthEl.textContent = enr.match_strength === "commit"
? "Strong match (commit + file + line)"
: "Partial match (file + line only)";
}
const statusRaw = enr.validation & & enr.validation.status ? String(enr.validation.status) : "Unknown";
const normalized = normalizeValidationStatus(statusRaw);
const badgeClass = normalized === "active" ? "active" : normalized === "inactive" ? "inactive" : "unknown";
const validationEl = document.getElementById("fd-kf-validation");
if (validationEl) {
validationEl.innerHTML = `< span class = "status-badge ${badgeClass}" > ${escapeHtml(statusRaw)}< / span > `;
}
const resEl = document.getElementById("fd-kf-validation-res");
if (resEl) {
if (enr.validation & & enr.validation.response) {
resEl.classList.remove("hidden");
resEl.textContent = enr.validation.response;
} else {
resEl.classList.add("hidden");
resEl.textContent = "";
}
}
wireKfEnrichmentCopyButton("fd-kf-validate-wrap", "fd-kf-validate-cmd", "fd-kf-validate-copy", enr.validate_command);
wireKfEnrichmentCopyButton("fd-kf-revoke-wrap", "fd-kf-revoke-cmd", "fd-kf-revoke-copy", enr.revoke_command);
const matchKeyEl = document.getElementById("fd-kf-match-key");
if (matchKeyEl) {
matchKeyEl.textContent = enr.match_strength === "commit"
? "commit + file + line"
: "file + line (no commit context)";
}
const ruleEl = document.getElementById("fd-kf-rule");
if (ruleEl) {
ruleEl.textContent = enr.kingfisher_rule_id || enr.kingfisher_rule_name || "(unknown rule)";
}
}
2026-02-13 18:19:18 -08:00
function getAccessMapForFingerprint(fingerprint) {
if (!fingerprint || !Array.isArray(accessMap)) return [];
return accessMap.filter((entry) => entry.fingerprint === fingerprint);
}
function countResources(entries) {
let total = 0;
entries.forEach((entry) => {
(entry.groups || []).forEach((group) => {
total += (group.resources || []).length;
});
});
return total;
}
function countPermissions(entries) {
const permSet = new Set();
entries.forEach((entry) => {
(entry.groups || []).forEach((group) => {
(group.permissions || []).forEach((p) => permSet.add(p));
});
});
return permSet.size;
}
function generateRiskRationale(finding, accessEntries) {
const rule = finding.rule || {};
const fd = finding.finding || {};
2026-04-16 06:44:12 -07:00
const importInfo = fd.viewer_import & & typeof fd.viewer_import === "object" ? fd.viewer_import : null;
2026-02-13 18:19:18 -08:00
const statusRaw = fd.validation & & fd.validation.status ? String(fd.validation.status) : "";
const normalizedStatus = normalizeValidationStatus(statusRaw);
const isActive = normalizedStatus === "active";
if (!accessEntries || accessEntries.length === 0) {
2026-04-16 06:44:12 -07:00
if (importInfo & & scanMetadata.imported) {
return {
level: isActive ? "low" : "none",
text: `This finding was imported from ${escapeHtml(importInfo.source_tool || "an external tool")}. Imported reports do not carry Kingfisher access-map data, so blast radius cannot be derived here. Re-scan with < code > kingfisher scan --access-map< / code > for native mapping.`,
};
}
2026-02-13 18:19:18 -08:00
if (isActive) {
return {
level: "medium",
text: `This is an active ${escapeHtml(rule.name || "credential")} found in ${escapeHtml(fd.path || "the codebase")}. No access map data is available, but the credential validated successfully, indicating it grants live access to the target service.`,
};
}
return {
level: "none",
text: "No access map data is linked to this finding. Run a scan with --access-map to map the blast radius of validated credentials.",
};
}
const providers = [...new Set(accessEntries.map((e) => (e.provider || "").toUpperCase()).filter(Boolean))];
const resourceCount = countResources(accessEntries);
const permCount = countPermissions(accessEntries);
const identityCount = accessEntries.length;
// Categorize resources
const resourceTypes = {};
accessEntries.forEach((entry) => {
(entry.groups || []).forEach((group) => {
(group.resources || []).forEach((r) => {
const rStr = String(r);
let type = "resource";
if (/s3|bucket|storage|blob/i.test(rStr)) type = "storage bucket";
else if (/lambda|function|cloud.run/i.test(rStr)) type = "serverless function";
else if (/iam|role|policy/i.test(rStr)) type = "IAM resource";
else if (/secret|vault|kms/i.test(rStr)) type = "secret/key";
else if (/ec2|instance|vm|compute/i.test(rStr)) type = "compute instance";
else if (/dynamodb|database|rds|sql/i.test(rStr)) type = "database";
else if (/repo|repository/i.test(rStr)) type = "repository";
resourceTypes[type] = (resourceTypes[type] || 0) + 1;
});
});
});
const typeDescriptions = Object.entries(resourceTypes)
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => `${count} ${type}${count > 1 ? "s" : ""}`)
.join(", ");
// Determine risk level
let level = "low";
if (isActive & & resourceCount > 10) level = "critical";
else if (isActive & & resourceCount > 3) level = "high";
else if (isActive & & resourceCount > 0) level = "medium";
else if (!isActive & & resourceCount > 0) level = "low";
// Check for dangerous permissions
const allPerms = [];
accessEntries.forEach((e) => (e.groups || []).forEach((g) => allPerms.push(...(g.permissions || []))));
const dangerousPerms = allPerms.filter((p) =>
/admin|full.?control|owner|delete|write.*all|manage|superuser|\*/i.test(p)
);
if (isActive & & dangerousPerms.length > 0) level = "critical";
// Check for token details with scopes
const scopeInfo = [];
accessEntries.forEach((entry) => {
if (entry.token_details & & Array.isArray(entry.token_details.scopes)) {
scopeInfo.push(...entry.token_details.scopes);
}
});
let text = "";
if (isActive) {
text = `This active ${escapeHtml(rule.name || "credential")} grants access to < strong > ${resourceCount} resource${resourceCount !== 1 ? "s" : ""}< / strong > across < strong > ${identityCount} identit${identityCount !== 1 ? "ies" : "y"}< / strong > on ${providers.join(", ")}.`;
if (typeDescriptions) {
text += ` Accessible resources include ${typeDescriptions}.`;
}
if (dangerousPerms.length > 0) {
text += ` < strong > Warning:< / strong > This credential has elevated privileges including ${dangerousPerms.slice(0, 3).map((p) => "< code > " + escapeHtml(p) + "< / code > ").join(", ")}${dangerousPerms.length > 3 ? ` and ${dangerousPerms.length - 3} more` : ""}.`;
}
if (scopeInfo.length > 0) {
text += ` Token scopes: ${scopeInfo.slice(0, 5).map((s) => escapeHtml(s)).join(", ")}${scopeInfo.length > 5 ? ` (+${scopeInfo.length - 5} more)` : ""}.`;
}
} else {
text = `This ${escapeHtml(rule.name || "credential")} has access map data showing < strong > ${resourceCount} resource${resourceCount !== 1 ? "s" : ""}< / strong > that would be accessible if the credential were active.`;
if (typeDescriptions) {
text += ` Resources include ${typeDescriptions}.`;
}
}
return { level, text };
}
function renderBlastRadius(f) {
const finding = f.finding || {};
const fingerprint = finding.fingerprint || "";
const blastSection = document.getElementById("fd-blast-radius");
const blastEntries = document.getElementById("fd-blast-entries");
const blastCount = document.getElementById("fd-blast-count");
const rationaleEl = document.getElementById("fd-risk-rationale");
const entries = getAccessMapForFingerprint(fingerprint);
const { level, text } = generateRiskRationale(f, entries);
// Always show the blast radius section
blastSection.classList.remove("hidden");
// Risk rationale
rationaleEl.className = "risk-rationale " + level;
rationaleEl.innerHTML = text;
// Count
const resourceTotal = countResources(entries);
blastCount.textContent = entries.length > 0
? `${entries.length} identit${entries.length !== 1 ? "ies" : "y"}, ${resourceTotal} resource${resourceTotal !== 1 ? "s" : ""}`
: "No access data";
// Render entries
blastEntries.innerHTML = "";
if (entries.length === 0) return;
entries.forEach((entry) => {
const identity = document.createElement("div");
identity.className = "blast-identity";
const header = document.createElement("div");
header.className = "blast-identity__header";
const providerBadge = document.createElement("span");
providerBadge.className = "badge " + providerBadgeClass(entry.provider);
providerBadge.textContent = (entry.provider || "").toUpperCase();
header.appendChild(providerBadge);
const nameSpan = document.createElement("span");
nameSpan.textContent = entry.account || "(identity)";
header.appendChild(nameSpan);
// Token info summary
if (entry.token_details) {
const tokenInfo = document.createElement("span");
tokenInfo.style.fontSize = "12px";
tokenInfo.style.color = "var(--text-muted)";
const parts = [];
if (entry.token_details.username) parts.push(entry.token_details.username);
if (entry.token_details.token_type) parts.push(entry.token_details.token_type);
if (parts.length) tokenInfo.textContent = "(" + parts.join(" · ") + ")";
header.appendChild(tokenInfo);
}
identity.appendChild(header);
const resList = document.createElement("ul");
resList.className = "blast-resource-list";
(entry.groups || []).forEach((group) => {
const resources = group.resources || [];
const perms = group.permissions || [];
if (resources.length === 0 & & perms.length > 0) {
const item = document.createElement("li");
item.className = "blast-resource-item";
const name = document.createElement("div");
name.className = "blast-resource-name";
name.textContent = "Project-wide / Unscoped";
item.appendChild(name);
const permDiv = document.createElement("div");
permDiv.className = "blast-perms";
perms.forEach((p) => {
const tag = document.createElement("span");
tag.className = "blast-perm-tag";
tag.textContent = p;
permDiv.appendChild(tag);
});
item.appendChild(permDiv);
resList.appendChild(item);
}
resources.forEach((resName) => {
const item = document.createElement("li");
item.className = "blast-resource-item";
const name = document.createElement("div");
name.className = "blast-resource-name";
name.textContent = String(resName);
// Add console link if available
const { resourceType, resourceName } = extractResourceParts(String(resName));
const consoleLink = buildResourceConsoleLink(entry.provider, resourceType, resourceName);
if (consoleLink) {
const a = document.createElement("a");
a.href = consoleLink;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = " (open in console)";
a.style.fontSize = "11px";
a.style.fontFamily = "inherit";
a.style.color = "var(--brand)";
name.appendChild(a);
}
item.appendChild(name);
if (perms.length > 0) {
const permDiv = document.createElement("div");
permDiv.className = "blast-perms";
perms.forEach((p) => {
const tag = document.createElement("span");
tag.className = "blast-perm-tag";
tag.textContent = p;
permDiv.appendChild(tag);
});
item.appendChild(permDiv);
}
resList.appendChild(item);
});
});
identity.appendChild(resList);
blastEntries.appendChild(identity);
});
}
function exportSingleFindingRiskReport(f) {
const rule = f.rule || {};
const finding = f.finding || {};
const fingerprint = finding.fingerprint || "";
const entries = getAccessMapForFingerprint(fingerprint);
const { level, text: rationaleText } = generateRiskRationale(f, entries);
const statusRaw = finding.validation & & finding.validation.status ? String(finding.validation.status) : "Unknown";
const gitUrl = getFileUrlFromFinding(finding);
2026-04-24 00:14:56 -07:00
const sourceTool = getFindingSourceDisplayName(finding);
2026-02-13 18:19:18 -08:00
const levelColors = {
critical: { bg: "#fef2f2", border: "#fca5a5", text: "#991b1b" },
high: { bg: "#fff7ed", border: "#fdba74", text: "#9a3412" },
medium: { bg: "#fffbeb", border: "#fde68a", text: "#92400e" },
low: { bg: "#f0fdf4", border: "#bbf7d0", text: "#166534" },
none: { bg: "#f8fafc", border: "#e2e8f0", text: "#475569" },
};
const colors = levelColors[level] || levelColors.none;
let accessHtml = "";
if (entries.length > 0) {
accessHtml = entries.map((entry) => {
const groups = (entry.groups || []).map((g) => {
const resList = (g.resources || []).map((r) => `< li > < code > ${escapeHtml(String(r))}< / code > < / li > `).join("");
const permList = (g.permissions || []).map((p) => `< span style = "display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:#ecfdf5;color:#16a34a;border:1px solid #bbf7d0;margin:2px;" > ${escapeHtml(p)}< / span > `).join(" ");
return `< div style = "margin-bottom:12px;" >
< div style = "font-weight:600;margin-bottom:4px;" > Resources:< / div >
< ul style = "margin:0;padding-left:20px;" > ${resList || "< li > No specific resources< / li > "}< / ul >
< div style = "font-weight:600;margin:8px 0 4px;" > Permissions:< / div >
< div > ${permList || "No specific permissions"}< / div >
< / div > `;
}).join("");
let tokenHtml = "";
if (entry.token_details) {
const td = entry.token_details;
const fields = [];
if (td.username) fields.push(["Username", td.username]);
if (td.token_type) fields.push(["Token Type", td.token_type]);
if (td.account_type) fields.push(["Account Type", td.account_type]);
if (td.name) fields.push(["Token Name", td.name]);
if (td.email) fields.push(["Email", td.email]);
if (td.created_at) fields.push(["Created", td.created_at]);
if (td.expires_at) fields.push(["Expires", td.expires_at]);
if (td.last_used_at) fields.push(["Last Used", td.last_used_at]);
if (Array.isArray(td.scopes) & & td.scopes.length) fields.push(["Scopes", td.scopes.join(", ")]);
if (fields.length) {
tokenHtml = `< div style = "margin-top:10px;padding:10px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;" >
< div style = "font-weight:700;font-size:13px;margin-bottom:6px;" > Token Details< / div >
${fields.map(([k, v]) => `< div style = "font-size:12px;" > < strong > ${escapeHtml(k)}:< / strong > ${escapeHtml(String(v))}< / div > `).join("")}
< / div > `;
}
}
return `< div style = "border:1px solid #d1d5db;border-radius:8px;padding:14px;margin-bottom:10px;background:#f8fafc;" >
< div style = "display:flex;align-items:center;gap:8px;margin-bottom:10px;" >
< span style = "display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:700;background:#e0f2fe;color:#075985;" > ${escapeHtml((entry.provider || "").toUpperCase())}< / span >
< strong > ${escapeHtml(entry.account || "(identity)")}< / strong >
< / div >
${groups}
${tokenHtml}
< / div > `;
}).join("");
}
const reportHtml = `<!doctype html>
< html >
< head >
< meta charset = "UTF-8" >
< title > Risk Report: ${escapeHtml(rule.name || rule.id || "Finding")}< / title >
< style >
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 24px; color: #0f172a; max-width: 900px; margin: 0 auto; }
h1 { font-size: 22px; margin: 0 0 6px; }
h2 { font-size: 17px; margin: 20px 0 10px; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
.meta { color: #4b5563; font-size: 13px; margin-bottom: 20px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 16px; }
.detail-item { padding: 8px 12px; background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 6px; }
.detail-item label { display: block; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.04em; font-weight: 600; }
.detail-item div { font-size: 14px; font-weight: 500; margin-top: 2px; word-break: break-all; }
.snippet { background: #1e293b; color: #e2e8f0; padding: 14px; border-radius: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; margin: 10px 0; }
.rationale { padding: 14px 18px; border-radius: 8px; font-size: 14px; line-height: 1.7; margin-bottom: 16px; }
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
@media print { body { padding: 12px; } .no-print { display: none; } }
< / style >
< / head >
< body >
< h1 > Risk Report: ${escapeHtml(rule.name || rule.id || "")}< / h1 >
< div class = "meta" > Generated ${escapeHtml(new Date().toLocaleString())} · Fingerprint: ${escapeHtml(fingerprint)}< / div >
< h2 > Risk Assessment< / h2 >
< div class = "rationale" style = "background:${colors.bg};border:1px solid ${colors.border};color:${colors.text};" >
< strong style = "text-transform:uppercase;" > ${escapeHtml(level)} risk< / strong > < br >
${rationaleText}
< / div >
< h2 > Finding Details< / h2 >
< div class = "detail-grid" >
< div class = "detail-item" > < label > Rule< / label > < div > ${escapeHtml(rule.name || "")} (${escapeHtml(rule.id || "")})< / div > < / div >
< div class = "detail-item" > < label > Validation< / label > < div > ${escapeHtml(statusRaw)}< / div > < / div >
2026-04-24 00:14:56 -07:00
< div class = "detail-item" > < label > Tool< / label > < div > ${escapeHtml(sourceTool)}< / div > < / div >
2026-02-13 18:19:18 -08:00
< div class = "detail-item" > < label > Confidence< / label > < div > ${escapeHtml(finding.confidence || "")}< / div > < / div >
< div class = "detail-item" > < label > Entropy< / label > < div > ${finding.entropy != null ? escapeHtml(String(finding.entropy)) : "—"}< / div > < / div >
< div class = "detail-item" style = "grid-column:span 2;" > < label > File Path< / label > < div > ${escapeHtml(finding.path || "")}< / div > < / div >
${gitUrl ? `< div class = "detail-item" style = "grid-column:span 2;" > < label > Git URL< / label > < div > < a href = "${escapeHtml(gitUrl)}" target = "_blank" > ${escapeHtml(gitUrl)}< / a > < / div > < / div > ` : ""}
${finding.git_metadata & & finding.git_metadata.commit ? `< div class = "detail-item" > < label > Commit< / label > < div > ${escapeHtml(finding.git_metadata.commit.id ? finding.git_metadata.commit.id.substring(0, 8) : "")}< / div > < / div > ` : ""}
${finding.git_metadata & & finding.git_metadata.commit & & finding.git_metadata.commit.committer ? `< div class = "detail-item" > < label > Committer< / label > < div > ${escapeHtml(finding.git_metadata.commit.committer.email || "")}< / div > < / div > ` : ""}
< / div >
< div style = "font-weight:600;font-size:12px;text-transform:uppercase;color:#6b7280;margin-bottom:6px;" > Match Snippet< / div >
< div class = "snippet" > ${escapeHtml(finding.snippet || "")}< / div >
${entries.length > 0 ? `
< h2 > Blast Radius (${entries.length} Identit${entries.length !== 1 ? "ies" : "y"}, ${countResources(entries)} Resource${countResources(entries) !== 1 ? "s" : ""})< / h2 >
${accessHtml}
` : `
< h2 > Blast Radius< / h2 >
< p style = "color:#6b7280;" > No access map data available. Run with < code > --access-map< / code > to map credential blast radius.< / p >
`}
< div class = "no-print" style = "margin-top:24px;text-align:center;" >
< button onclick = "window.print()" style = "padding:10px 24px;font-size:14px;font-weight:600;background:#0e7c56;color:#fff;border:none;border-radius:8px;cursor:pointer;" >
Print / Save as PDF
< / button >
< / div >
< / body >
< / html > `;
const win = window.open("", "_blank", "width=1000,height=900");
if (!win) {
alert("Please allow pop-ups to open the risk report.");
return;
}
win.document.write(reportHtml);
win.document.close();
win.focus();
}
function generateRiskReport({ activeOnly = false } = {}) {
if (!findings || findings.length === 0) {
alert("No findings loaded. Load a report first.");
return;
}
let pool = findings.slice();
if (activeOnly) {
pool = pool.filter((f) => {
const fd = f.finding || {};
return normalizeValidationStatus(fd.validation & & fd.validation.status ? fd.validation.status : "") === "active";
});
if (pool.length === 0) {
alert("No active credentials found. Uncheck 'Active credentials only' to include all findings.");
return;
}
}
// Sort: active first, then by rule name
2026-04-15 17:13:10 -07:00
const statusOrder = { active: 0, inactive: 1, canary: 2, not_attempted: 3, unknown: 4 };
2026-02-13 18:19:18 -08:00
const sorted = pool.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(sorted);
const riskScopeLabel = activeOnly ? "Active credentials only" : "All findings";
const findingsWithAccess = sorted.filter((f) => {
const fp = f.finding & & f.finding.fingerprint ? f.finding.fingerprint : "";
return getAccessMapForFingerprint(fp).length > 0;
});
const levelColors = {
critical: { bg: "#fef2f2", border: "#fca5a5", text: "#991b1b" },
high: { bg: "#fff7ed", border: "#fdba74", text: "#9a3412" },
medium: { bg: "#fffbeb", border: "#fde68a", text: "#92400e" },
low: { bg: "#f0fdf4", border: "#bbf7d0", text: "#166534" },
none: { bg: "#f8fafc", border: "#e2e8f0", text: "#475569" },
};
let findingSections = "";
sorted.forEach((f, idx) => {
const rule = f.rule || {};
const finding = f.finding || {};
const fp = finding.fingerprint || "";
const entries = getAccessMapForFingerprint(fp);
const { level, text: rationaleText } = generateRiskRationale(f, entries);
const colors = levelColors[level] || levelColors.none;
const statusRaw = finding.validation & & finding.validation.status ? String(finding.validation.status) : "Unknown";
const gitUrl = getFileUrlFromFinding(finding);
2026-04-24 00:14:56 -07:00
const sourceTool = getFindingSourceDisplayName(finding);
2026-02-13 18:19:18 -08:00
let accessSummary = "";
if (entries.length > 0) {
const resCount = countResources(entries);
const permCount = countPermissions(entries);
accessSummary = entries.map((entry) => {
const resources = [];
(entry.groups || []).forEach((g) => {
(g.resources || []).forEach((r) => resources.push(String(r)));
});
const perms = [];
(entry.groups || []).forEach((g) => (g.permissions || []).forEach((p) => perms.push(p)));
const uniquePerms = [...new Set(perms)];
return `< div style = "margin:8px 0;padding:10px;border:1px solid #e5e7eb;border-radius:6px;background:#f8fafc;" >
< div style = "font-weight:700;font-size:13px;margin-bottom:6px;" >
< span style = "display:inline-block;padding:1px 6px;border-radius:999px;font-size:11px;background:#e0f2fe;color:#075985;margin-right:6px;" > ${escapeHtml((entry.provider || "").toUpperCase())}< / span >
${escapeHtml(entry.account || "")}
< / div >
${resources.length ? `< div style = "font-size:12px;" > < strong > Resources (${resources.length}):< / strong > ${resources.slice(0, 8).map((r) => "< code > " + escapeHtml(r) + "< / code > ").join(", ")}${resources.length > 8 ? ` +${resources.length - 8} more` : ""}< / div > ` : ""}
${uniquePerms.length ? `< div style = "font-size:12px;margin-top:4px;" > < strong > Permissions (${uniquePerms.length}):< / strong > ${uniquePerms.slice(0, 6).map((p) => escapeHtml(p)).join(", ")}${uniquePerms.length > 6 ? ` +${uniquePerms.length - 6} more` : ""}< / div > ` : ""}
< / div > `;
}).join("");
}
findingSections += `
< div style = "page-break-inside:avoid;border:1px solid #d1d5db;border-radius:8px;padding:16px;margin-bottom:16px;background:#fff;" >
< div style = "display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;" >
< div style = "font-size:16px;font-weight:700;" > ${idx + 1}. ${escapeHtml(rule.name || rule.id || "Unknown Rule")}< / div >
< span style = "display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:700;text-transform:uppercase;background:${colors.bg};border:1px solid ${colors.border};color:${colors.text};" > ${escapeHtml(level)}< / span >
< / div >
< div style = "padding:10px 14px;border-radius:6px;background:${colors.bg};border:1px solid ${colors.border};color:${colors.text};font-size:13px;line-height:1.6;margin-bottom:12px;" >
${rationaleText}
< / div >
< div style = "display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:12px;margin-bottom:10px;" >
< div > < strong > Path:< / strong > ${escapeHtml(finding.path || "—")}< / div >
< div > < strong > Line:< / strong > ${finding.line != null ? finding.line : "—"}< / div >
2026-04-24 00:14:56 -07:00
< div > < strong > Tool:< / strong > ${escapeHtml(sourceTool)}< / div >
2026-02-13 18:19:18 -08:00
< div > < strong > Validation:< / strong > ${escapeHtml(statusRaw)}< / div >
< div > < strong > Confidence:< / strong > ${escapeHtml(finding.confidence || "—")}< / div >
${gitUrl ? `< div style = "grid-column:span 2;" > < strong > URL:< / strong > < a href = "${escapeHtml(gitUrl)}" style = "color:#1d4ed8;" > ${escapeHtml(gitUrl)}< / a > < / div > ` : ""}
< / div >
< div style = "background:#1e293b;color:#e2e8f0;padding:10px;border-radius:6px;font-family:monospace;font-size:11px;white-space:pre-wrap;word-break:break-all;margin-bottom:10px;" > ${escapeHtml((finding.snippet || "").substring(0, 300))}< / div >
${accessSummary ? `< div style = "margin-top:8px;" > < div style = "font-weight:700;font-size:13px;margin-bottom:6px;" > Blast Radius< / div > ${accessSummary}< / div > ` : ""}
< / div > `;
});
const reportHtml = `<!doctype html>
< html >
< head >
< meta charset = "UTF-8" >
< title > Kingfisher Risk Report< / title >
< style >
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 24px; color: #0f172a; }
h1 { font-size: 24px; margin: 0 0 4px; }
h2 { font-size: 18px; margin: 24px 0 12px; }
.meta { color: #4b5563; font-size: 13px; margin-bottom: 20px; }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-bottom: 20px; }
.summary-stat { padding: 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #f8fafc; }
.summary-stat .label { font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.04em; font-weight: 600; }
.summary-stat .value { font-size: 24px; font-weight: 800; margin-top: 4px; }
code { background: #f1f5f9; padding: 1px 5px; border-radius: 4px; font-size: 11px; }
@media print { body { padding: 12px; font-size: 11px; } .no-print { display: none; } }
< / style >
< / head >
< body >
< h1 > Kingfisher Risk Report< / h1 >
< div class = "meta" >
Generated ${escapeHtml(new Date().toLocaleString())} ·
Scope: ${escapeHtml(riskScopeLabel)} ·
${sorted.length} finding${sorted.length !== 1 ? "s" : ""} ·
${findingsWithAccess.length} with access map data
< / div >
< div class = "summary-grid" >
< div class = "summary-stat" > < div class = "label" > Total Findings< / div > < div class = "value" > ${sorted.length}< / div > < / div >
< div class = "summary-stat" > < div class = "label" > Active Credentials< / div > < div class = "value" style = "color:#dc2626;" > ${counts.active || 0}< / div > < / div >
< div class = "summary-stat" > < div class = "label" > With Blast Radius< / div > < div class = "value" > ${findingsWithAccess.length}< / div > < / div >
< div class = "summary-stat" > < div class = "label" > Identities Mapped< / div > < div class = "value" > ${(accessMap || []).length}< / div > < / div >
< / div >
< h2 > Findings by Risk< / h2 >
${findingSections}
< div class = "no-print" style = "margin-top:24px;text-align:center;" >
< button onclick = "window.print()" style = "padding:10px 24px;font-size:14px;font-weight:600;background:#0e7c56;color:#fff;border:none;border-radius:8px;cursor:pointer;" > Print / Save as PDF< / button >
< / div >
< / body >
< / html > `;
const win = window.open("", "_blank", "width=1100,height=900");
if (!win) {
alert("Please allow pop-ups to view the risk report.");
return;
}
win.document.write(reportHtml);
win.document.close();
win.focus();
}
2026-04-30 18:11:10 -07:00
// Local-storage key for the "Critical only" preset.
const AM_CRITICAL_KEY = "kf-access-map-critical-only";
function isCriticalOnly() {
try { return localStorage.getItem(AM_CRITICAL_KEY) === "1"; }
catch (_) { return false; }
}
function setCriticalOnly(value) {
try { localStorage.setItem(AM_CRITICAL_KEY, value ? "1" : "0"); }
catch (_) { /* ignore */ }
}
// SEVERITY_ORDER and labels used throughout the renderer.
const SEVERITY_BUCKETS = [
{ key: "admin", label: "Admin", cls: "admin" },
{ key: "privilege_escalation", label: "Privilege Escalation", cls: "privesc" },
{ key: "risky", label: "Risky", cls: "risky" },
{ key: "read_only", label: "Read Only", cls: "readonly" },
];
// Classify a permission list using the identity's permissions_by_severity
// map when available; otherwise treat all permissions as "risky" (unknown
// classification — better than green "read only" by default).
function classifyPermissions(perms, byClass) {
const out = { admin: [], privilege_escalation: [], risky: [], read_only: [] };
if (!perms || !perms.length) return out;
if (!byClass) {
out.risky = perms.slice();
return out;
}
const lookup = new Map();
Object.keys(out).forEach((k) => {
(byClass[k] || []).forEach((p) => lookup.set(String(p).toLowerCase(), k));
});
perms.forEach((p) => {
const cls = lookup.get(String(p).toLowerCase()) || "risky";
out[cls].push(p);
});
return out;
}
// Aggregate per-identity severity counts across all groups.
function summarizeSeverity(entry) {
const byClass = entry.identity & & entry.identity.permissions_by_severity;
if (byClass) {
return {
admin: (byClass.admin || []).length,
privilege_escalation: (byClass.privilege_escalation || []).length,
risky: (byClass.risky || []).length,
read_only: (byClass.read_only || []).length,
};
}
const all = new Set();
(entry.groups || []).forEach((g) => (g.permissions || []).forEach((p) => all.add(p)));
return { admin: 0, privilege_escalation: 0, risky: all.size, read_only: 0 };
}
function entryHasCritical(entry) {
const s = summarizeSeverity(entry);
return s.admin + s.privilege_escalation + s.risky > 0;
}
// Strip read_only permissions from a group when in "Critical only" mode.
function applyCriticalFilter(group, byClass) {
if (!isCriticalOnly()) return group;
const perms = group.permissions || [];
if (!perms.length) return group;
const cls = classifyPermissions(perms, byClass);
const kept = [...cls.admin, ...cls.privilege_escalation, ...cls.risky];
return { ...group, permissions: kept };
}
2025-12-04 22:02:30 -08:00
function renderAccessMapTree(filter = "") {
2026-02-13 18:35:36 -08:00
const list = document.getElementById("am-card-list");
list.innerHTML = "";
2025-12-04 22:02:30 -08:00
const filterLower = (filter || "").toLowerCase();
2025-12-05 22:24:16 -08:00
filteredAccessMapView = buildRenderableAccessMap(filterLower);
2025-12-17 11:57:35 -08:00
const hasAccess = Array.isArray(accessMap) & & accessMap.length > 0;
syncAccessMapUi(hasAccess);
2026-02-13 18:35:36 -08:00
renderAccessMapStats();
2026-04-30 18:11:10 -07:00
syncCriticalToggleVisibility();
// When "Critical only" is active, drop entries that have no admin/privesc/risky perms.
let visible = filteredAccessMapView;
if (isCriticalOnly()) {
visible = visible.filter(entryHasCritical);
}
2025-12-05 22:24:16 -08:00
2026-04-30 18:11:10 -07:00
if (!visible || visible.length === 0) {
2026-02-13 18:35:36 -08:00
list.innerHTML =
2026-04-16 06:44:12 -07:00
`< div style = "padding:32px 16px; text-align:center; color:var(--text-muted); font-size:13px;" > ${getAccessMapEmptyMessage()}< / div > `;
2025-12-04 22:02:30 -08:00
return;
}
2026-04-30 18:11:10 -07:00
// Group by provider.
const byProvider = new Map();
visible.forEach((entry) => {
const prov = String(entry.identity.provider || "unknown").toLowerCase();
if (!byProvider.has(prov)) byProvider.set(prov, []);
byProvider.get(prov).push(entry);
});
// Provider order: those with admin perms first, then by entry count desc, then alpha.
const providerOrder = [...byProvider.keys()].sort((a, b) => {
const aHasAdmin = byProvider.get(a).some((e) => summarizeSeverity(e).admin > 0);
const bHasAdmin = byProvider.get(b).some((e) => summarizeSeverity(e).admin > 0);
if (aHasAdmin !== bHasAdmin) return aHasAdmin ? -1 : 1;
const sizeDiff = byProvider.get(b).length - byProvider.get(a).length;
if (sizeDiff) return sizeDiff;
return a.localeCompare(b);
});
2026-02-13 18:35:36 -08:00
const frag = document.createDocumentFragment();
2026-04-30 18:11:10 -07:00
providerOrder.forEach((prov) => {
const entries = byProvider.get(prov);
frag.appendChild(buildProviderSection(prov, entries));
2026-02-13 18:35:36 -08:00
});
list.appendChild(frag);
}
2026-04-30 18:11:10 -07:00
function buildProviderSection(provider, entries) {
const section = document.createElement("div");
section.className = "am-provider-section";
2026-05-04 19:24:46 -07:00
// In "Critical only" mode, the cards rendered below exclude read-only
// permissions; aggregate the section header stats from the same
// filtered view so resource / unique-perm counts and the severity
// chips match what the user actually sees.
const criticalMode = isCriticalOnly();
2026-04-30 18:11:10 -07:00
let totalRes = 0;
const permSet = new Set();
let admin = 0;
let privesc = 0;
let risky = 0;
entries.forEach((e) => {
2026-05-04 19:24:46 -07:00
const byClass = e.identity & & e.identity.permissions_by_severity;
const groups = criticalMode
? (e.groups || []).map((g) => applyCriticalFilter(g, byClass))
: (e.groups || []);
groups.forEach((g) => {
2026-04-30 18:11:10 -07:00
totalRes += (g.resources || []).length;
(g.permissions || []).forEach((p) => permSet.add(p));
});
const s = summarizeSeverity(e);
admin += s.admin;
privesc += s.privilege_escalation;
risky += s.risky;
});
const header = document.createElement("div");
header.className = "am-provider-header";
const caret = document.createElement("span");
caret.className = "am-provider-header__caret";
caret.textContent = "▼";
header.appendChild(caret);
const provBadge = document.createElement("span");
provBadge.className = "badge " + providerBadgeClass(provider);
provBadge.textContent = provider.toUpperCase();
header.appendChild(provBadge);
const title = document.createElement("span");
title.className = "am-provider-header__title";
title.textContent = `${entries.length} identit${entries.length !== 1 ? "ies" : "y"}`;
header.appendChild(title);
const sub = document.createElement("span");
sub.className = "am-provider-header__sub";
const subParts = [
`${totalRes} resource${totalRes !== 1 ? "s" : ""}`,
`${permSet.size} unique perm${permSet.size !== 1 ? "s" : ""}`,
];
if (admin) subParts.push(`< span class = "rollup-chip rollup-chip--admin" > ⚠ ${admin} admin< / span > `);
else if (privesc) subParts.push(`< span class = "rollup-chip rollup-chip--privesc" > ${privesc} privesc< / span > `);
else if (risky) subParts.push(`< span class = "rollup-chip rollup-chip--risky" > ${risky} risky< / span > `);
sub.innerHTML = subParts.map((p) => `< span > ${typeof p === "string" & & p.startsWith("< ") ? p : escapeHtml(p)}< / span > `).join("");
header.appendChild(sub);
const cards = document.createElement("div");
cards.className = "am-provider-section__cards";
entries.forEach((entry) => cards.appendChild(buildIdentityCard(entry)));
header.addEventListener("click", () => {
section.classList.toggle("collapsed");
caret.textContent = section.classList.contains("collapsed") ? "▶" : "▼";
});
section.appendChild(header);
section.appendChild(cards);
return section;
}
function syncCriticalToggleVisibility() {
const wrap = document.getElementById("am-critical-toggle");
if (!wrap) return;
const anyClassified = (accessMap || []).some(
(e) => e & & e.permissions_by_severity & & (
(e.permissions_by_severity.admin || []).length
+ (e.permissions_by_severity.privilege_escalation || []).length
+ (e.permissions_by_severity.risky || []).length
+ (e.permissions_by_severity.read_only || []).length
) > 0
);
2026-05-04 19:24:46 -07:00
// When no entries are classified, the toggle is meaningless — but if
// the user previously enabled it, the persisted setting would still
// drive the filter and could leave them with an empty access-map view
// and no UI to disable it. Clear the persisted state and uncheck the
// input so the filter no longer applies on this report.
if (!anyClassified) {
const input = wrap.querySelector('input[type="checkbox"]');
if (input) input.checked = false;
setCriticalOnly(false);
}
2026-04-30 18:11:10 -07:00
wrap.style.display = anyClassified ? "" : "none";
}
2026-02-13 18:35:36 -08:00
function renderAccessMapStats() {
const bar = document.getElementById("am-stats-bar");
if (!bar) return;
if (!accessMap || accessMap.length === 0) {
bar.classList.add("hidden");
return;
}
let totalRes = 0;
const permSet = new Set();
const providerSet = new Set();
2026-04-30 18:11:10 -07:00
let totalAdmin = 0;
let identitiesWithAdmin = 0;
2026-02-13 18:35:36 -08:00
accessMap.forEach((entry) => {
providerSet.add((entry.provider || "unknown").toUpperCase());
(entry.groups || []).forEach((g) => {
totalRes += (g.resources || []).length;
(g.permissions || []).forEach((p) => permSet.add(p));
2025-12-04 22:02:30 -08:00
});
2026-04-30 18:11:10 -07:00
const s = summarizeSeverity({ identity: entry, groups: entry.groups || [] });
totalAdmin += s.admin;
if (s.admin > 0) identitiesWithAdmin += 1;
2026-02-13 18:35:36 -08:00
});
2025-12-04 22:02:30 -08:00
2026-02-13 18:35:36 -08:00
bar.classList.remove("hidden");
2026-04-30 18:11:10 -07:00
const adminStat = totalAdmin > 0
? `< div class = "am-stat" > < span class = "am-stat-value" style = "color:#b91c1c;" > ⚠ ${totalAdmin}< / span > < span class = "am-stat-label" > admin perm${totalAdmin !== 1 ? "s" : ""} / ${identitiesWithAdmin} identit${identitiesWithAdmin !== 1 ? "ies" : "y"}< / span > < / div > `
: "";
2026-02-13 18:35:36 -08:00
bar.innerHTML = `
< div class = "am-stat" > < span class = "am-stat-value" > ${accessMap.length}< / span > < span class = "am-stat-label" > Identities< / span > < / div >
< div class = "am-stat" > < span class = "am-stat-value" > ${totalRes}< / span > < span class = "am-stat-label" > Resources< / span > < / div >
< div class = "am-stat" > < span class = "am-stat-value" > ${permSet.size}< / span > < span class = "am-stat-label" > Unique Permissions< / span > < / div >
2026-04-30 18:11:10 -07:00
${adminStat}
2026-02-13 18:35:36 -08:00
< div class = "am-stat" > ${[...providerSet].map((p) => '< span class = "badge ' + providerBadgeClass(p) + '" > ' + escapeHtml(p) + '< / span > ').join(" ")}< / div >
`;
}
function buildIdentityCard(entry) {
const identity = entry.identity;
2026-04-30 18:11:10 -07:00
const byClass = identity.permissions_by_severity || null;
2026-02-13 18:35:36 -08:00
const provider = (identity.provider || "unknown");
const account = identity.account || "(unknown identity)";
const fingerprint = identity.fingerprint || "";
2026-04-30 18:11:10 -07:00
// Apply "Critical only" filter to the groups before rendering.
const groups = (entry.groups || []).map((g) => applyCriticalFilter(g, byClass));
// Count resources and collect all permissions across (filtered) groups.
2026-02-13 18:35:36 -08:00
let resCount = 0;
const allPerms = new Set();
groups.forEach((g) => {
resCount += (g.resources || []).length;
(g.permissions || []).forEach((p) => allPerms.add(p));
});
const permArr = [...allPerms];
2026-04-30 18:11:10 -07:00
const sevSummary = summarizeSeverity(entry);
2026-02-13 18:35:36 -08:00
// Card container
const card = document.createElement("div");
card.className = "id-card";
// Header (always visible)
const header = document.createElement("div");
header.className = "id-card__header";
const avatar = document.createElement("div");
avatar.className = "id-card__avatar";
avatar.textContent = "👤";
const info = document.createElement("div");
info.className = "id-card__info";
const nameRow = document.createElement("div");
nameRow.className = "id-card__name";
nameRow.textContent = account + " ";
const provBadge = document.createElement("span");
provBadge.className = "badge " + providerBadgeClass(provider);
provBadge.textContent = provider.toUpperCase();
nameRow.appendChild(provBadge);
info.appendChild(nameRow);
2026-04-30 18:11:10 -07:00
// Discriminator subtitle (helps tell duplicate-named identities apart).
// Skip context fields that are equal to the account name — those are
// redundant (e.g., MongoDB admin/admin) and don't help the user
// distinguish duplicates.
const discParts = [];
const ctx = identity.context || {};
const seen = new Set([String(account).toLowerCase()]);
const pushDistinct = (v) => {
if (!v) return;
const k = String(v).toLowerCase();
if (seen.has(k)) return;
seen.add(k);
discParts.push(v);
};
pushDistinct(ctx.identity_id);
pushDistinct(ctx.project);
pushDistinct(ctx.tenant);
pushDistinct(ctx.account_id);
pushDistinct(ctx.access_type);
if (discParts.length) {
const disc = document.createElement("div");
disc.className = "id-card__discriminator";
disc.textContent = discParts.join(" · ");
info.appendChild(disc);
}
2026-02-13 18:35:36 -08:00
// Meta row
const meta = document.createElement("div");
meta.className = "id-card__meta";
meta.innerHTML = `
< span class = "id-card__meta-item" > 📦 < strong > ${resCount}< / strong > resource${resCount !== 1 ? "s" : ""}< / span >
< span class = "id-card__meta-item" > 🔑 < strong > ${permArr.length}< / strong > permission${permArr.length !== 1 ? "s" : ""}< / span >
`;
// Token summary in meta
if (identity.token_details) {
const td = identity.token_details;
const parts = [];
if (td.username) parts.push(td.username);
if (td.token_type) parts.push(td.token_type);
if (parts.length) {
const tokenSpan = document.createElement("span");
tokenSpan.className = "id-card__meta-item";
tokenSpan.textContent = "🏷️ " + parts.join(" · ");
meta.appendChild(tokenSpan);
}
}
info.appendChild(meta);
2026-04-30 18:11:10 -07:00
// Severity rollup chips replace the per-permission preview.
2026-05-04 19:24:46 -07:00
// In "Critical only" mode the expanded view hides read-only permissions;
// suppress the read_only chip too so the rollup matches what's rendered
// (otherwise the header still advertises a count the body never shows).
const rollupCriticalMode = isCriticalOnly();
2026-04-30 18:11:10 -07:00
if (byClass & & (sevSummary.admin + sevSummary.privilege_escalation + sevSummary.risky + sevSummary.read_only) > 0) {
const rollup = document.createElement("div");
rollup.className = "id-card__rollup";
SEVERITY_BUCKETS.forEach(({ key, label, cls }) => {
2026-05-04 19:24:46 -07:00
if (rollupCriticalMode & & key === "read_only") return;
2026-04-30 18:11:10 -07:00
const n = sevSummary[key];
if (!n) return;
const chip = document.createElement("span");
chip.className = "rollup-chip rollup-chip--" + cls;
chip.textContent = (key === "admin" ? "⚠ " : "") + n + " " + label;
rollup.appendChild(chip);
});
info.appendChild(rollup);
} else if (permArr.length > 0) {
// Fallback for entries without classification (e.g. imported reports).
2026-02-13 18:35:36 -08:00
const preview = document.createElement("div");
preview.className = "id-card__perms-preview";
const limit = 6;
permArr.slice(0, limit).forEach((p) => {
const tag = document.createElement("span");
tag.className = "badge badge-perm";
tag.style.fontSize = "11px";
tag.textContent = p;
preview.appendChild(tag);
2025-12-04 22:02:30 -08:00
});
2026-02-13 18:35:36 -08:00
if (permArr.length > limit) {
const more = document.createElement("span");
more.className = "badge";
more.style.fontSize = "11px";
more.style.background = "var(--surface-muted)";
more.style.color = "var(--text-muted)";
more.textContent = "+" + (permArr.length - limit) + " more";
preview.appendChild(more);
}
info.appendChild(preview);
}
2025-12-04 22:02:30 -08:00
2026-02-13 18:35:36 -08:00
const toggle = document.createElement("div");
toggle.className = "id-card__toggle";
toggle.textContent = "▶";
2025-12-04 22:02:30 -08:00
2026-02-13 18:35:36 -08:00
header.appendChild(avatar);
header.appendChild(info);
header.appendChild(toggle);
// Body (expandable)
const body = document.createElement("div");
body.className = "id-card__body";
2026-04-30 18:11:10 -07:00
// Resources / permissions — rendered as one block per group.
// Each group is a (resources, permissions) pair where permissions are
// shared across all resources in the group, so we render them ONCE.
2026-02-13 18:35:36 -08:00
const resSection = document.createElement("div");
resSection.className = "id-card__section";
const resTitle = document.createElement("div");
resTitle.className = "id-card__section-title";
2026-04-30 18:11:10 -07:00
resTitle.textContent = "Resources & permissions (" + resCount + " resource" + (resCount !== 1 ? "s" : "") + ", " + groups.length + " group" + (groups.length !== 1 ? "s" : "") + ")";
2026-02-13 18:35:36 -08:00
resSection.appendChild(resTitle);
2026-04-30 18:11:10 -07:00
groups.forEach((group, idx) => {
2026-02-13 18:35:36 -08:00
const resources = group.resources || [];
const perms = group.permissions || [];
2026-04-30 18:11:10 -07:00
if (resources.length === 0 & & perms.length === 0) return;
const block = document.createElement("div");
block.className = "perm-group";
// Resource chips at the top of the block (no per-chip permissions —
// they share the permission set rendered below).
if (resources.length > 0) {
const resBox = document.createElement("div");
resBox.className = "perm-group__resources";
resources.forEach((resName) => {
const chip = document.createElement("span");
chip.className = "perm-group__resource";
chip.textContent = String(resName);
2026-02-13 18:35:36 -08:00
2026-04-30 18:11:10 -07:00
const { resourceType, resourceName } = extractResourceParts(String(resName));
const consoleLink = buildResourceConsoleLink(provider, resourceType, resourceName);
if (consoleLink) {
const a = document.createElement("a");
a.href = consoleLink;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = "↗";
a.title = "Open in console";
a.addEventListener("click", (e) => e.stopPropagation());
chip.appendChild(a);
}
resBox.appendChild(chip);
2026-02-13 18:35:36 -08:00
});
2026-04-30 18:11:10 -07:00
block.appendChild(resBox);
} else if (perms.length > 0) {
const note = document.createElement("div");
note.className = "perm-group__shared-note";
note.textContent = "Project-wide / Unscoped permissions:";
block.appendChild(note);
2026-02-13 18:35:36 -08:00
}
2026-04-30 18:11:10 -07:00
if (resources.length > 1 & & perms.length > 0) {
const note = document.createElement("div");
note.className = "perm-group__shared-note";
note.textContent = "These permissions apply to all " + resources.length + " resources above.";
block.appendChild(note);
}
2026-02-13 18:35:36 -08:00
2026-04-30 18:11:10 -07:00
// Permissions classified by severity.
if (perms.length > 0) {
const cls = classifyPermissions(perms, byClass);
SEVERITY_BUCKETS.forEach(({ key, label, cls: sevCls }) => {
const list = cls[key] || [];
if (!list.length) return;
const wrap = document.createElement("div");
wrap.className = "perm-severity";
// Default-collapse Read Only when the group has any non-read-only perms.
const hasOther = (cls.admin.length + cls.privilege_escalation.length + cls.risky.length) > 0;
if (key === "read_only" & & hasOther) wrap.classList.add("collapsed");
const titleEl = document.createElement("div");
titleEl.className = "perm-severity__title";
titleEl.innerHTML = `< span class = "perm-severity__caret" > ▼< / span > ${escapeHtml(label)} < span style = "color:var(--text-main); font-weight:700;" > ${list.length}< / span > `;
const pills = document.createElement("div");
pills.className = "perm-severity__pills";
list.forEach((p) => {
2026-02-13 18:35:36 -08:00
const tag = document.createElement("span");
2026-04-30 18:11:10 -07:00
tag.className = "badge badge-perm badge-perm--" + sevCls;
2026-02-13 18:35:36 -08:00
tag.style.fontSize = "10px";
tag.textContent = p;
2026-04-30 18:11:10 -07:00
pills.appendChild(tag);
2025-12-04 22:02:30 -08:00
});
2026-04-30 18:11:10 -07:00
titleEl.addEventListener("click", (e) => {
e.stopPropagation();
wrap.classList.toggle("collapsed");
const c = titleEl.querySelector(".perm-severity__caret");
if (c) c.textContent = wrap.classList.contains("collapsed") ? "▶" : "▼";
});
wrap.appendChild(titleEl);
wrap.appendChild(pills);
block.appendChild(wrap);
});
}
resSection.appendChild(block);
2026-02-13 18:35:36 -08:00
});
body.appendChild(resSection);
// Token details section
if (identity.token_details) {
const tokenSection = document.createElement("div");
tokenSection.className = "id-card__section";
const tokenTitle = document.createElement("div");
tokenTitle.className = "id-card__section-title";
tokenTitle.textContent = "Token Details";
tokenSection.appendChild(tokenTitle);
const tokenGrid = document.createElement("div");
tokenGrid.className = "token-grid";
const td = identity.token_details;
const fields = [
["Name", td.name], ["Username", td.username], ["Token Type", td.token_type],
["Account Type", td.account_type], ["User ID", td.user_id], ["Email", td.email],
["Company", td.company], ["Created", td.created_at], ["Expires", td.expires_at],
["Last Used", td.last_used_at], ["Location", td.location],
];
if (identity.provider_metadata) {
if (identity.provider_metadata.version) fields.push(["Version", identity.provider_metadata.version]);
if (typeof identity.provider_metadata.enterprise === "boolean") fields.push(["Enterprise", identity.provider_metadata.enterprise ? "Yes" : "No"]);
}
fields.forEach(([label, value]) => {
if (!value) return;
const field = document.createElement("div");
field.className = "token-field";
field.innerHTML = `< strong > ${escapeHtml(label)}:< / strong > ${escapeHtml(String(value))}`;
tokenGrid.appendChild(field);
2025-12-04 22:02:30 -08:00
});
2026-02-13 18:35:36 -08:00
tokenSection.appendChild(tokenGrid);
// Scopes
if (Array.isArray(td.scopes) & & td.scopes.length) {
const scopeDiv = document.createElement("div");
scopeDiv.style.marginTop = "8px";
scopeDiv.style.display = "flex";
scopeDiv.style.flexWrap = "wrap";
scopeDiv.style.gap = "4px";
td.scopes.forEach((scope) => {
const tag = document.createElement("span");
tag.className = "badge badge-perm";
tag.style.fontSize = "11px";
tag.textContent = scope;
scopeDiv.appendChild(tag);
});
tokenSection.appendChild(scopeDiv);
}
// Profile URL
if (td.url) {
const urlDiv = document.createElement("div");
urlDiv.style.marginTop = "6px";
urlDiv.style.fontSize = "12px";
const a = document.createElement("a");
a.href = td.url;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = td.url;
a.style.color = "var(--brand)";
urlDiv.appendChild(a);
tokenSection.appendChild(urlDiv);
}
body.appendChild(tokenSection);
}
card.appendChild(header);
card.appendChild(body);
// Footer with fingerprint
if (fingerprint) {
const footer = document.createElement("div");
footer.className = "id-card__footer";
footer.innerHTML = `Fingerprint: < code > ${escapeHtml(fingerprint.substring(0, 20))}…< / code > `;
const goBtn = document.createElement("button");
goBtn.className = "badge";
goBtn.textContent = "Go to finding";
goBtn.style.cursor = "pointer";
goBtn.style.marginLeft = "auto";
goBtn.style.background = "var(--brand-soft)";
goBtn.style.borderColor = "var(--brand)";
goBtn.style.color = "var(--brand-dark)";
goBtn.onclick = (e) => {
e.stopPropagation();
const si = document.getElementById("search-input");
if (si) {
setActiveView("view-findings");
si.value = fingerprint;
currentFilter = fingerprint;
currentPage = 1;
renderFindingsTable();
}
};
footer.appendChild(goBtn);
card.appendChild(footer);
}
// Toggle expand/collapse
header.addEventListener("click", () => {
const isOpen = body.classList.contains("open");
body.classList.toggle("open");
toggle.textContent = isOpen ? "▶" : "▼";
2025-12-04 22:02:30 -08:00
});
2026-02-13 18:35:36 -08:00
return card;
2025-12-04 22:02:30 -08:00
}
2025-12-05 22:24:16 -08:00
function buildRenderableAccessMap(filterLower) {
if (!accessMap || accessMap.length === 0) return [];
const identities = [];
accessMap.forEach((identity) => {
2026-01-01 22:24:57 -08:00
const account = formatIdentityLabel(identity);
2025-12-05 22:24:16 -08:00
const groups = Array.isArray(identity.groups) ? identity.groups : [];
2026-01-14 17:19:02 -08:00
const fingerprint = (identity.fingerprint || "").toLowerCase();
const identityNameMatches = Boolean(filterLower) & & (account.toLowerCase().includes(filterLower) || fingerprint.includes(filterLower));
2025-12-05 22:24:16 -08:00
let anyResourceMatches = false;
2026-02-13 18:35:36 -08:00
let anyPermMatches = false;
2025-12-05 22:24:16 -08:00
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;
});
2026-02-13 18:35:36 -08:00
if (filterLower & & !identityNameMatches) {
perms.forEach((p) => {
if (String(p).toLowerCase().includes(filterLower)) anyPermMatches = true;
});
}
2025-12-05 22:24:16 -08:00
return { resources, filteredResources, permissions: perms };
});
2026-02-13 18:35:36 -08:00
const hasMatch = !filterLower || identityNameMatches || anyResourceMatches || anyPermMatches;
2025-12-05 22:24:16 -08:00
if (!hasMatch) return;
const viewGroups = preparedGroups.map((group) => ({
2026-02-13 18:35:36 -08:00
resources: !filterLower || identityNameMatches || anyPermMatches ? group.resources : group.filteredResources,
2025-12-05 22:24:16 -08:00
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();
}
}
2025-12-04 22:02:30 -08:00
function addBadge(container, text, cls) {
const span = document.createElement("span");
span.className = "badge " + cls;
span.textContent = text;
container.appendChild(span);
}
2026-01-01 22:24:57 -08:00
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;
}
2025-12-06 09:10:21 -08:00
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;
2026-02-13 18:35:36 -08:00
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)}`;
2025-12-06 09:10:21 -08:00
return null;
}
if (service === "lambda") {
const match = resourcePart.match(/^function[:\/](.+)$/);
if (match & & match[1]) {
const regionQuery = region ? `?region=${encodeURIComponent(region)}` : "";
2026-02-13 18:35:36 -08:00
return `https://console.aws.amazon.com/lambda/home${regionQuery}#/functions/${encodeURIComponent(match[1])}`;
2025-12-06 09:10:21 -08:00
}
return null;
}
if (service === "ec2") {
const match = resourcePart.match(/^instance\/(.+)$/);
2026-02-13 18:35:36 -08:00
if (match & & match[1]) return `https://console.aws.amazon.com/ec2/v2/home?#InstanceDetails:instanceId=${encodeURIComponent(match[1])}`;
2025-12-06 09:10:21 -08:00
return null;
}
if (service === "kms") {
const match = resourcePart.match(/^(?:key|alias)\/(.+)$/);
2026-02-13 18:35:36 -08:00
if (match & & match[1]) return `https://console.aws.amazon.com/kms/home?#/kms/keys/${encodeURIComponent(match[1])}`;
2025-12-06 09:10:21 -08:00
return null;
}
2026-02-13 18:35:36 -08:00
if (service === "secretsmanager") return `https://console.aws.amazon.com/secretsmanager/home?#/secret?name=${encodeURIComponent(resource)}`;
2025-12-06 09:10:21 -08:00
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\/([^/]+)/);
2026-02-13 18:35:36 -08:00
if (bucketMatch & & bucketMatch[1] & & project) return `https://console.cloud.google.com/storage/browser/${encodeURIComponent(bucketMatch[1])}?project=${encodeURIComponent(project)}`;
2025-12-06 09:10:21 -08:00
}
if (resource.includes("/datasets/")) {
const datasetMatch = resource.match(/\/datasets\/([^/]+)/);
2026-02-13 18:35:36 -08:00
if (datasetMatch & & datasetMatch[1] & & project) return `https://console.cloud.google.com/bigquery?project=${encodeURIComponent(project)}&p=${encodeURIComponent(project)}&d=${encodeURIComponent(datasetMatch[1])}&page=dataset`;
2025-12-06 09:10:21 -08:00
}
if (resource.includes("/secrets/")) {
const secretMatch = resource.match(/\/secrets\/([^/:]+)/);
2026-02-13 18:35:36 -08:00
if (secretMatch & & secretMatch[1] & & project) return `https://console.cloud.google.com/security/secret-manager/secret/${encodeURIComponent(secretMatch[1])}/versions?project=${encodeURIComponent(project)}`;
2025-12-06 09:10:21 -08:00
}
if (resource.includes("/functions/")) {
const fnMatch = resource.match(/\/locations\/([^/]+)\/functions\/([^/]+)/);
2026-02-13 18:35:36 -08:00
if (fnMatch & & project) return `https://console.cloud.google.com/functions/details/${encodeURIComponent(fnMatch[1])}/${encodeURIComponent(fnMatch[2])}?project=${encodeURIComponent(project)}`;
2025-12-06 09:10:21 -08:00
}
2026-02-13 18:35:36 -08:00
if (project) return `https://console.cloud.google.com/home/dashboard?project=${encodeURIComponent(project)}`;
2025-12-06 09:10:21 -08:00
return null;
}
2025-12-04 22:02:30 -08:00
< / script >
< / body >
2026-04-24 00:14:56 -07:00
< / html >