Added a 'kingfisher view' subcommand that serves the bundled access-map HTML viewer from the binary so users can load JSON or JSONL reports passed on the CLI (or upload them in the browser) over a configurable local-only port.

This commit is contained in:
Mick Grove 2025-12-05 22:24:16 -08:00
commit 19cd75293f
4 changed files with 271 additions and 50 deletions

View file

@ -565,7 +565,9 @@
<span class="hero__subtitle">Access Map &amp; Findings</span>
</div>
</div>
<button class="btn" id="reset-btn">Reset</button>
<div style="display:flex; gap:10px;">
<button class="btn" id="reset-btn">Load New Report</button>
</div>
</header>
<main class="page">
@ -620,7 +622,10 @@
<h3>Access Map</h3>
<p>Identity hierarchy: Identity &gt; Resources &gt; Resource &gt; Permissions</p>
</div>
<button class="btn" id="am-toggle" type="button">Collapse</button>
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn" id="copy-access-map" type="button">Copy Access Map</button>
<button class="btn" id="am-toggle" type="button">Collapse</button>
</div>
</div>
<div class="am-container" id="am-container">
<div class="am-sidebar">
@ -673,6 +678,8 @@
<p>Detailed list of detected secrets</p>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<button class="btn" id="download-json" type="button">Download JSON</button>
<button class="btn" id="download-csv" type="button">Export CSV</button>
<input type="text" id="search-input" class="search-input" placeholder="Search rule, path, snippet">
<select id="validation-filter" class="rows-select">
<option value="all" selected>All validation states</option>
@ -774,6 +781,7 @@
let rawData = null;
let findings = [];
let accessMap = [];
let filteredAccessMapView = [];
let currentFilter = "";
let validationFilter = "all";
let pageSize = 10;
@ -796,10 +804,13 @@
const pageNext = document.getElementById("page-next");
const pageInfo = document.getElementById("page-info");
const findingsBody = document.getElementById("findings-body");
const downloadJsonBtn = document.getElementById("download-json");
const downloadCsvBtn = document.getElementById("download-csv");
const treeSearch = document.getElementById("tree-search");
const amContainer = document.getElementById("am-container");
const amToggle = document.getElementById("am-toggle");
const copyAccessMapButton = document.getElementById("copy-access-map");
dropZone.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => {
@ -819,7 +830,17 @@
if (e.dataTransfer.files.length) processFile(e.dataTransfer.files[0]);
});
document.getElementById("reset-btn").addEventListener("click", () => location.reload());
const resetButton = document.getElementById("reset-btn");
resetButton.addEventListener("click", () => {
const confirmReset = confirm(
"Loading a new report will clear the currently loaded data (your file stays on disk). Continue?",
);
if (confirmReset) {
resetViewer(true);
}
});
searchInput.addEventListener("input", (e) => {
currentFilter = e.target.value || "";
@ -869,6 +890,12 @@
}
});
downloadJsonBtn.addEventListener("click", downloadFindingsJson);
downloadCsvBtn.addEventListener("click", downloadFindingsCsv);
copyAccessMapButton.addEventListener("click", copyFilteredAccessMap);
loadCliReport();
document.querySelectorAll("th.sortable").forEach((th) => {
th.addEventListener("click", () => {
const field = th.dataset.field;
@ -932,6 +959,73 @@
}, 30);
}
async function loadCliReport() {
try {
loaderText.textContent = "Loading report from CLI…";
loader.classList.remove("hidden");
const response = await fetch("/report", { cache: "no-store" });
if (response.status === 404) {
loader.classList.add("hidden");
return;
}
if (!response.ok) {
throw new Error("Server returned status " + response.status);
}
const text = await response.text();
parseAndRender(text);
} catch (err) {
loader.classList.add("hidden");
errorMsg.textContent = "Failed to load report from CLI: " + err.message;
errorMsg.classList.remove("hidden");
}
}
function resetViewer(promptFilePicker = false) {
findings = [];
accessMap = [];
filteredAccessMapView = [];
rawData = null;
currentFilter = "";
validationFilter = "all";
pageSize = 10;
currentPage = 1;
sortField = "rule";
sortDirection = "asc";
searchInput.value = "";
validationSelect.value = "all";
rowsSelect.value = "10";
treeSearch.value = "";
document.getElementById("am-empty-state").classList.remove("hidden");
document.getElementById("am-detail-view").classList.add("hidden");
amContainer.classList.remove("hidden");
amToggle.textContent = "Collapse";
renderAccessMapTree();
renderFindingsTable();
updateMetrics();
dashboard.classList.add("hidden");
uploadSection.classList.remove("hidden");
errorMsg.classList.add("hidden");
errorMsg.textContent = "";
loader.classList.add("hidden");
loaderText.textContent = "Processing report...";
fileInput.value = "";
if (promptFilePicker) {
fileInput.click();
}
}
function parseAndRender(text) {
const t0 = performance.now();
findings = [];
@ -1171,6 +1265,73 @@
return "unknown";
}
function triggerDownload(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function downloadFindingsJson() {
const filtered = getFilteredSortedFindings();
if (!filtered.length) {
alert("No findings are available for the current filters.");
return;
}
const json = JSON.stringify(filtered, null, 2);
triggerDownload("kingfisher-findings.json", json, "application/json");
}
function csvEscape(value) {
const str = String(value ?? "");
if (/[",\n]/.test(str)) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
function downloadFindingsCsv() {
const filtered = getFilteredSortedFindings();
if (!filtered.length) {
alert("No findings are available for the current filters.");
return;
}
const headers = [
"rule_id",
"rule_name",
"file_path",
"line",
"validation_status",
"confidence",
"snippet",
];
const rows = filtered.map((entry) => {
const rule = entry.rule || {};
const finding = entry.finding || {};
const status =
finding.validation && finding.validation.status ? finding.validation.status : "";
return [
rule.id || "",
rule.name || "",
finding.path || "",
finding.line != null ? finding.line : "",
status,
finding.confidence || "",
(finding.snippet || "").replace(/\s+/g, " ").trim(),
];
});
const csv = [headers.join(",")].concat(rows.map((r) => r.map(csvEscape).join(","))).join("\n");
triggerDownload("kingfisher-findings.csv", csv, "text/csv");
}
function renderFindingsTable() {
const all = getFilteredSortedFindings();
const total = all.length;
@ -1308,49 +1469,18 @@
root.innerHTML = "";
const filterLower = (filter || "").toLowerCase();
if (!accessMap || accessMap.length === 0) {
filteredAccessMapView = buildRenderableAccessMap(filterLower);
if (!filteredAccessMapView || filteredAccessMapView.length === 0) {
root.innerHTML =
'<div style="padding:16px; text-align:center; color:var(--text-muted); font-size:13px;">No access map data available</div>';
return;
}
accessMap.forEach((identity) => {
filteredAccessMapView.forEach((entry) => {
const identity = entry.identity;
const account = identity.account || "unknown-identity";
const accountLower = account.toLowerCase();
const groups = Array.isArray(identity.groups) ? identity.groups : [];
const identityNameMatches = filterLower && accountLower.includes(filterLower);
let anyResourceMatches = false;
const preparedGroups = groups.map((group) => {
const resources = Array.isArray(group.resources) ? group.resources : [];
const perms = Array.isArray(group.permissions) ? group.permissions : [];
const filteredResources = [];
resources.forEach((resName) => {
const resLower = String(resName).toLowerCase();
const matches =
!filterLower || resLower.includes(filterLower) || identityNameMatches;
if (matches) {
filteredResources.push(resName);
if (!identityNameMatches && filterLower && resLower.includes(filterLower)) {
anyResourceMatches = true;
}
}
});
return { resources: filteredResources, permissions: perms, originalResources: resources };
});
let hasMatch;
if (!filterLower) {
hasMatch = true;
} else {
hasMatch = identityNameMatches || anyResourceMatches;
}
if (!hasMatch) return;
const identityNameMatches = entry.identityNameMatches;
const idNode = createTreeNode(account, "identity", true);
idNode.container.style.borderLeft = "none";
idNode.container.style.marginLeft = "0";
@ -1368,10 +1498,9 @@
});
idNode.childrenContainer.appendChild(resGroup.container);
preparedGroups.forEach((group) => {
const resourcesToShow =
!filterLower || identityNameMatches ? group.originalResources : group.resources;
const perms = group.permissions;
entry.groups.forEach((group) => {
const resourcesToShow = Array.isArray(group.resources) ? group.resources : [];
const perms = Array.isArray(group.permissions) ? group.permissions : [];
if (resourcesToShow && resourcesToShow.length > 0) {
resourcesToShow.forEach((resName) => {
@ -1413,6 +1542,78 @@
});
}
function buildRenderableAccessMap(filterLower) {
if (!accessMap || accessMap.length === 0) return [];
const identities = [];
accessMap.forEach((identity) => {
const account = identity.account || "unknown-identity";
const groups = Array.isArray(identity.groups) ? identity.groups : [];
const identityNameMatches = Boolean(filterLower) && account.toLowerCase().includes(filterLower);
let anyResourceMatches = false;
const preparedGroups = groups.map((group) => {
const resources = Array.isArray(group.resources) ? group.resources : [];
const perms = Array.isArray(group.permissions) ? group.permissions : [];
const filteredResources = resources.filter((resName) => {
const resLower = String(resName).toLowerCase();
const matches = !filterLower || resLower.includes(filterLower) || identityNameMatches;
if (!identityNameMatches && filterLower && resLower.includes(filterLower)) {
anyResourceMatches = true;
}
return matches;
});
return { resources, filteredResources, permissions: perms };
});
const hasMatch = !filterLower || identityNameMatches || anyResourceMatches;
if (!hasMatch) return;
const viewGroups = preparedGroups.map((group) => ({
resources: !filterLower || identityNameMatches ? group.resources : group.filteredResources,
permissions: group.permissions,
}));
identities.push({ identity, groups: viewGroups, identityNameMatches });
});
return identities;
}
function copyFilteredAccessMap() {
if (!filteredAccessMapView || filteredAccessMapView.length === 0) {
alert("No access map entries are available for the current filter.");
return;
}
const payload = filteredAccessMapView.map((entry) => {
return Object.assign({}, entry.identity, { groups: entry.groups });
});
const text = JSON.stringify(payload, null, 2);
const fallbackCopy = () => {
const area = document.createElement("textarea");
area.value = text;
document.body.appendChild(area);
area.select();
document.execCommand("copy");
document.body.removeChild(area);
alert("Copied access map for " + payload.length + " identities.");
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => alert("Copied access map for " + payload.length + " identities."))
.catch(fallbackCopy);
} else {
fallbackCopy();
}
}
function createTreeNode(label, type, isOpen) {
const container = document.createElement("div");
container.className = "tree-node";

View file

@ -1,4 +1,8 @@
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
};
use anyhow::{anyhow, Context, Result};
use axum::{
@ -37,6 +41,7 @@ struct AppState {
/// Run the `kingfisher view` subcommand.
pub async fn run(args: ViewArgs) -> Result<()> {
let report = if let Some(path) = args.report.as_ref() {
let expanded_path = expand_tilde(path)?;
let ext = path
.extension()
.and_then(|ext| ext.to_str())
@ -48,9 +53,9 @@ pub async fn run(args: ViewArgs) -> Result<()> {
}
Some(
tokio::fs::read(path)
tokio::fs::read(&expanded_path)
.await
.with_context(|| format!("Failed to read report at {}", path.display()))?,
.with_context(|| format!("Failed to read report at {}", expanded_path.display()))?,
)
} else {
None
@ -189,3 +194,15 @@ fn apply_security_headers(response: Response) -> Response {
);
response
}
fn expand_tilde(path: &Path) -> Result<PathBuf> {
let path_str = path.to_string_lossy();
if path_str == "~" || path_str.starts_with("~/") {
let home = std::env::var("HOME")
.context("Could not resolve home directory for tilde-expanded path")?;
let trimmed = path_str.trim_start_matches("~/");
return Ok(PathBuf::from(home).join(trimmed));
}
Ok(path.to_path_buf())
}

View file

@ -6,7 +6,9 @@ use strum::Display;
use sysinfo::{MemoryRefreshKind, RefreshKind, System};
use tracing::Level;
use crate::cli::commands::{access_map::AccessMapArgs, rules::RulesArgs, scan::ScanCommandArgs, view::ViewArgs};
use crate::cli::commands::{
access_map::AccessMapArgs, rules::RulesArgs, scan::ScanCommandArgs, view::ViewArgs,
};
#[deny(missing_docs)]
#[derive(Parser, Debug)]
@ -69,7 +71,6 @@ pub enum Command {
/// View an access-map report locally
View(ViewArgs),
/// Update the Kingfisher binary
#[command(name = "self-update")]
SelfUpdate,

View file

@ -338,7 +338,9 @@ async fn async_main(args: CommandLineArgs) -> Result<()> {
run_rules_list(&list_args)?;
}
},
Command::View(_) => { anyhow::bail!("View command should not reach this branch") },
Command::View(_) => {
anyhow::bail!("View command should not reach this branch")
}
Command::AccessMap(_) => {
anyhow::bail!("AccessMap command should not reach this branch")
}