forked from mirrors/kingfisher
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:
parent
33412d04be
commit
19cd75293f
4 changed files with 271 additions and 50 deletions
|
|
@ -565,7 +565,9 @@
|
|||
<span class="hero__subtitle">Access Map & 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 > Resources > Resource > 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";
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue