kingfisher/docs/viewer/index.html
2026-05-04 19:24:46 -07:00

7091 lines
267 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kingfisher Access Map Viewer</title>
<style>
:root {
color-scheme: dark;
--brand: #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);
--critical: #fca5a5;
--success: #34d399;
--radius: 16px;
--hover: #182522;
--table-header: #12201d;
--code-bg: #0d1617;
--code-border: #223733;
--code-border-strong: rgba(0, 237, 100, 0.24);
--code-text: #e2e8f0;
--code-accent: rgba(0, 237, 100, 0.07);
--link-strong: #8fffd0;
--link-strong-hover: #d9fff0;
--link-underline: rgba(143, 255, 208, 0.32);
}
:root[data-theme="light"] {
color-scheme: light;
--brand: #13aa52;
--brand-dark: #0f7f3d;
--brand-soft: rgba(19, 170, 82, 0.1);
--brand-soft-strong: rgba(19, 170, 82, 0.16);
--bg: #eef4f0;
--surface: #ffffff;
--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);
--critical: #dc2626;
--success: #059669;
--hover: #edf5f0;
--table-header: #f5f9f7;
--code-bg: #f7fbf9;
--code-border: #dbe7df;
--code-border-strong: rgba(19, 170, 82, 0.18);
--code-text: #0f172a;
--code-accent: rgba(19, 170, 82, 0.08);
--link-strong: #0f7f3d;
--link-strong-hover: #0b5d2d;
--link-underline: rgba(15, 127, 61, 0.28);
}
* { box-sizing: border-box; }
body {
margin: 0;
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%);
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;
min-height: 100vh;
}
/* Header */
.hero {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), var(--surface);
color: var(--text-main);
padding: 10px 24px;
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(10px);
}
.hero__brand {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.hero__icon {
width: 40px;
height: 40px;
background: linear-gradient(180deg, rgba(19, 170, 82, 0.95), rgba(15, 127, 61, 0.95));
color: #ffffff;
border-radius: 12px;
display: grid;
place-items: center;
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);
font-weight: 700;
font-size: 18px;
}
.hero__title {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.02em;
}
.hero__subtitle {
font-size: 13px;
color: var(--text-muted);
margin-left: 12px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
.layout {
display: grid;
grid-template-columns: 124px 1fr;
gap: 20px;
align-items: start;
}
.sidebar {
background: linear-gradient(180deg, #0b1832 0%, #0e2143 100%);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 20px;
box-shadow: var(--shadow-md);
padding: 14px 10px;
position: sticky;
top: 84px;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.sidebar-section-label {
display: none;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
align-items: center;
}
.nav-group {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
align-items: center;
}
.nav-button {
width: 100%;
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);
font-weight: 600;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
box-shadow: none;
transition: border 0.15s ease, background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
}
.nav-button:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.nav-button.active {
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);
}
:root[data-theme="light"] .nav-button.active {
color: #ffffff;
}
.nav-icon {
width: 34px;
height: 34px;
border-radius: 10px;
display: grid;
place-items: center;
font-size: 15px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
opacity: 0.95;
}
.nav-icon svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-button__content {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
align-items: center;
width: 100%;
}
.nav-button__title {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: inherit;
line-height: 1.2;
text-align: center;
max-width: 100%;
white-space: nowrap;
}
.nav-button__sub {
display: none;
}
.view {
display: flex;
flex-direction: column;
gap: 18px;
}
.view-stack {
display: flex;
flex-direction: column;
gap: 20px;
}
.workspace-topbar {
display: grid;
grid-template-columns: minmax(320px, 1.1fr) minmax(420px, 1fr);
gap: 18px;
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;
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
top: auto;
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;
}
}
.info-banner {
background: linear-gradient(180deg, rgba(0, 237, 100, 0.08), transparent), var(--surface);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
text-align: center;
padding: 11px 16px;
font-size: 13px;
letter-spacing: 0.01em;
}
.page {
max-width: 1600px;
margin: 20px auto 32px;
padding: 0 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.panel {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 40%), var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.panel__header {
padding: 18px 22px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent), var(--surface-strong);
}
.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); }
/* Upload */
.upload-shell {
padding: 22px;
display: flex;
flex-direction: column;
gap: 16px;
}
.upload-area {
padding: 44px 30px 36px;
text-align: center;
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);
cursor: pointer;
transition: all 0.2s ease;
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.upload-area:hover,
.upload-area.active {
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);
border-color: var(--brand);
transform: translateY(-1px);
}
.upload-icon {
width: 68px;
height: 68px;
margin: 0 auto 16px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 34px;
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;
}
.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;
margin-top: 20px;
}
.upload-primary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
border-radius: 999px;
background: linear-gradient(180deg, var(--brand), var(--brand-dark));
color: #fff;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.01em;
box-shadow: 0 14px 28px rgba(0, 237, 100, 0.18);
}
.upload-action-hint {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.upload-help-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
text-align: left;
}
.upload-help-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px 15px 13px;
box-shadow: var(--shadow-sm);
min-height: 0;
}
.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;
}
@media (max-width: 720px) {
.upload-help-grid {
grid-template-columns: 1fr;
}
}
/* Metrics */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
padding: 22px;
gap: 18px;
}
.metric {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 42%), var(--surface-muted);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 18px 20px;
box-shadow: var(--shadow-sm);
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);
}
.metric__label {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric__value {
font-size: 34px;
font-weight: 700;
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);
}
.metric.critical .metric__value { color: var(--critical); }
.metric.success .metric__value { color: var(--success); }
/* Access Map */
.am-container {
padding: 18px 22px 22px;
background: var(--surface);
}
.am-toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
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);
}
.am-toolbar input {
flex: 1;
min-width: 200px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
font-size: 13px;
background: var(--surface);
color: var(--text-main);
}
.am-toolbar input:focus {
outline: none;
border-color: var(--brand);
}
.am-stats {
display: flex;
gap: 16px;
flex-wrap: wrap;
padding: 14px 16px;
background: linear-gradient(180deg, rgba(0, 237, 100, 0.06), transparent), var(--surface-muted);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
box-shadow: var(--shadow-sm);
}
.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);
}
.am-empty-hint {
padding: 14px 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface-muted);
border: 1px dashed var(--border);
border-radius: 12px;
color: var(--text-muted);
margin: 12px 20px 0;
}
/* Identity cards */
.am-card-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.id-card {
border: 1px solid var(--border);
border-radius: var(--radius);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 44%), var(--surface);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: border-color 0.15s, transform 0.12s ease, box-shadow 0.15s ease;
}
.id-card:hover {
border-color: var(--border-strong);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.id-card__header {
padding: 16px 18px;
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
user-select: none;
}
.id-card__header:hover {
background: rgba(255, 255, 255, 0.02);
}
.id-card__avatar {
width: 36px;
height: 36px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 18px;
flex-shrink: 0;
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);
}
.id-card__info {
flex: 1;
min-width: 0;
}
.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 {
display: none;
border-top: 1px solid var(--border);
padding: 18px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent), var(--surface-muted);
}
.id-card__body.open {
display: block;
}
.id-card__section {
margin-bottom: 14px;
}
.id-card__section:last-child {
margin-bottom: 0;
}
.id-card__section-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.resource-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
}
.resource-chip {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
font-size: 12px;
box-shadow: var(--shadow-sm);
}
.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;
}
.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 {
padding: 10px 16px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.02);
}
.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 */
.am-card-meta {
display: flex;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
flex-wrap: wrap;
}
.badge {
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
border: 1px solid transparent;
}
.badge-aws { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
.badge-gcp { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
.badge-azure { background: #ecfeff; color: #0e7490; border-color: #a5f3fc; }
.badge-github { background: #f4f4f5; color: #18181b; border-color: #d4d4d8; }
.badge-gitlab { background: #fff1f2; color: #be123c; border-color: #fecdd3; }
.badge-perm { background: #ecfdf5; color: #16a34a; border-color: #bbf7d0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.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;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.detail-field label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.detail-field div {
font-weight: 500;
font-size: 14px;
word-break: break-all;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
padding: 0 20px 20px;
}
.chart-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow-sm);
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
min-width: 0;
min-height: 220px;
overflow: hidden;
transition: transform 0.12s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.chart-card > canvas {
flex-shrink: 0;
}
.chart-card:hover {
transform: translateY(-1px);
border-color: var(--border-strong);
box-shadow: var(--shadow-md);
}
.chart-legend {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: var(--text-main);
min-width: 0;
flex: 1 1 160px;
overflow-wrap: anywhere;
word-break: break-word;
}
.chart-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
border: 1px solid var(--border);
flex-shrink: 0;
}
#status-chart,
#source-chart,
canvas[id^="source-detector-chart-"] {
background: radial-gradient(circle at center, rgba(0, 237, 100, 0.05), transparent 58%), var(--surface-muted);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow-sm);
}
.dashboard-breakdown {
padding: 0 20px 20px;
}
.breakdown-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
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;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent), var(--surface-strong);
}
.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;
background: var(--table-header);
}
.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;
}
/* Findings table */
.table-container {
width: 100%;
overflow-x: auto;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.01), transparent), var(--surface);
}
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th {
text-align: left;
padding: 13px 16px;
background: var(--table-header);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-weight: 700;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
position: sticky;
top: 0;
z-index: 1;
}
.table td {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.table tr:last-child td { border-bottom: none; }
.table tr:hover td {
background: linear-gradient(180deg, rgba(0, 237, 100, 0.06), transparent), var(--surface-muted);
cursor: pointer;
}
.sortable {
cursor: pointer;
user-select: none;
}
.sort-indicator {
font-size: 10px;
margin-left: 4px;
opacity: 0.6;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.status-badge.active { background: #ecfdf5; color: #15803d; }
.status-badge.inactive { background: #fef2f2; color: #b91c1c; }
.status-badge.unknown { background: var(--surface-muted); color: var(--text-muted); }
/* Finding detail snippet */
.snippet-box {
position: relative;
background: linear-gradient(90deg, var(--code-accent), transparent), var(--code-bg);
color: var(--code-text);
padding: 16px;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
max-width: 100%;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid var(--code-border);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35), 0 0 0 1px var(--code-border-strong);
}
.snippet-box::after {
content: "";
position: absolute;
inset: 0;
border-radius: 8px;
pointer-events: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
}
#fd-validation-box {
margin-top: 16px;
padding: 14px 16px;
background: #ecfdf5;
border: 1px solid #bbf7d0;
border-radius: 6px;
}
#fd-validation-box label {
font-size: 13px;
font-weight: 600;
color: #166534;
display: block;
margin-bottom: 4px;
}
#fd-validation-res {
white-space: pre-wrap;
font-size: 12px;
color: #166534;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
/* Validate command box - blue theme */
#fd-validate-box {
margin-top: 16px;
padding: 14px 16px;
background: #eff6ff;
border: 1px solid #93c5fd;
border-radius: 6px;
}
#fd-validate-box label {
font-size: 13px;
font-weight: 600;
color: #1e40af;
display: block;
margin-bottom: 8px;
}
#fd-validate-cmd {
flex: 1;
font-size: 12px;
color: #1e3a8a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: rgba(255, 255, 255, 0.5);
padding: 8px 12px;
border-radius: 4px;
word-break: break-all;
white-space: pre-wrap;
}
.copy-btn-validate {
color: #1e40af;
background: #dbeafe;
border: 1px solid #3b82f6;
}
.copy-btn-validate:hover {
background: #bfdbfe;
border-color: #2563eb;
}
/* Revoke command box - amber/warning theme */
#fd-revoke-box {
margin-top: 16px;
padding: 14px 16px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 6px;
}
#fd-revoke-box label {
font-size: 13px;
font-weight: 600;
color: #92400e;
display: block;
margin-bottom: 8px;
}
#fd-revoke-cmd {
flex: 1;
font-size: 12px;
color: #78350f;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: rgba(255, 255, 255, 0.5);
padding: 8px 12px;
border-radius: 4px;
word-break: break-all;
white-space: pre-wrap;
}
/* 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;
}
/* Shared command container styles */
.cmd-container {
display: flex;
align-items: flex-start;
gap: 10px;
}
.copy-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
font-size: 12px;
font-weight: 500;
color: #78350f;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s;
}
.copy-btn:hover {
background: #fde68a;
border-color: #d97706;
}
.copy-btn.copied {
background: #d1fae5;
border-color: #10b981;
color: #065f46;
}
.path-area {
width: 100%;
min-height: 48px;
background: var(--surface-muted);
color: var(--text-main);
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border-strong);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 13px;
resize: vertical;
overflow: auto;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.link-mono a {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
color: var(--link-strong);
text-decoration: underline;
text-decoration-color: var(--link-underline);
text-decoration-thickness: 2px;
font-weight: 600;
word-break: break-all;
}
.link-mono a:hover {
color: var(--link-strong-hover);
text-decoration-color: currentColor;
}
/* Search & pagination */
.search-input {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
width: 220px;
font-size: 13px;
background: var(--surface);
color: var(--text-main);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.rows-label {
font-size: 12px;
color: var(--text-muted);
}
.rows-select {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
font-size: 13px;
color: var(--text-main);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.pager {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.pager-btn {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
color: var(--text-main);
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);
}
.pager-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* Buttons & loader */
.btn {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
border: 1px solid var(--border);
padding: 9px 16px;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
color: var(--text-main);
box-shadow: var(--shadow-sm);
transition: background 0.1s, border-color 0.1s, transform 0.1s ease, box-shadow 0.1s ease;
}
.btn:hover {
background: var(--surface-muted);
border-color: var(--border-strong);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#theme-toggle {
background: var(--surface-muted);
color: var(--text-main);
border-color: var(--border);
font-weight: 700;
box-shadow: var(--shadow-sm);
}
#theme-toggle:hover {
background: var(--hover);
border-color: var(--border-strong);
color: var(--text-main);
}
:root[data-theme="light"] #theme-toggle {
background: #ffffff;
color: var(--brand-dark);
border-color: var(--border-strong);
}
:root[data-theme="light"] #theme-toggle:hover {
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;
}
.hidden { display: none !important; }
.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);
}
/* 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;
padding: 16px 18px;
background: linear-gradient(180deg, rgba(0, 237, 100, 0.08), transparent), var(--surface-muted);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
color: var(--text-muted);
box-shadow: var(--shadow-sm);
}
.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;
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;
}
.workspace-actions .report-type-group {
height: 100%;
justify-content: flex-start;
}
.report-type-group + .report-type-group {
margin-top: 0;
}
.report-type-group label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 600;
}
.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;
}
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.2);
backdrop-filter: blur(2px);
z-index: 100;
display: grid;
place-items: center;
}
.spinner {
width: 40px;
height: 40px;
border-radius: 999px;
border: 3px solid var(--border);
border-top-color: #0e7c56;
animation: spin 0.8s linear infinite;
}
.progress-track {
width: 220px;
height: 6px;
border-radius: 999px;
background: var(--border);
overflow: hidden;
margin: 12px auto 0;
}
.progress-inner {
width: 40%;
height: 100%;
background: linear-gradient(90deg, #0e7c56, #22c55e, #f59e0b);
animation: progress-move 0.9s linear infinite;
}
@keyframes progress-move {
0% { transform: translateX(-100%); }
50% { transform: translateX(0%); }
100% { transform: translateX(100%); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="loader" class="loading-overlay hidden">
<div style="background:var(--surface); padding:18px 22px; border-radius:10px; border:1px solid var(--border); box-shadow:var(--shadow-md); text-align:center; min-width:260px; color:var(--text-main);">
<div class="spinner" style="margin:0 auto 12px;"></div>
<div class="progress-track"><div class="progress-inner"></div></div>
<div id="loader-text" style="margin-top:10px; font-size:13px; color:var(--text-muted);">
Processing report...
</div>
</div>
</div>
<header class="hero">
<div class="hero__brand">
<div class="hero__icon">K</div>
<div style="display:flex; align-items:center;">
<span class="hero__title">Kingfisher Report Viewer</span>
<span class="hero__subtitle">Access Map &amp; Findings</span>
</div>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; justify-content:flex-end;">
<a class="btn" href="https://github.com/mongodb/kingfisher" target="_blank" rel="noopener noreferrer">GitHub</a>
<button class="btn" id="theme-toggle" type="button">Light Mode</button>
<button class="btn" id="reset-btn" type="button">Clear and Load New Report(s)</button>
</div>
</header>
<div class="info-banner">Runs entirely in your browser. Reports stay local and are never uploaded.</div>
<main class="page">
<section id="upload-section" class="panel" style="max-width: 920px; margin: 0 auto;">
<div class="panel__header">
<div class="panel__title">
<h3>Load Report</h3>
<p>Analyze Kingfisher JSON / JSONL output, plus Gitleaks and TruffleHog JSON reports</p>
</div>
</div>
<div class="upload-shell">
<div class="upload-area" id="drop-zone">
<div class="upload-icon">📄</div>
<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>
<input type="file" id="file-input" hidden accept=".json,.jsonl" multiple>
<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>
</div>
<div class="upload-help-grid">
<div class="upload-help-card">
<div class="upload-help-label">Formats</div>
<div class="upload-help-text"><strong>Kingfisher</strong> JSON/JSONL, <strong>Gitleaks</strong> OSS JSON, and <strong>TruffleHog</strong> OSS JSON/JSONL.</div>
</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>
<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>
</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>
<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>
</div>
<div id="error-msg" class="hidden" style="margin-top:16px; padding:10px 12px; background:#fef2f2; border:1px solid #fecaca; border-radius:6px; color:#b91c1c; font-size:13px;"></div>
</div>
</section>
<div id="dashboard" class="hidden">
<div class="layout">
<aside class="sidebar">
<div class="sidebar-nav">
<div class="sidebar-section-label">Navigate</div>
<div class="nav-group">
<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>
<span class="nav-button__content">
<span class="nav-button__title">Dashboard</span>
<span class="nav-button__sub">Overview and report mix</span>
</span>
</button>
<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>
<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>
<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>
<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>
</div>
</aside>
<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>
</div>
<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>
</div>
</div>
</section>
<section id="view-dashboard" class="view">
<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>
<div class="scan-meta-item" id="meta-source-wrap" style="display:none;"><strong>Source:</strong> <span id="meta-source"></span></div>
<div class="scan-meta-item" id="meta-version-wrap" style="display:none;"><strong>Version:</strong> <span id="meta-version"></span></div>
</div>
<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>
<section class="panel">
<div class="metrics-grid">
<div class="metric">
<div class="metric__label">Total Findings</div>
<div class="metric__value" id="stat-total">0</div>
</div>
<div class="metric critical">
<div class="metric__label">High Confidence</div>
<div class="metric__value" id="stat-high">0</div>
</div>
<div class="metric success">
<div class="metric__label">Active Credentials</div>
<div class="metric__value" id="stat-active">0</div>
</div>
<div class="metric">
<div class="metric__label">Identities Mapped</div>
<div class="metric__value" id="stat-identities">0</div>
</div>
<div class="metric 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 &ldquo;Deduplicate findings&rdquo; 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>
</div>
</section>
<section class="panel">
<div class="panel__header">
<div class="panel__title">
<h3>Finding Status Distribution</h3>
<p>Validation state and report source mix</p>
</div>
</div>
<div class="chart-grid">
<div class="chart-card">
<canvas id="status-chart" width="260" height="220"></canvas>
<div class="chart-legend" id="status-legend">
<!-- filled by JS -->
</div>
</div>
<div 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">&lsaquo;</button>
<span id="detector-breakdown-page-info">0 of 0</span>
<button id="detector-breakdown-next" class="pager-btn" type="button">&rsaquo;</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>
</div>
</section>
</section>
<section id="view-access" class="view hidden">
<section class="panel" id="am-section">
<div class="panel__header">
<div class="panel__title">
<h3>Access Map</h3>
<p>Credentials and what they can reach</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn" id="copy-access-map" type="button">Copy JSON</button>
</div>
</div>
<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>
<div class="am-container" id="am-container">
<div id="am-stats-bar" class="am-stats hidden"></div>
<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>
</div>
<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.
</div>
</div>
</div>
</section>
</section>
<section id="view-findings" class="view hidden">
<section class="panel">
<div class="panel__header">
<div class="panel__title">
<h3>Findings</h3>
<p>Detailed list of detected secrets</p>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<button class="btn" id="download-json" type="button">Download JSON</button>
<button class="btn" id="download-csv" type="button">Export CSV</button>
<input type="text" id="search-input" class="search-input" placeholder="Search rule, path, snippet">
<select id="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>
<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>
<option value="canary">Canary Token (Skipped)</option>
</select>
<span class="rows-label">Rows</span>
<select id="rows-select" class="rows-select">
<option value="10" selected>10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
<div class="pager">
<button id="page-prev" class="pager-btn" type="button">&lsaquo;</button>
<span id="page-info">0 of 0</span>
<button id="page-next" class="pager-btn" type="button">&rsaquo;</button>
</div>
</div>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th class="sortable" data-field="rule">
Rule <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="source">
Source <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="location">
Location <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="validation">
Validation <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="confidence">
Confidence <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="line">
Line <span class="sort-indicator"></span>
</th>
</tr>
</thead>
<tbody id="findings-body"></tbody>
</table>
</div>
</section>
<section id="finding-detail" class="panel hidden">
<div class="panel__header">
<div class="panel__title">
<h3>Finding Details</h3>
</div>
<button class="btn" onclick="document.getElementById('finding-detail').classList.add('hidden')">Close</button>
</div>
<div style="padding:20px 22px 22px;">
<div class="detail-grid">
<div class="detail-field">
<label>Rule ID</label>
<div id="fd-rule-id"></div>
</div>
<div class="detail-field">
<label>Fingerprint</label>
<div id="fd-fingerprint"></div>
</div>
<div class="detail-field">
<label>Entropy</label>
<div id="fd-entropy"></div>
</div>
<div class="detail-field">
<label>Validation Status</label>
<div id="fd-validation-status"></div>
</div>
<div class="detail-field">
<label>Report Source</label>
<div id="fd-source"></div>
</div>
<div class="detail-field">
<label>Git Commit</label>
<div id="fd-commit"></div>
</div>
<div class="detail-field" id="fd-committer-email-wrapper">
<label>Committer Email</label>
<div id="fd-committer-email"></div>
</div>
<div class="detail-field">
<label>File Path</label>
<textarea id="fd-path" class="path-area" readonly></textarea>
</div>
<div class="detail-field" id="fd-git-url-wrapper">
<label>Git URL</label>
<div id="fd-git-url" class="link-mono"></div>
</div>
</div>
<label style="font-size:12px; color:var(--text-muted); display:block; margin-bottom:6px; text-transform:uppercase; letter-spacing:0.06em; font-weight:600;">
Match Snippet
</label>
<div class="snippet-box" id="fd-snippet"></div>
<div id="fd-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>
<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> &middot; Kingfisher rule <span id="fd-kf-rule"></span>
</div>
</div>
<div id="fd-validation-box" class="hidden">
<label>Live Validation Response</label>
<pre id="fd-validation-res"></pre>
</div>
<div id="fd-validate-box" class="hidden">
<label>Validate Command</label>
<div class="cmd-container">
<code id="fd-validate-cmd"></code>
<button class="copy-btn copy-btn-validate" id="fd-validate-copy" title="Copy to clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span>Copy</span>
</button>
</div>
</div>
<div id="fd-revoke-box" class="hidden">
<label>Revoke Command</label>
<div class="cmd-container">
<code id="fd-revoke-cmd"></code>
<button class="copy-btn" id="fd-revoke-copy" title="Copy to clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span>Copy</span>
</button>
</div>
</div>
<div 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>
</div>
</section>
</section>
</div>
</div>
</div>
</main>
<script>
let rawData = null;
let findings = [];
let accessMap = [];
let duplicatesRemoved = { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 };
let filteredAccessMapView = [];
let currentFilter = "";
let validationFilter = "all";
let sourceFilter = "all";
let pageSize = 10;
let currentPage = 1;
let sortField = "rule";
let sortDirection = "asc";
let detectorBreakdownSortField = "count";
let detectorBreakdownSortDirection = "desc";
let detectorBreakdownActiveOnly = false;
let detectorBreakdownPage = 1;
let autoCollapsedAccessMap = false;
let currentDetailFinding = null;
let scanMetadata = {};
const dropZone = document.getElementById("drop-zone");
const fileInput = document.getElementById("file-input");
const loader = document.getElementById("loader");
const loaderText = document.getElementById("loader-text");
const errorMsg = document.getElementById("error-msg");
const uploadSection = document.getElementById("upload-section");
const dashboard = document.getElementById("dashboard");
const downloadRiskReportBtn = document.getElementById("download-risk-report");
const downloadScanReportBtn = document.getElementById("download-scan-report");
const reportScopeSelect = document.getElementById("report-scope");
const downloadAccessReportBtn = document.getElementById("download-access-report");
const exportFindingRiskBtn = document.getElementById("fd-export-risk-report");
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");
const searchInput = document.getElementById("search-input");
const sourceSelect = document.getElementById("source-filter");
const validationSelect = document.getElementById("validation-filter");
const rowsSelect = document.getElementById("rows-select");
const pagePrev = document.getElementById("page-prev");
const pageNext = document.getElementById("page-next");
const pageInfo = document.getElementById("page-info");
const findingsBody = document.getElementById("findings-body");
const downloadJsonBtn = document.getElementById("download-json");
const downloadCsvBtn = document.getElementById("download-csv");
const treeSearch = document.getElementById("tree-search");
const amContainer = document.getElementById("am-container");
const amToggle = document.getElementById("am-toggle"); // may be null now
const copyAccessMapButton = document.getElementById("copy-access-map");
const amEmptyNotice = document.getElementById("am-empty-notice");
const themeToggle = document.getElementById("theme-toggle");
const fdPathInput = document.getElementById("fd-path");
const navButtons = document.querySelectorAll("[data-view-target]");
const statusChartCanvas = document.getElementById("status-chart");
const statusLegend = document.getElementById("status-legend");
const sourceChartCanvas = document.getElementById("source-chart");
const sourceLegend = document.getElementById("source-legend");
const detectorBreakdown = document.getElementById("detector-breakdown");
const viewRegistry = {
"view-dashboard": document.getElementById("view-dashboard"),
"view-access": document.getElementById("view-access"),
"view-findings": document.getElementById("view-findings"),
};
const THEME_KEY = "viewer-theme";
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";
}
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";
}
return normalized;
}
function rerenderThemeSensitiveViews() {
if (!hasLoadedReportData()) return;
const validationCounts = calculateValidationCounts();
renderStatusChart(validationCounts);
renderSourceChart(calculateSourceCounts());
renderSourceDetectorCharts();
}
setTheme(getPreferredTheme());
systemThemeQuery.addEventListener("change", () => {
if (getStoredThemePreference()) return;
setTheme(getPreferredTheme());
rerenderThemeSensitiveViews();
});
// 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);
}
function hasLoadedReportData() {
return findings.length > 0 || accessMap.length > 0 || rawData !== null;
}
function openReplaceFilePicker() {
fileInput.value = "";
fileInput.click();
}
dropZone.addEventListener("click", () => openReplaceFilePicker());
fileInput.addEventListener("change", (e) => {
if (e.target.files.length) {
processFiles(Array.from(e.target.files), { append: false });
}
});
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("active");
});
dropZone.addEventListener("dragleave", (e) => {
e.preventDefault();
dropZone.classList.remove("active");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("active");
if (e.dataTransfer.files.length) {
processFiles(Array.from(e.dataTransfer.files), { append: hasLoadedReportData() });
}
});
navButtons.forEach((btn) => {
btn.addEventListener("click", () => setActiveView(btn.dataset.viewTarget));
});
const resetButton = document.getElementById("reset-btn");
resetButton.addEventListener("click", () => {
const confirmReset = confirm(
"Clearing and loading a new report will discard the currently loaded data from the viewer (your files stay on disk). Continue?",
);
if (confirmReset) {
resetViewer(true);
}
});
searchInput.addEventListener("input", (e) => {
currentFilter = e.target.value || "";
currentPage = 1;
renderFindingsTable();
});
validationSelect.addEventListener("change", (e) => {
validationFilter = e.target.value || "all";
currentPage = 1;
renderFindingsTable();
});
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();
});
}
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 || "");
});
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 || "");
});
}
if (amToggle) {
amToggle.addEventListener("click", () => {
const willCollapse = !amContainer.classList.contains("hidden");
setAccessMapCollapsed(willCollapse);
});
}
downloadJsonBtn.addEventListener("click", downloadFindingsJson);
downloadCsvBtn.addEventListener("click", downloadFindingsCsv);
copyAccessMapButton.addEventListener("click", copyFilteredAccessMap);
if (downloadRiskReportBtn) {
downloadRiskReportBtn.addEventListener("click", () => {
const activeOnly = document.getElementById("risk-active-only");
generateRiskReport({ activeOnly: activeOnly && activeOnly.checked });
});
}
if (downloadScanReportBtn) {
downloadScanReportBtn.addEventListener("click", () => {
const scope = reportScopeSelect ? reportScopeSelect.value : "all";
const activeOnly = document.getElementById("scan-active-only");
generateScanReport(scope, { activeOnly: activeOnly && activeOnly.checked });
});
}
if (downloadAccessReportBtn) {
downloadAccessReportBtn.addEventListener("click", generateAccessMapReport);
}
if (exportFindingRiskBtn) {
exportFindingRiskBtn.addEventListener("click", () => {
if (currentDetailFinding) {
exportSingleFindingRiskReport(currentDetailFinding);
}
});
}
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);
rerenderThemeSensitiveViews();
});
}
loadCliReport();
document.querySelectorAll("th.sortable").forEach((th) => {
th.addEventListener("click", () => {
const field = th.dataset.field;
if (!field) return;
if (sortField === field) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortField = field;
sortDirection = "asc";
}
currentPage = 1;
renderFindingsTable();
});
});
function setActiveView(targetId) {
const fallback = "view-dashboard";
const viewId = targetId && viewRegistry[targetId] ? targetId : fallback;
Object.entries(viewRegistry).forEach(([id, el]) => {
if (!el) return;
if (id === viewId) el.classList.remove("hidden");
else el.classList.add("hidden");
});
navButtons.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.viewTarget === viewId);
});
}
function setAccessMapCollapsed(collapsed, { auto = false } = {}) {
if (!amContainer) return;
if (collapsed) {
amContainer.classList.add("hidden");
if (amToggle) amToggle.textContent = "Expand";
autoCollapsedAccessMap = auto;
} else {
amContainer.classList.remove("hidden");
if (amToggle) amToggle.textContent = "Collapse";
autoCollapsedAccessMap = false;
}
}
function syncAccessMapUi(hasAccess) {
const previouslyAutoCollapsed = autoCollapsedAccessMap;
if (amToggle) amToggle.disabled = !hasAccess;
if (downloadAccessReportBtn) downloadAccessReportBtn.disabled = !hasAccess;
if (amEmptyNotice) {
amEmptyNotice.innerHTML = getAccessMapEmptyMessage();
amEmptyNotice.classList.toggle("hidden", hasAccess);
}
if (!hasAccess) {
setAccessMapCollapsed(true, { auto: true });
return;
}
if (previouslyAutoCollapsed) {
setAccessMapCollapsed(false);
}
}
function escapeHtml(unsafe) {
if (unsafe === undefined || unsafe === null) return "";
return unsafe
.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function parsePossiblyMultiJson(text) {
try {
return JSON.parse(text);
} catch (e) {
const trimmed = text.trim();
const parts = trimmed.split(/\r?\n(?=\s*[{\[])/g).filter(Boolean);
if (parts.length > 1) {
try {
return JSON.parse("[" + parts.join(",") + "]");
} catch (e2) {}
}
}
return null;
}
function collectReportData(entries) {
const findings = [];
const accessMap = [];
let mainReport = null;
let statsReport = null;
const items = Array.isArray(entries) ? entries : [entries];
items.forEach((item) => {
if (!item || typeof item !== "object") return;
if (Array.isArray(item.findings) || Array.isArray(item.access_map)) {
if (!mainReport) {
mainReport = item;
}
}
if (Array.isArray(item.findings)) {
findings.push(...item.findings);
}
if (Array.isArray(item.access_map)) {
accessMap.push(...item.access_map);
}
if (item.rule && item.finding) {
findings.push(item);
}
if (item.provider && item.account) {
accessMap.push(item);
}
if (
typeof item.scan_duration !== "undefined" ||
typeof item.scanDuration !== "undefined" ||
typeof item.bytes_scanned !== "undefined" ||
item.kingfisher ||
item.stats ||
item.summary
) {
statsReport = item;
}
});
return { findings, accessMap, mainReport, statsReport };
}
function 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] : [];
}
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) {
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) };
}
if (repositoryUrl) {
gitMetadata.repository = { url: String(repositoryUrl) };
}
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]))
);
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);
const gitMetadata = buildGitMetadata(
firstNonEmpty(item.Commit, item.commit),
firstNonEmpty(item.Email, item.email),
fileUrl,
normalizeRepositoryWebUrl(repoUrl)
);
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 || {};
const gitSource = findDeepValue(sourceMetadata, ["git"]) || {};
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
);
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
);
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) || "");
const normalizedRepoUrl = normalizeRepositoryWebUrl(repositoryUrl);
const fileUrl = isHttpUrl(sourceUrl) ? sourceUrl : buildGitFileUrl(normalizedRepoUrl, commitId, path, line);
const gitMetadata = buildGitMetadata(commitId, email, fileUrl, normalizedRepoUrl);
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"
? ["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."];
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) {
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;
}
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;
}
function processFiles(files, { append = false } = {}) {
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
? (append ? 'Importing "' + validFiles[0].name + '"…' : 'Processing "' + validFiles[0].name + '"…')
: (append ? "Importing " + validFiles.length + " additional files…" : "Processing " + validFiles.length + " files…");
loaderText.textContent = label;
loader.classList.remove("hidden");
errorMsg.classList.add("hidden");
errorMsg.textContent = "";
setTimeout(() => {
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 {
parseAndRenderMultiple(texts, { append });
} 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 {
parseAndRenderMultiple(validTexts, { append });
} catch (err) {
console.error(err);
errorMsg.textContent = "Error parsing files: " + err.message;
errorMsg.classList.remove("hidden");
loader.classList.add("hidden");
}
}
}
};
reader.readAsText(file);
});
}, 30);
}
async function loadCliReport() {
try {
loaderText.textContent = "Loading report from CLI…";
loader.classList.remove("hidden");
const response = await fetch("/report", { cache: "no-store" });
if (response.status === 404) {
loader.classList.add("hidden");
return;
}
if (!response.ok) {
throw new Error("Server returned status " + response.status);
}
const text = await response.text();
parseAndRender(text);
} catch (err) {
loader.classList.add("hidden");
errorMsg.textContent = "Failed to load report from CLI: " + err.message;
errorMsg.classList.remove("hidden");
}
}
function resetViewer(promptFilePicker = false) {
findings = [];
accessMap = [];
filteredAccessMapView = [];
rawData = null;
currentDetailFinding = null;
scanMetadata = {};
duplicatesRemoved = { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 };
currentFilter = "";
validationFilter = "all";
sourceFilter = "all";
pageSize = 10;
currentPage = 1;
sortField = "rule";
sortDirection = "asc";
detectorBreakdownActiveOnly = false;
detectorBreakdownPage = 1;
searchInput.value = "";
validationSelect.value = "all";
sourceSelect.value = "all";
rowsSelect.value = "10";
treeSearch.value = "";
if (detectorBreakdownActiveOnlyToggle) detectorBreakdownActiveOnlyToggle.checked = false;
setAccessMapCollapsed(true, { auto: true });
if (amToggle) amToggle.disabled = true;
if (amEmptyNotice) amEmptyNotice.classList.add("hidden");
const amStatsBar = document.getElementById("am-stats-bar");
if (amStatsBar) amStatsBar.classList.add("hidden");
renderAccessMapTree();
renderFindingsTable();
updateMetrics();
const scanMetaSection = document.getElementById("scan-meta-section");
if (scanMetaSection) scanMetaSection.classList.add("hidden");
const importNote = document.getElementById("import-note");
if (importNote) {
importNote.classList.add("hidden");
importNote.innerHTML = "";
}
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");
setActiveView("view-dashboard");
dashboard.classList.add("hidden");
uploadSection.classList.remove("hidden");
errorMsg.classList.add("hidden");
errorMsg.textContent = "";
loader.classList.add("hidden");
loaderText.textContent = "Processing report...";
fileInput.value = "";
if (promptFilePicker) {
openReplaceFilePicker();
}
}
function parseAndRenderMultiple(texts, { append = false } = {}) {
const t0 = performance.now();
const nextFindings = append ? findings.slice() : [];
const nextAccessMap = append ? accessMap.slice() : [];
let nextRawData = append ? rawData : null;
for (let i = 0; i < texts.length; i++) {
const text = texts[i];
if (!text) continue;
const { f, am, rd } = parseSingleText(text);
nextFindings.push(...f);
nextAccessMap.push(...am);
nextRawData = mergeRawReportData(nextRawData, rd);
}
// 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);
// Deduplicate access map entries by fingerprint
accessMap = deduplicateAccessMap(nextAccessMap);
rawData = nextRawData;
finalizeRender(t0);
}
function parseAndRender(text) {
const t0 = performance.now();
findings = [];
accessMap = [];
rawData = null;
const { f, am, rd } = parseSingleText(text);
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);
accessMap = deduplicateAccessMap(am);
rawData = rd;
finalizeRender(t0);
}
function parseSingleText(text) {
let parsed = parsePossiblyMultiJson(text);
if (parsed === null) {
const lines = text.split(/\r?\n/);
const entries = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || (line[0] !== "{" && line[0] !== "[")) continue;
try {
const obj = JSON.parse(line);
entries.push(obj);
} catch (errLine) {
console.warn("Skipping invalid JSON line", i);
}
}
return normalizeReportPayload(entries);
}
return normalizeReportPayload(parsed);
}
function isDedupDisabled() {
// Toggle is default-checked; only disabled when the user has unchecked it.
const el = document.getElementById("dedup-toggle");
return !!(el && !el.checked);
}
function deduplicateFindings(list) {
const seen = new Set();
const result = [];
const dropped = { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 };
for (const f of list) {
const fp = f.finding && f.finding.fingerprint ? f.finding.fingerprint : "";
if (fp && seen.has(fp)) {
const tool = getFindingSourceTool(f.finding || {});
dropped[tool] = (dropped[tool] || 0) + 1;
dropped.total += 1;
continue;
}
if (fp) seen.add(fp);
result.push(f);
}
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 || "",
};
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,
});
}
}
}
return findingList;
}
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) {
currentPage = 1;
currentFilter = "";
validationFilter = "all";
sourceFilter = "all";
searchInput.value = "";
validationSelect.value = "all";
sourceSelect.value = "all";
extractScanMetadata();
setActiveView("view-dashboard");
updateMetrics();
renderScanMetadata();
renderAccessMapTree();
renderFindingsTable();
uploadSection.classList.add("hidden");
dashboard.classList.remove("hidden");
const elapsed = performance.now() - t0;
loaderText.textContent = "Done in " + elapsed.toFixed(1) + " ms";
setTimeout(() => loader.classList.add("hidden"), 250);
}
function 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(" ")}`;
}
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;
const reportMeta = data.metadata && typeof data.metadata === "object" ? data.metadata : {};
const viewerImport = reportMeta.viewer_import && typeof reportMeta.viewer_import === "object"
? reportMeta.viewer_import
: buildViewerImportMetadata("Kingfisher", {});
scanMetadata.timestamp =
reportMeta.scan_timestamp ||
reportMeta.generated_at ||
data.timestamp ||
data.scan_timestamp ||
data.generated_at ||
new Date().toLocaleString();
// Target info
scanMetadata.target =
reportMeta.target ||
data.target ||
data.scan_target ||
data.repository ||
data.repo ||
(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);
}
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,
};
// Version
scanMetadata.version =
reportMeta.kingfisher_version ||
data.version ||
data.kingfisher_version ||
(data.kingfisher && data.kingfisher.version) || "";
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),
};
}
// 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");
const sourceWrap = document.getElementById("meta-source-wrap");
const source = document.getElementById("meta-source");
const versionWrap = document.getElementById("meta-version-wrap");
const version = document.getElementById("meta-version");
const importNote = document.getElementById("import-note");
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";
}
if (scanMetadata.reportSource) {
sourceWrap.style.display = "";
source.textContent = scanMetadata.reportSource;
} else {
sourceWrap.style.display = "none";
}
if (scanMetadata.version) {
versionWrap.style.display = "";
version.textContent = scanMetadata.version;
} else {
versionWrap.style.display = "none";
}
if (importNote) {
const importHtml = getImportNoticeHtml(scanMetadata.importNotes);
if (importHtml) {
importNote.classList.remove("hidden");
importNote.innerHTML = importHtml;
} else {
importNote.classList.add("hidden");
importNote.innerHTML = "";
}
}
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";
}
function updateMetrics() {
const totalFindings = findings.length;
document.getElementById("stat-total").textContent = totalFindings.toString();
const highSev = findings.filter((f) => {
const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : "";
return conf.toLowerCase() === "high";
}).length;
document.getElementById("stat-high").textContent = highSev.toString();
const validationCounts = calculateValidationCounts();
document.getElementById("stat-active").textContent = (validationCounts.active || 0).toString();
document.getElementById("stat-identities").textContent = (accessMap || []).length.toString();
const 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");
}
renderStatusChart(validationCounts);
renderSourceChart(calculateSourceCounts());
renderSourceDetectorCharts();
renderDetectorBreakdown();
updateSidebarSummary();
}
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";
}
function getFilteredSortedFindings() {
const filterLower = currentFilter.toLowerCase();
const validation = validationFilter;
const source = sourceFilter;
let arr = findings.filter((f) => {
const rule = f.rule || {};
const finding = f.finding || {};
const ruleName = (rule.name || rule.id || "").toLowerCase();
const path = (finding.path || "").toLowerCase();
const snippet = (finding.snippet || "").toLowerCase();
const fingerprint = (finding.fingerprint || "").toLowerCase();
const sourceTool = getFindingSourceTool(finding);
const status = (finding.validation && finding.validation.status
? String(finding.validation.status)
: "").toLowerCase();
if (filterLower) {
if (!ruleName.includes(filterLower) && !path.includes(filterLower) && !snippet.includes(filterLower) && !fingerprint.includes(filterLower)) {
return false;
}
}
const normalizedStatus = normalizeValidationStatus(status);
if (source !== "all" && sourceTool !== source) {
return false;
}
if (validation === "active") {
return normalizedStatus === "active";
} else if (validation === "inactive") {
return normalizedStatus === "inactive";
} else if (validation === "not_attempted") {
return normalizedStatus === "not_attempted";
} else if (validation === "canary") {
return normalizedStatus === "canary";
}
return true;
});
const dirFactor = sortDirection === "asc" ? 1 : -1;
arr.sort((a, b) => {
const ra = a.rule || {};
const rb = b.rule || {};
const fa = a.finding || {};
const fb = b.finding || {};
let va = "";
let vb = "";
switch (sortField) {
case "rule":
va = (ra.name || ra.id || "").toLowerCase();
vb = (rb.name || rb.id || "").toLowerCase();
break;
case "source":
va = getFindingSourceTool(fa);
vb = getFindingSourceTool(fb);
break;
case "location":
va = (fa.path || "").toLowerCase();
vb = (fb.path || "").toLowerCase();
break;
case "confidence":
va = (fa.confidence || "").toLowerCase();
vb = (fb.confidence || "").toLowerCase();
break;
case "validation":
va = (fa.validation && fa.validation.status ? fa.validation.status : "").toLowerCase();
vb = (fb.validation && fb.validation.status ? fb.validation.status : "").toLowerCase();
break;
case "line":
va = fa.line != null ? Number(fa.line) : Number.POSITIVE_INFINITY;
vb = fb.line != null ? Number(fb.line) : Number.POSITIVE_INFINITY;
break;
default:
return 0;
}
if (typeof va === "number" && typeof vb === "number") {
if (va < vb) return -1 * dirFactor;
if (va > vb) return 1 * dirFactor;
return 0;
} else {
if (va < vb) return -1 * dirFactor;
if (va > vb) return 1 * dirFactor;
return 0;
}
});
return arr;
}
function updateSortIndicators() {
document.querySelectorAll("th.sortable").forEach((th) => {
const field = th.dataset.field;
const span = th.querySelector(".sort-indicator");
if (!span) return;
if (field === sortField) {
span.textContent = sortDirection === "asc" ? "▲" : "▼";
} else {
span.textContent = "";
}
});
}
function trimPath(path) {
if (!path) return "-";
const parts = path.split("/");
if (parts.length > 4) {
return ".../" + parts.slice(-3).join("/");
}
return path;
}
function normalizeValidationStatus(status) {
const normalized = (status || "").toString().trim().toLowerCase();
if (normalized === "active credential" || normalized === "active") {
return "active";
}
if (normalized === "inactive credential" || normalized === "inactive") {
return "inactive";
}
if (normalized === "not attempted" || normalized === "not_attempted") {
return "not_attempted";
}
if (normalized === "canary token (skipped)" || normalized === "canary") {
return "canary";
}
return "unknown";
}
function calculateValidationCounts(list = findings) {
const counts = { active: 0, inactive: 0, not_attempted: 0, canary: 0, unknown: 0 };
(list || []).forEach((f) => {
const status =
f.finding && f.finding.validation && f.finding.validation.status
? f.finding.validation.status
: "";
const key = normalizeValidationStatus(status);
counts[key] = (counts[key] || 0) + 1;
});
return counts;
}
function 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;
}
function normalizeRuleFamilyId(ruleId) {
const normalized = String(ruleId || "").trim();
if (!normalized) return "";
return normalized.replace(/\.\d+$/, "").replace(/^kingfisher\./, "");
}
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",
];
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;
const total = dataPoints.reduce((sum, entry) => sum + (counts[entry.key] || 0), 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
const style = getComputedStyle(document.documentElement);
const surfaceColor = style.getPropertyValue("--surface") || "#0d1424";
const textColor = style.getPropertyValue("--text-main") || "#e5e7eb";
const radius = Math.min(canvas.width, canvas.height) / 2 - 10;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
ctx.save();
ctx.fillStyle = surfaceColor;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
if (total === 0) {
ctx.fillStyle = textColor;
ctx.font = "600 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(emptyLabel, centerX, centerY);
} else {
let startAngle = -Math.PI / 2;
dataPoints.forEach((entry) => {
const value = counts[entry.key] || 0;
if (!value) return;
const slice = (value / total) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.fillStyle = entry.color;
ctx.arc(centerX, centerY, radius, startAngle, startAngle + slice);
ctx.closePath();
ctx.fill();
startAngle += slice;
});
}
const innerRadius = radius * 0.55;
ctx.save();
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.save();
ctx.fillStyle = textColor;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "700 16px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
ctx.fillText(total + " findings", centerX, centerY);
ctx.restore();
if (legendEl) {
legendEl.innerHTML = "";
dataPoints.forEach((entry) => {
if ((counts[entry.key] || 0) === 0) return;
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);
legendEl.appendChild(row);
});
}
}
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>
`;
}
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",
"source_tool",
"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 || "",
getFindingSourceDisplayName(finding),
finding.path || "",
finding.line != null ? finding.line : "",
status,
finding.confidence || "",
(finding.snippet || "").replace(/\s+/g, " ").trim(),
];
});
const csv = [headers.join(",")].concat(rows.map((r) => r.map(csvEscape).join(","))).join("\n");
triggerDownload("kingfisher-findings.csv", csv, "text/csv");
}
function getFindingIdFromFinding(finding) {
if (!finding) return "";
return (
finding.id ||
finding.finding_id ||
finding.findingId ||
finding.fingerprint ||
""
);
}
function getFindingIdFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.finding_id ||
entry.findingId ||
entry.finding ||
entry.fingerprint ||
""
);
}
function getTokenNameFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.token_name ||
entry.tokenName ||
(entry.token_details && entry.token_details.name) ||
(entry.token && entry.token.name) ||
""
);
}
function getUserIdFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.user_id ||
entry.userId ||
(entry.token_details && entry.token_details.user_id) ||
(entry.token && entry.token.user_id) ||
""
);
}
function buildAccessMapListHtml({ includeMeta = false } = {}) {
if (!Array.isArray(accessMap) || accessMap.length === 0) {
return "";
}
return accessMap
.map((entry) => {
const groups = Array.isArray(entry.groups) ? entry.groups : [];
const findingId = getFindingIdFromAccessEntry(entry);
const tokenName = getTokenNameFromAccessEntry(entry);
const userId = getUserIdFromAccessEntry(entry);
const metaLine = includeMeta
? `<div style="font-size:12px; color:#475569; margin-top:4px;">
<strong>Finding ID:</strong> ${escapeHtml(findingId || "-")} ·
<strong>Token Name:</strong> ${escapeHtml(tokenName || "-")} ·
<strong>User ID:</strong> ${escapeHtml(userId || "-")}
</div>`
: "";
const groupList = groups
.map((g) => {
const resList = Array.isArray(g.resources) && g.resources.length
? g.resources.map((r) => escapeHtml(String(r))).join(", ")
: "No resources listed";
const permList = Array.isArray(g.permissions) && g.permissions.length
? g.permissions.map((p) => escapeHtml(String(p))).join(", ")
: "No permissions listed";
return `<li><strong>Resources:</strong> ${resList}<br><strong>Permissions:</strong> ${permList}</li>`;
})
.join("");
return `
<li class="access-entry">
<div class="access-head">
${escapeHtml(entry.account || "(identity)")}
<span class="tag">${escapeHtml((entry.provider || "Unknown").toUpperCase())}</span>
</div>
${metaLine}
<ul class="access-groups">
${groupList || "<li>No resources recorded.</li>"}
</ul>
</li>
`;
})
.join("");
}
function generateScanReport(scope = "all", { activeOnly = false } = {}) {
if (!findings || findings.length === 0) {
alert("Load a report before downloading a Scan report.");
return;
}
const statusOrder = { active: 0, inactive: 1, canary: 2, not_attempted: 3, unknown: 4 };
let baseFindings =
scope === "filtered" ? getFilteredSortedFindings().slice() : (Array.isArray(findings) ? findings.slice() : []);
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;
}
}
const findingsForReport = baseFindings.slice();
findingsForReport.sort((a, b) => {
const fa = a.finding || {};
const fb = b.finding || {};
const keyA = normalizeValidationStatus(fa.validation && fa.validation.status ? fa.validation.status : "");
const keyB = normalizeValidationStatus(fb.validation && fb.validation.status ? fb.validation.status : "");
const sa = statusOrder.hasOwnProperty(keyA) ? statusOrder[keyA] : 4;
const sb = statusOrder.hasOwnProperty(keyB) ? statusOrder[keyB] : 4;
if (sa !== sb) return sa - sb;
const ra = (a.rule && (a.rule.name || a.rule.id)) || "";
const rb = (b.rule && (b.rule.name || b.rule.id)) || "";
return ra.localeCompare(rb);
});
const counts = calculateValidationCounts(baseFindings);
const hasAccess = Array.isArray(accessMap) && accessMap.length > 0;
const statusImage = scope === "all" && statusChartCanvas ? statusChartCanvas.toDataURL("image/png") : "";
const highConfidence = baseFindings.filter((f) => {
const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : "";
return conf.toLowerCase() === "high";
}).length;
const scopeLabel = (scope === "filtered" ? "Filtered findings" : "All findings") + (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("");
const findingsHtml = findingsForReport.length
? findingsForReport
.map((entry) => {
const rule = entry.rule || {};
const finding = entry.finding || {};
const statusRaw = finding.validation && finding.validation.status ? finding.validation.status : "Unknown";
const normalizedStatus = normalizeValidationStatus(statusRaw);
const findingId = getFindingIdFromFinding(finding);
const gitUrl = getFileUrlFromFinding(finding);
const sourceTool = getFindingSourceDisplayName(finding);
const statusColor = normalizedStatus === "active" ? "#dc2626" : normalizedStatus === "inactive" ? "#f97316" : "#6b7280";
return `
<tr>
<td>${escapeHtml(rule.name || rule.id || "")}</td>
<td>${escapeHtml(sourceTool)}</td>
<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>"}
</tr>
`;
})
.join("")
: '<tr><td colspan="8">No findings available.</td></tr>';
// 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)}`);
if (scanMetadata.reportSource) metaLines.push(`<strong>Source:</strong> ${escapeHtml(scanMetadata.reportSource)}`);
if (scanMetadata.version) metaLines.push(`<strong>Version:</strong> ${escapeHtml(scanMetadata.version)}`);
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>`
: "";
const metaHtml = metaLines.length
? `<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}`
: "";
// 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.` : ""}
${counts.canary > 0 ? `${counts.canary} ${counts.canary !== 1 ? "were" : "was"} canary token${counts.canary !== 1 ? "s" : ""} (skipped).` : ""}
${scanMetadata.imported ? `Imported reports do not include Kingfisher access-map data or command workflows.` : ""}
</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>`;
}
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>
<span><span class="dot" style="background:#a855f7"></span> Canary Token: ${counts.canary || 0}</span>
<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>
<th style="width:10%;">Tool</th>
<th style="width:10%;">Fingerprint</th>
<th style="width:18%;">File Path</th>
<th style="width:12%;">Status</th>
<th style="width:9%;">Confidence</th>
<th style="width:5%;">Line</th>
<th style="width:21%;">Git URL</th>
</tr>
</thead>
<tbody>
${findingsHtml}
</tbody>
</table>
</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>`;
const win = window.open("", "_blank", "width=1200,height=900");
if (!win) {
alert("Please allow pop-ups to view the Scan report.");
return;
}
win.document.write(pdfHtml);
win.document.close();
win.focus();
setTimeout(() => win.print(), 400);
}
function generateAccessMapReport() {
if (!Array.isArray(accessMap) || accessMap.length === 0) {
alert("No Access Map entries are available. Run a scan with --access-map first.");
return;
}
// 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>`;
}
}
// 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>
</div>
${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 &amp; 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>`;
const win = window.open("", "_blank", "width=1200,height=900");
if (!win) {
alert("Please allow pop-ups to view the Access Map report.");
return;
}
win.document.write(pdfHtml);
win.document.close();
win.focus();
setTimeout(() => win.print(), 400);
}
function renderFindingsTable() {
const all = getFilteredSortedFindings();
const total = all.length;
const totalPages = total ? Math.ceil(total / pageSize) : 1;
if (currentPage > totalPages) currentPage = totalPages;
const startIndex = total ? (currentPage - 1) * pageSize : 0;
const endIndex = total ? Math.min(startIndex + pageSize, total) : 0;
const pageItems = total ? all.slice(startIndex, endIndex) : [];
findingsBody.innerHTML = "";
const frag = document.createDocumentFragment();
if (pageItems.length === 0) {
findingsBody.innerHTML =
'<tr><td colspan="6" style="text-align:center; padding:24px; color:var(--text-muted);">No findings match your filters.</td></tr>';
} else {
for (let i = 0; i < pageItems.length; i++) {
const f = pageItems[i] || {};
const rule = f.rule || {};
const finding = f.finding || {};
const ruleName = rule.name || rule.id || "(unknown rule)";
const path = finding.path || "";
const status =
finding.validation && finding.validation.status
? String(finding.validation.status)
: "Unknown";
const normalizedStatus = normalizeValidationStatus(status);
const badgeClass =
normalizedStatus === "active"
? "active"
: normalizedStatus === "inactive"
? "inactive"
: "unknown";
const 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>`;
const tr = document.createElement("tr");
tr.innerHTML = `
<td>
<div style="font-weight:600">${escapeHtml(ruleName)}${enrichedChip}</div>
<div style="font-size:11px; color:var(--text-muted)">${escapeHtml(rule.id || "")}</div>
</td>
<td>${sourceBadge}</td>
<td style="font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size:12px; color:var(--text-muted);" title="${escapeHtml(path)}">
${escapeHtml(trimPath(path))}
</td>
<td>
<span class="status-badge ${badgeClass}">${escapeHtml(status)}</span>
</td>
<td>${escapeHtml(finding.confidence || "")}</td>
<td style="font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;">
${finding.line != null ? finding.line : ""}
</td>
`;
tr.onclick = () => showFindingDetail(f);
frag.appendChild(tr);
}
findingsBody.appendChild(frag);
}
if (total === 0) {
pageInfo.textContent = "0 of 0";
pagePrev.disabled = true;
pageNext.disabled = true;
} else {
pageInfo.textContent = (startIndex + 1) + "" + endIndex + " of " + total;
pagePrev.disabled = currentPage <= 1;
pageNext.disabled = currentPage >= totalPages;
}
updateSortIndicators();
}
function showFindingDetail(f) {
const rule = f.rule || {};
const finding = f.finding || {};
const importInfo = finding.viewer_import && typeof finding.viewer_import === "object" ? finding.viewer_import : null;
const panel = document.getElementById("finding-detail");
panel.classList.remove("hidden");
panel.scrollIntoView({ behavior: "smooth", block: "start" });
document.getElementById("fd-rule-id").textContent = rule.id || "";
const fpEl = document.getElementById("fd-fingerprint");
const fpVal = finding.fingerprint || "";
fpEl.textContent = fpVal;
fpEl.innerHTML = fpVal; // reset content
if (fpVal && Array.isArray(accessMap)) {
const hasEntry = accessMap.some(entry => entry.fingerprint === fpVal);
if (hasEntry) {
const btn = document.createElement("button");
btn.className = "badge";
btn.textContent = "Go to Access Map";
btn.style.marginLeft = "10px";
btn.style.cursor = "pointer";
btn.style.background = "var(--brand-soft)";
btn.style.borderColor = "var(--brand)";
btn.style.color = "var(--brand-dark)";
btn.onclick = () => {
setActiveView("view-access");
const treeSearch = document.getElementById("tree-search");
if (treeSearch) {
treeSearch.value = fpVal;
treeSearch.dispatchEvent(new Event('input'));
}
};
fpEl.appendChild(btn);
}
}
document.getElementById("fd-entropy").textContent =
finding.entropy != null ? String(finding.entropy) : "";
const commit =
finding.git_metadata && finding.git_metadata.commit && finding.git_metadata.commit.id
? finding.git_metadata.commit.id.substring(0, 8)
: "N/A";
document.getElementById("fd-commit").textContent = commit;
const committerWrapper = document.getElementById("fd-committer-email-wrapper");
const committerEmailEl = document.getElementById("fd-committer-email");
const committerEmail =
finding.git_metadata &&
finding.git_metadata.commit &&
finding.git_metadata.commit.committer &&
finding.git_metadata.commit.committer.email
? String(finding.git_metadata.commit.committer.email)
: "";
if (committerWrapper && committerEmailEl) {
if (committerEmail) {
committerWrapper.style.display = "";
committerEmailEl.textContent = committerEmail;
} else {
committerWrapper.style.display = "none";
committerEmailEl.textContent = "";
}
}
const statusRaw =
finding.validation && finding.validation.status ? String(finding.validation.status) : "Unknown";
const normalizedStatus = normalizeValidationStatus(statusRaw);
const badgeClass =
normalizedStatus === "active"
? "active"
: normalizedStatus === "inactive"
? "inactive"
: "unknown";
const statusEl = document.getElementById("fd-validation-status");
if (statusEl) {
statusEl.innerHTML = `<span class="status-badge ${badgeClass}">${escapeHtml(statusRaw)}</span>`;
}
const sourceEl = document.getElementById("fd-source");
if (sourceEl) {
sourceEl.textContent = importInfo && importInfo.source_tool ? importInfo.source_tool : (scanMetadata.reportSource || "Kingfisher");
}
const path = finding.path || "";
if (fdPathInput) {
fdPathInput.value = path || "—";
fdPathInput.style.height = "auto";
fdPathInput.style.height = Math.min(fdPathInput.scrollHeight, 260) + "px";
}
const gitUrlWrapper = document.getElementById("fd-git-url-wrapper");
const gitUrlEl = document.getElementById("fd-git-url");
const gitUrl = getFileUrlFromFinding(finding);
gitUrlEl.innerHTML = "";
if (gitUrl) {
gitUrlWrapper.style.display = "";
const a = document.createElement("a");
a.href = gitUrl;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = gitUrl;
gitUrlEl.appendChild(a);
} else {
gitUrlWrapper.style.display = "none";
}
document.getElementById("fd-snippet").textContent = finding.snippet || "";
const 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 = "";
}
}
renderKingfisherEnrichment(finding);
const valBox = document.getElementById("fd-validation-box");
if (finding.validation && finding.validation.response) {
valBox.classList.remove("hidden");
document.getElementById("fd-validation-res").textContent =
finding.validation.response;
} else {
valBox.classList.add("hidden");
document.getElementById("fd-validation-res").textContent = "";
}
// Validate command section
const validateBox = document.getElementById("fd-validate-box");
const validateCmd = document.getElementById("fd-validate-cmd");
const validateCopyBtn = document.getElementById("fd-validate-copy");
if (finding.validate_command && (!importInfo || scanMetadata.capabilities.validateCommandSupported)) {
validateBox.classList.remove("hidden");
validateCmd.textContent = finding.validate_command;
// Set up copy button
validateCopyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(finding.validate_command);
validateCopyBtn.classList.add("copied");
validateCopyBtn.querySelector("span").textContent = "Copied!";
setTimeout(() => {
validateCopyBtn.classList.remove("copied");
validateCopyBtn.querySelector("span").textContent = "Copy";
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
} else {
validateBox.classList.add("hidden");
validateCmd.textContent = "";
}
// Revoke command section
const revokeBox = document.getElementById("fd-revoke-box");
const revokeCmd = document.getElementById("fd-revoke-cmd");
const revokeCopyBtn = document.getElementById("fd-revoke-copy");
if (finding.revoke_command && (!importInfo || scanMetadata.capabilities.revokeCommandSupported)) {
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 = "";
}
// Blast radius
currentDetailFinding = f;
renderBlastRadius(f);
}
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;
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 || {}));
}
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)";
}
}
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 || {};
const importInfo = fd.viewer_import && typeof fd.viewer_import === "object" ? fd.viewer_import : null;
const statusRaw = fd.validation && fd.validation.status ? String(fd.validation.status) : "";
const normalizedStatus = normalizeValidationStatus(statusRaw);
const isActive = normalizedStatus === "active";
if (!accessEntries || accessEntries.length === 0) {
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.`,
};
}
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);
const sourceTool = getFindingSourceDisplayName(finding);
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>
<div class="detail-item"><label>Tool</label><div>${escapeHtml(sourceTool)}</div></div>
<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
const statusOrder = { active: 0, inactive: 1, canary: 2, not_attempted: 3, unknown: 4 };
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);
const sourceTool = getFindingSourceDisplayName(finding);
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>
<div><strong>Tool:</strong> ${escapeHtml(sourceTool)}</div>
<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();
}
// 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 };
}
function renderAccessMapTree(filter = "") {
const list = document.getElementById("am-card-list");
list.innerHTML = "";
const filterLower = (filter || "").toLowerCase();
filteredAccessMapView = buildRenderableAccessMap(filterLower);
const hasAccess = Array.isArray(accessMap) && accessMap.length > 0;
syncAccessMapUi(hasAccess);
renderAccessMapStats();
syncCriticalToggleVisibility();
// When "Critical only" is active, drop entries that have no admin/privesc/risky perms.
let visible = filteredAccessMapView;
if (isCriticalOnly()) {
visible = visible.filter(entryHasCritical);
}
if (!visible || visible.length === 0) {
list.innerHTML =
`<div style="padding:32px 16px; text-align:center; color:var(--text-muted); font-size:13px;">${getAccessMapEmptyMessage()}</div>`;
return;
}
// 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);
});
const frag = document.createDocumentFragment();
providerOrder.forEach((prov) => {
const entries = byProvider.get(prov);
frag.appendChild(buildProviderSection(prov, entries));
});
list.appendChild(frag);
}
function buildProviderSection(provider, entries) {
const section = document.createElement("div");
section.className = "am-provider-section";
// 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();
let totalRes = 0;
const permSet = new Set();
let admin = 0;
let privesc = 0;
let risky = 0;
entries.forEach((e) => {
const byClass = e.identity && e.identity.permissions_by_severity;
const groups = criticalMode
? (e.groups || []).map((g) => applyCriticalFilter(g, byClass))
: (e.groups || []);
groups.forEach((g) => {
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
);
// 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);
}
wrap.style.display = anyClassified ? "" : "none";
}
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();
let totalAdmin = 0;
let identitiesWithAdmin = 0;
accessMap.forEach((entry) => {
providerSet.add((entry.provider || "unknown").toUpperCase());
(entry.groups || []).forEach((g) => {
totalRes += (g.resources || []).length;
(g.permissions || []).forEach((p) => permSet.add(p));
});
const s = summarizeSeverity({ identity: entry, groups: entry.groups || [] });
totalAdmin += s.admin;
if (s.admin > 0) identitiesWithAdmin += 1;
});
bar.classList.remove("hidden");
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>`
: "";
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>
${adminStat}
<div class="am-stat">${[...providerSet].map((p) => '<span class="badge ' + providerBadgeClass(p) + '">' + escapeHtml(p) + '</span>').join(" ")}</div>
`;
}
function buildIdentityCard(entry) {
const identity = entry.identity;
const byClass = identity.permissions_by_severity || null;
const provider = (identity.provider || "unknown");
const account = identity.account || "(unknown identity)";
const fingerprint = identity.fingerprint || "";
// 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.
let resCount = 0;
const allPerms = new Set();
groups.forEach((g) => {
resCount += (g.resources || []).length;
(g.permissions || []).forEach((p) => allPerms.add(p));
});
const permArr = [...allPerms];
const sevSummary = summarizeSeverity(entry);
// 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);
// 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);
}
// 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);
// Severity rollup chips replace the per-permission preview.
// 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();
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 }) => {
if (rollupCriticalMode && key === "read_only") return;
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).
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);
});
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);
}
const toggle = document.createElement("div");
toggle.className = "id-card__toggle";
toggle.textContent = "▶";
header.appendChild(avatar);
header.appendChild(info);
header.appendChild(toggle);
// Body (expandable)
const body = document.createElement("div");
body.className = "id-card__body";
// 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.
const resSection = document.createElement("div");
resSection.className = "id-card__section";
const resTitle = document.createElement("div");
resTitle.className = "id-card__section-title";
resTitle.textContent = "Resources & permissions (" + resCount + " resource" + (resCount !== 1 ? "s" : "") + ", " + groups.length + " group" + (groups.length !== 1 ? "s" : "") + ")";
resSection.appendChild(resTitle);
groups.forEach((group, idx) => {
const resources = group.resources || [];
const perms = group.permissions || [];
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);
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);
});
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);
}
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);
}
// 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) => {
const tag = document.createElement("span");
tag.className = "badge badge-perm badge-perm--" + sevCls;
tag.style.fontSize = "10px";
tag.textContent = p;
pills.appendChild(tag);
});
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);
});
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);
});
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 ? "▶" : "▼";
});
return card;
}
function buildRenderableAccessMap(filterLower) {
if (!accessMap || accessMap.length === 0) return [];
const identities = [];
accessMap.forEach((identity) => {
const account = formatIdentityLabel(identity);
const groups = Array.isArray(identity.groups) ? identity.groups : [];
const fingerprint = (identity.fingerprint || "").toLowerCase();
const identityNameMatches = Boolean(filterLower) && (account.toLowerCase().includes(filterLower) || fingerprint.includes(filterLower));
let anyResourceMatches = false;
let anyPermMatches = false;
const preparedGroups = groups.map((group) => {
const resources = Array.isArray(group.resources) ? group.resources : [];
const perms = Array.isArray(group.permissions) ? group.permissions : [];
const filteredResources = resources.filter((resName) => {
const resLower = String(resName).toLowerCase();
const matches = !filterLower || resLower.includes(filterLower) || identityNameMatches;
if (!identityNameMatches && filterLower && resLower.includes(filterLower)) {
anyResourceMatches = true;
}
return matches;
});
if (filterLower && !identityNameMatches) {
perms.forEach((p) => {
if (String(p).toLowerCase().includes(filterLower)) anyPermMatches = true;
});
}
return { resources, filteredResources, permissions: perms };
});
const hasMatch = !filterLower || identityNameMatches || anyResourceMatches || anyPermMatches;
if (!hasMatch) return;
const viewGroups = preparedGroups.map((group) => ({
resources: !filterLower || identityNameMatches || anyPermMatches ? group.resources : group.filteredResources,
permissions: group.permissions,
}));
identities.push({ identity, groups: viewGroups, identityNameMatches });
});
return identities;
}
function copyFilteredAccessMap() {
if (!filteredAccessMapView || filteredAccessMapView.length === 0) {
alert("No access map entries are available for the current filter.");
return;
}
const payload = filteredAccessMapView.map((entry) => {
return Object.assign({}, entry.identity, { groups: entry.groups });
});
const text = JSON.stringify(payload, null, 2);
const fallbackCopy = () => {
const area = document.createElement("textarea");
area.value = text;
document.body.appendChild(area);
area.select();
document.execCommand("copy");
document.body.removeChild(area);
alert("Copied access map for " + payload.length + " identities.");
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => alert("Copied access map for " + payload.length + " identities."))
.catch(fallbackCopy);
} else {
fallbackCopy();
}
}
function addBadge(container, text, cls) {
const span = document.createElement("span");
span.className = "badge " + cls;
span.textContent = text;
container.appendChild(span);
}
function providerBadgeClass(provider) {
const normalized = String(provider || "").toLowerCase();
if (normalized === "aws") return "badge-aws";
if (normalized === "gcp") return "badge-gcp";
if (normalized === "azure") return "badge-azure";
if (normalized === "github") return "badge-github";
if (normalized === "gitlab") return "badge-gitlab";
return "badge-aws";
}
function formatIdentityLabel(identity) {
const base = identity && identity.account ? identity.account : "unknown-identity";
const provider = identity && identity.provider ? identity.provider.toUpperCase() : null;
return provider ? `[${provider}] ${base}` : base;
}
function extractResourceParts(label) {
if (!label) return { resourceType: "", resourceName: "" };
const idx = label.indexOf(":");
if (idx === -1) return { resourceType: "", resourceName: label };
return { resourceType: label.slice(0, idx), resourceName: label.slice(idx + 1) };
}
function buildResourceConsoleLink(provider, resourceType, resourceName) {
if (!provider || !resourceName) return null;
const normalizedProvider = provider.toLowerCase();
if (normalizedProvider === "aws") return awsResourceConsoleLink(resourceName);
if (normalizedProvider === "gcp") return gcpResourceConsoleLink(resourceName);
return null;
}
function awsResourceConsoleLink(resource) {
if (!resource || !resource.startsWith("arn:")) return null;
const parts = resource.split(":");
if (parts.length < 6) return null;
const service = parts[2];
const region = parts[3];
const resourcePart = parts[5] || "";
if (service === "s3") {
const bucket = resourcePart.replace(/^\/*/, "");
return `https://console.aws.amazon.com/s3/buckets/${encodeURIComponent(bucket)}`;
}
if (service === "iam") {
const match = resource.match(/^arn:aws:iam::\d+:([^/]+)\/(.+)$/);
const kind = match ? match[1] : null;
const res = match ? match[2] : null;
if (kind === "role") return `https://console.aws.amazon.com/iam/home?#/roles/${encodeURIComponent(res)}`;
if (kind === "user") return `https://console.aws.amazon.com/iam/home?#/users/${encodeURIComponent(res)}`;
return null;
}
if (service === "lambda") {
const match = resourcePart.match(/^function[:\/](.+)$/);
if (match && match[1]) {
const regionQuery = region ? `?region=${encodeURIComponent(region)}` : "";
return `https://console.aws.amazon.com/lambda/home${regionQuery}#/functions/${encodeURIComponent(match[1])}`;
}
return null;
}
if (service === "ec2") {
const match = resourcePart.match(/^instance\/(.+)$/);
if (match && match[1]) return `https://console.aws.amazon.com/ec2/v2/home?#InstanceDetails:instanceId=${encodeURIComponent(match[1])}`;
return null;
}
if (service === "kms") {
const match = resourcePart.match(/^(?:key|alias)\/(.+)$/);
if (match && match[1]) return `https://console.aws.amazon.com/kms/home?#/kms/keys/${encodeURIComponent(match[1])}`;
return null;
}
if (service === "secretsmanager") return `https://console.aws.amazon.com/secretsmanager/home?#/secret?name=${encodeURIComponent(resource)}`;
if (service === "dynamodb") {
const match = resourcePart.match(/^(?:table\/(.+)|table:(.+))/);
const tableRaw = match ? match[1] || match[2] : null;
const table = tableRaw ? tableRaw.split("/")[0] : null;
if (table) {
const regionQuery = region ? `?region=${encodeURIComponent(region)}` : "";
return `https://console.aws.amazon.com/dynamodbv2/home${regionQuery}#/table/${encodeURIComponent(table)}/items`;
}
return null;
}
return null;
}
function gcpResourceConsoleLink(resource) {
if (!resource) return null;
const projectMatch = resource.match(/^projects\/([^/]+)/);
const project = projectMatch ? projectMatch[1] : null;
if (resource.includes("/buckets/")) {
const bucketMatch = resource.match(/\/buckets\/([^/]+)/);
if (bucketMatch && bucketMatch[1] && project) return `https://console.cloud.google.com/storage/browser/${encodeURIComponent(bucketMatch[1])}?project=${encodeURIComponent(project)}`;
}
if (resource.includes("/datasets/")) {
const datasetMatch = resource.match(/\/datasets\/([^/]+)/);
if (datasetMatch && datasetMatch[1] && project) return `https://console.cloud.google.com/bigquery?project=${encodeURIComponent(project)}&p=${encodeURIComponent(project)}&d=${encodeURIComponent(datasetMatch[1])}&page=dataset`;
}
if (resource.includes("/secrets/")) {
const secretMatch = resource.match(/\/secrets\/([^/:]+)/);
if (secretMatch && secretMatch[1] && project) return `https://console.cloud.google.com/security/secret-manager/secret/${encodeURIComponent(secretMatch[1])}/versions?project=${encodeURIComponent(project)}`;
}
if (resource.includes("/functions/")) {
const fnMatch = resource.match(/\/locations\/([^/]+)\/functions\/([^/]+)/);
if (fnMatch && project) return `https://console.cloud.google.com/functions/details/${encodeURIComponent(fnMatch[1])}/${encodeURIComponent(fnMatch[2])}?project=${encodeURIComponent(project)}`;
}
if (project) return `https://console.cloud.google.com/home/dashboard?project=${encodeURIComponent(project)}`;
return null;
}
</script>
</body>
</html>