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 21:57:20 -08:00
commit 33412d04be
9 changed files with 787 additions and 3 deletions

View file

@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
## [v1.70.0]
- Added new rules for AWS Bedrock, Voyage.ai, Posthog
- 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.
## [v1.69.0]
- Reduced per-match memory usage by compacting stored source locations and interning repeated capture names.

View file

@ -85,6 +85,7 @@ reqwest = { version = "0.12", default-features = false, features = [
"blocking",
"multipart",
] }
axum = { version = "0.7", default-features = false, features = ["tokio", "http1"] }
chrono = "0.4.42"

View file

@ -79,6 +79,7 @@ See ([docs/COMPARISON.md](docs/COMPARISON.md))
- [Output JSON and capture to a file](#output-json-and-capture-to-a-file)
- [Output SARIF directly to disk](#output-sarif-directly-to-disk)
- [Access map outputs and viewer](#access-map-outputs-and-viewer)
- [View access-map reports locally](#view-access-map-reports-locally)
- [Pipe any text directly into Kingfisher by passing `-`](#pipe-any-text-directly-into-kingfisher-by-passing--)
- [Limit maximum file size scanned (`--max-file-size`)](#limit-maximum-file-size-scanned---max-file-size)
- [Scan using a rule _family_ with one flag](#scan-using-a-rule-family-with-one-flag)
@ -419,7 +420,16 @@ kingfisher scan /path/to/repo --format sarif --output findings.sarif
- Add `--access-map` to enrich JSON, JSONL, BSON, pretty, and SARIF reports with an `access_map` array containing providers, accounts/projects, resources, and the permissions available for each resource (grouped when identical).
- If you validated cloud credentials without `--access-map`, Kingfisher will remind you on stderr to rerun with the flag so the access map appears in the output.
- Open `docs/access-map-viewer/index.html` in a browser to explore a report locally; the viewer accepts the same JSON/JSONL payloads and includes a bundled sample (`sample-report.json`).
- Run `kingfisher view ./kingfisher.json` to explore a report locally in a local web UI
### View access-map reports locally
```bash
kingfisher view kingfisher.json
```
The `view` subcommand starts a local-only server (default port `7890`) that bundles the HTML, CSS, and JavaScript for the access-map viewer directly into the Kingfisher binary. Provide a JSON or JSONL report to load it automatically, or open the page and upload a report in the browser. If port 7890 is already in use, Kingfisher will exit and tell you to re-run with `--port <PORT>`.
### Pipe any text directly into Kingfisher by passing `-`

View file

@ -0,0 +1,571 @@
let rawData = null;
let findings = [];
let accessMap = [];
let currentFilter = "";
let validationFilter = "all";
let pageSize = 10;
let currentPage = 1;
let sortField = "rule";
let sortDirection = "asc";
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 searchInput = document.getElementById("search-input");
const validationSelect = document.getElementById("validation-filter");
const rowsSelect = document.getElementById("rows-select");
const pagePrev = document.getElementById("page-prev");
const pageNext = document.getElementById("page-next");
const pageInfo = document.getElementById("page-info");
const findingsBody = document.getElementById("findings-body");
const resetButton = document.getElementById("reset-btn");
const treeSearch = document.getElementById("tree-search");
const amContainer = document.getElementById("am-container");
const amToggle = document.getElementById("am-toggle");
dropZone.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => {
if (e.target.files.length) processFile(e.target.files[0]);
});
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("active");
});
dropZone.addEventListener("dragleave", (e) => {
e.preventDefault();
dropZone.classList.remove("active");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("active");
if (e.dataTransfer.files.length) processFile(e.dataTransfer.files[0]);
});
if (resetButton) {
resetButton.addEventListener("click", () => {
rawData = null;
findings = [];
accessMap = [];
currentFilter = "";
validationFilter = "all";
pageSize = 10;
currentPage = 1;
sortField = "rule";
sortDirection = "asc";
searchInput.value = "";
validationSelect.value = "all";
rowsSelect.value = "10";
treeSearch.value = "";
amContainer.classList.remove("hidden");
amToggle.textContent = "Collapse";
findingsBody.innerHTML = "";
document.getElementById("access-tree").innerHTML =
'<div style="color:var(--text-muted); font-size:13px; text-align:center; margin-top:32px;">No access map data found in report.</div>';
resetError();
uploadSection.classList.remove("hidden");
dashboard.classList.add("hidden");
setLoading(false);
});
}
searchInput.addEventListener("input", () => {
currentFilter = searchInput.value.trim().toLowerCase();
currentPage = 1;
renderTable();
});
validationSelect.addEventListener("change", () => {
validationFilter = validationSelect.value;
currentPage = 1;
renderTable();
});
rowsSelect.addEventListener("change", () => {
pageSize = parseInt(rowsSelect.value, 10);
currentPage = 1;
renderTable();
});
pagePrev.addEventListener("click", () => {
if (currentPage > 1) {
currentPage--;
renderTable();
}
});
pageNext.addEventListener("click", () => {
const totalPages = Math.max(1, Math.ceil(filteredFindings().length / pageSize));
if (currentPage < totalPages) {
currentPage++;
renderTable();
}
});
amToggle.addEventListener("click", () => {
amContainer.classList.toggle("hidden");
amToggle.textContent = amContainer.classList.contains("hidden") ? "Show Access Map" : "Hide Access Map";
});
function processFile(file) {
resetError();
setLoading(true, `Reading ${file.name}...`);
const reader = new FileReader();
reader.onload = (e) => {
try {
rawData = e.target.result;
parseData(rawData);
} catch (err) {
setError(`Failed to read file: ${err.message}`);
} finally {
setLoading(false);
}
};
reader.onerror = () => {
setError("Error reading file. Please try again.");
setLoading(false);
};
reader.readAsText(file);
}
async function loadEmbeddedReport() {
try {
setLoading(true, "Loading CLI report...");
const response = await fetch("/report", { cache: "no-store" });
if (response.status === 404) {
setLoading(false);
return;
}
if (!response.ok) {
throw new Error(`server returned ${response.status}`);
}
const text = await response.text();
rawData = text;
await parseData(text);
} catch (err) {
setLoading(false);
setError(`Failed to load report from CLI: ${err.message}`);
}
}
async function parseData(text) {
const parsed = parsePayload(text);
findings = parsed.findings.map(normalizeFinding);
accessMap = flattenAccessMap(normalizeAccessMap(parsed.access_map));
renderStats();
buildAccessTree(accessMap);
renderTable();
showDashboard();
}
function parsePayload(text) {
try {
const parsed = JSON.parse(text);
return collectReportData(parsed);
} catch (_) {
return collectReportDataFromJsonl(text);
}
}
function collectReportData(root) {
const findings = [];
const accessMap = [];
const visit = (node) => {
if (node === null || node === undefined) return;
if (Array.isArray(node)) {
for (const item of node) {
visit(item);
}
return;
}
if (typeof node !== "object") return;
if (node.rule && node.finding) {
findings.push(node);
}
if (Array.isArray(node.findings)) {
findings.push(...node.findings);
}
if (Array.isArray(node.access_map)) {
accessMap.push(...node.access_map);
}
Object.values(node).forEach(visit);
};
visit(root);
if (!findings.length && Array.isArray(root)) {
findings.push(...root);
}
return { findings, access_map: accessMap };
}
function collectReportDataFromJsonl(text) {
const findings = [];
const accessMap = [];
const lines = text.split(/\r?\n/).filter(Boolean);
for (const line of lines) {
try {
const obj = JSON.parse(line);
const parsed = collectReportData(obj);
findings.push(...parsed.findings);
accessMap.push(...parsed.access_map);
} catch (_) {
/* ignore invalid lines */
}
}
return { findings, access_map: accessMap };
}
function normalizeFinding(row) {
const validation = row.finding?.validation || row.validation || {};
return {
ruleId: `${row.rule?.id ?? ""}`,
ruleName: `${row.rule?.name ?? ""}`,
findingType: `${row.finding?.type ?? row.finding?.category ?? ""}`,
severity: `${row.finding?.severity ?? row.severity ?? ""}`,
message: `${row.finding?.message ?? row.finding?.snippet ?? ""}`,
path: `${row.finding?.path ?? row.path ?? ""}`,
line: `${row.finding?.line ?? row.finding?.start?.line ?? ""}`,
validationStatus: `${validation.status ?? ""}`,
validationConfidence: `${validation.confidence ?? ""}`,
validationResponse: `${validation.response ?? ""}`,
confidence: `${row.finding?.confidence ?? ""}`,
snippet: `${row.finding?.snippet ?? ""}`,
fingerprint: `${row.finding?.fingerprint ?? ""}`,
raw: row,
};
}
function normalizeAccessMap(entries = []) {
if (!Array.isArray(entries)) return [];
if (entries.some((entry) => Array.isArray(entry.groups))) {
return entries.map((entry) => ({
provider: entry.provider,
account: entry.account,
groups: (entry.groups || []).map((group) => ({
resources: Array.isArray(group.resources) ? group.resources : [],
permissions: Array.isArray(group.permissions) ? group.permissions : [],
})),
}));
}
return entries.map((entry) => ({
provider: entry.provider,
account: entry.account,
groups: [
{
resources: entry.resource ? [entry.resource] : [],
permissions: Array.isArray(entry.permissions)
? entry.permissions
: entry.permission
? String(entry.permission)
.split(",")
.map((p) => p.trim())
.filter(Boolean)
: [],
},
],
}));
}
function flattenAccessMap(entries = []) {
const rows = [];
entries.forEach((entry) => {
(entry.groups || []).forEach((group) => {
(group.resources || []).forEach((resource) => {
rows.push({
provider: entry.provider,
account: entry.account,
resource,
permissions: group.permissions || [],
});
});
});
});
return rows;
}
function renderStats() {
const totalFindings = findings.length;
const criticalCount = findings.filter((f) => f.severity.toLowerCase() === "critical").length;
const highCount = findings.filter((f) => f.severity.toLowerCase() === "high").length;
const mediumCount = findings.filter((f) => f.severity.toLowerCase() === "medium").length;
const validatedCount = findings.filter((f) => !!f.validationStatus).length;
document.getElementById("stat-findings").textContent = totalFindings;
document.getElementById("stat-critical").textContent = criticalCount;
document.getElementById("stat-high").textContent = highCount;
document.getElementById("stat-medium").textContent = mediumCount;
document.getElementById("stat-validated").textContent = validatedCount;
document.getElementById("stat-access-map").textContent = accessMap.length;
}
function renderTable() {
const rows = filteredFindings();
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
currentPage = Math.min(currentPage, totalPages);
pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
pagePrev.disabled = currentPage === 1;
pageNext.disabled = currentPage === totalPages;
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
const pageRows = rows.slice(start, end);
findingsBody.innerHTML = pageRows
.map((f) => `
<tr>
<td class="nowrap">${escapeHtml(f.ruleId)}</td>
<td class="nowrap">${escapeHtml(f.ruleName)}</td>
<td class="nowrap">${escapeHtml(f.findingType)}</td>
<td class="nowrap">${escapeHtml(f.severity)}</td>
<td>${escapeHtml(f.message)}</td>
<td>${escapeHtml(f.path)}</td>
<td>${escapeHtml(f.line)}</td>
<td>${escapeHtml(f.validationStatus)}</td>
<td>${escapeHtml(f.validationConfidence)}</td>
</tr>
`)
.join("");
}
function filteredFindings() {
return findings
.filter((f) => {
if (validationFilter === "active" && f.validationStatus.toLowerCase() !== "active credential") return false;
if (validationFilter === "inactive" && f.validationStatus.toLowerCase() !== "inactive credential") return false;
if (validationFilter === "not_attempted" && f.validationStatus.toLowerCase() !== "not attempted") return false;
if (!currentFilter) return true;
const haystack = `${f.ruleId} ${f.ruleName} ${f.findingType} ${f.message} ${f.path} ${f.validationStatus}`.toLowerCase();
return haystack.includes(currentFilter);
})
.sort((a, b) => {
const av = getSortValue(a, sortField);
const bv = getSortValue(b, sortField);
if (av === bv) return 0;
return sortDirection === "asc" ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
});
}
function getSortValue(obj, field) {
switch (field) {
case "rule":
return `${obj.ruleId}`.toLowerCase();
case "location":
return `${obj.path}`.toLowerCase();
case "severity":
return `${obj.severity}`.toLowerCase();
case "validation":
return `${obj.validationStatus}`.toLowerCase();
case "confidence":
return `${obj.confidence}`.toLowerCase();
case "line":
return `${obj.line}`.toLowerCase();
default:
return `${obj.path}`.toLowerCase();
}
}
document.querySelectorAll("th.sortable").forEach((th) => {
th.addEventListener("click", () => {
const field = th.dataset.sort;
if (sortField === field) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortField = field;
sortDirection = "asc";
}
document
.querySelectorAll("th.sortable")
.forEach((el) => el.classList.toggle("sorted", el.dataset.sort === sortField));
renderTable();
});
});
treeSearch.addEventListener("input", () => buildAccessTree(accessMap));
function buildAccessTree(entries) {
const search = treeSearch.value.trim().toLowerCase();
const tree = document.getElementById("access-tree");
tree.innerHTML = "";
if (!entries.length) {
tree.innerHTML = "<p style=\"color:var(--text-muted);\">No access map entries.</p>";
return;
}
const filtered = entries.filter((entry) =>
[entry.provider, entry.account, entry.resource, ...(entry.permissions || [])]
.join(" ")
.toLowerCase()
.includes(search)
);
const grouped = {};
for (const entry of filtered) {
const provider = entry.provider || "Unknown";
const account = entry.account || "Unknown";
grouped[provider] = grouped[provider] || {};
grouped[provider][account] = grouped[provider][account] || [];
grouped[provider][account].push(entry);
}
Object.entries(grouped).forEach(([provider, accounts]) => {
const providerEl = document.createElement("div");
providerEl.className = "tree-node";
providerEl.innerHTML = `<div class="tree-node__label">${escapeHtml(provider)}</div>`;
Object.entries(accounts).forEach(([account, resources]) => {
const accountEl = document.createElement("div");
accountEl.className = "tree-node tree-node--child";
accountEl.innerHTML = `<div class="tree-node__label">${escapeHtml(account)}</div>`;
resources.forEach((r) => {
const resEl = document.createElement("div");
resEl.className = "tree-node tree-node--grandchild";
resEl.innerHTML = `
<div class="tree-node__label">${escapeHtml(r.resource || "(resource)")}</div>
<div class="tree-badge">${escapeHtml((r.permissions || []).join(", "))}</div>
`;
accountEl.appendChild(resEl);
});
providerEl.appendChild(accountEl);
});
tree.appendChild(providerEl);
});
}
function setLoading(enabled, message = "Loading...") {
loader.classList.toggle("hidden", !enabled);
loaderText.textContent = message;
}
function setError(message) {
errorMsg.textContent = message;
errorMsg.classList.remove("hidden");
uploadSection.classList.add("error");
}
function resetError() {
errorMsg.textContent = "";
errorMsg.classList.add("hidden");
uploadSection.classList.remove("error");
}
function showDashboard() {
uploadSection.classList.add("hidden");
dashboard.classList.remove("hidden");
setLoading(false);
}
function escapeHtml(str) {
return (str || "")
.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function downloadJson() {
if (!rawData) return;
const blob = new Blob([rawData], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "access-map-report.json";
a.click();
URL.revokeObjectURL(url);
}
function copyAccessMap() {
if (!accessMap.length) return;
const text = JSON.stringify(accessMap, null, 2);
navigator.clipboard.writeText(text).catch(() => {});
}
function exportCsv() {
const rows = filteredFindings();
if (!rows.length) return;
const header = [
"rule_id",
"rule_name",
"finding_type",
"severity",
"message",
"path",
"line",
"validation_status",
"validation_confidence",
];
const csv = [header.join(",")]
.concat(
rows.map((f) =>
[
f.ruleId,
f.ruleName,
f.findingType,
f.severity,
escapeCsv(f.message),
f.path,
f.line,
f.validationStatus,
f.validationConfidence,
].join(",")
)
)
.join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "access-map-findings.csv";
a.click();
URL.revokeObjectURL(url);
}
function escapeCsv(value) {
const str = value.replace(/"/g, '""');
return `"${str}"`;
}
document.getElementById("download-json").addEventListener("click", downloadJson);
document.getElementById("download-csv").addEventListener("click", exportCsv);
document.getElementById("copy-access-map").addEventListener("click", copyAccessMap);
loadEmbeddedReport();

View file

@ -573,7 +573,7 @@
<div class="panel__header">
<div class="panel__title">
<h3>Upload Report</h3>
<p>Analyze Kingfisher JSON / JSONL output</p>
<p>Analyze Kingfisher JSON / JSONL output (auto-loads if provided on the CLI)</p>
</div>
</div>
<div style="padding:22px;">
@ -581,6 +581,7 @@
<div class="upload-icon">📄</div>
<div class="upload-text">Drag &amp; drop a report here</div>
<div class="upload-sub">…or click to choose a file</div>
<div class="upload-sub">Your file stays in the browser—load JSON or JSONL reports locally.</div>
<input type="file" id="file-input" hidden accept=".json,.jsonl">
</div>
<div id="error-msg" class="hidden" style="margin-top:16px; padding:10px 12px; background:#fef2f2; border:1px solid #fecaca; border-radius:6px; color:#b91c1c; font-size:13px;"></div>

View file

@ -9,3 +9,4 @@ pub mod inputs;
pub mod output;
pub mod rules;
pub mod scan;
pub mod view;

191
src/cli/commands/view.rs Normal file
View file

@ -0,0 +1,191 @@
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use anyhow::{anyhow, Context, Result};
use axum::{
body::Body,
extract::State,
http::{header, HeaderValue, StatusCode, Uri},
response::Response,
routing::get,
Router,
};
use clap::ValueHint;
use include_dir::{include_dir, Dir};
use tokio::net::TcpListener;
use tracing::info;
const DEFAULT_PORT: u16 = 7890;
static VIEWER_ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/docs/access-map-viewer");
/// View a Kingfisher access-map report locally.
#[derive(clap::Args, Debug)]
pub struct ViewArgs {
/// Path to a JSON or JSONL access-map report to load automatically
#[arg(value_name = "REPORT", value_hint = ValueHint::FilePath)]
pub report: Option<PathBuf>,
/// Local port for the embedded viewer (default 7890)
#[arg(long, default_value_t = DEFAULT_PORT)]
pub port: u16,
}
#[derive(Clone)]
struct AppState {
report: Option<Vec<u8>>,
}
/// Run the `kingfisher view` subcommand.
pub async fn run(args: ViewArgs) -> Result<()> {
let report = if let Some(path) = args.report.as_ref() {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.unwrap_or_default();
if ext != "json" && ext != "jsonl" {
return Err(anyhow!("Report must be a JSON or JSONL file (got extension: {})", ext));
}
Some(
tokio::fs::read(path)
.await
.with_context(|| format!("Failed to read report at {}", path.display()))?,
)
} else {
None
};
let listener =
TcpListener::bind(("127.0.0.1", args.port)).await.map_err(|err| match err.kind() {
std::io::ErrorKind::AddrInUse => anyhow!(
"Port {} is already in use. Re-run with --port <PORT> to choose a different port.",
args.port
),
_ => err.into(),
})?;
let address: SocketAddr =
listener.local_addr().context("Failed to read local listener address")?;
info!(%address, "Starting access-map viewer");
eprintln!(
"Serving access-map viewer at http://{}:{} (Ctrl+C to stop)",
address.ip(),
address.port()
);
let state = Arc::new(AppState { report });
let app = Router::new()
.route("/", get(serve_index))
.route("/report", get(serve_report))
.route("/favicon.ico", get(serve_favicon))
.fallback(get(serve_asset))
.with_state(state);
axum::serve(listener, app).await?;
Ok(())
}
async fn serve_index() -> Response {
serve_asset_at("index.html").unwrap_or_else(not_found)
}
async fn serve_favicon() -> Response {
Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.map(apply_security_headers)
.unwrap_or_else(|_| internal_error())
}
async fn serve_asset(uri: Uri) -> Response {
let path = uri.path().trim_start_matches('/');
if path.is_empty() {
return serve_index().await;
}
if !is_safe_path(path) {
return not_found();
}
serve_asset_at(path).unwrap_or_else(not_found)
}
async fn serve_report(State(state): State<Arc<AppState>>) -> Response {
if let Some(report) = &state.report {
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type_for("report.json"))
.body(Body::from(report.clone()))
.map(apply_security_headers)
.unwrap_or_else(|_| internal_error());
}
not_found()
}
fn serve_asset_at(path: &str) -> Option<Response> {
let file = VIEWER_ASSETS.get_file(path)?;
let body = Body::from(file.contents().to_vec());
let content_type = content_type_for(path);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.body(body)
.map(apply_security_headers)
.ok()
}
fn content_type_for(path: &str) -> HeaderValue {
if let Some(ext) = path.rsplit('.').next() {
let mime = match ext {
"html" => "text/html; charset=utf-8",
"js" => "application/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" | "jsonl" => "application/json; charset=utf-8",
_ => "application/octet-stream",
};
return HeaderValue::from_static(mime);
}
HeaderValue::from_static("application/octet-stream")
}
fn is_safe_path(path: &str) -> bool {
let candidate = std::path::Path::new(path);
candidate.components().all(|comp| matches!(comp, std::path::Component::Normal(_)))
}
fn not_found() -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Not found"))
.map(apply_security_headers)
.unwrap_or_else(|_| internal_error())
}
fn internal_error() -> Response {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from("Internal server error"))
.map(apply_security_headers)
.unwrap()
}
fn apply_security_headers(response: Response) -> Response {
let mut response = response;
let headers = response.headers_mut();
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
headers.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'",
),
);
response
}

View file

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

View file

@ -74,6 +74,7 @@ use crate::cli::commands::{
gitea::GiteaRepoType,
gitlab::GitLabRepoType,
scan::{ListRepositoriesCommand, ScanOperation},
view,
};
fn main() -> anyhow::Result<()> {
@ -91,6 +92,7 @@ fn main() -> anyhow::Result<()> {
Command::SelfUpdate => 1, // Self-update doesn't need a thread pool
Command::Rules(_) => num_cpus::get(), // Default for Rules commands
Command::AccessMap(_) => 1,
Command::View(_) => 1,
};
// Set up the Tokio runtime with the specified number of threads
@ -192,6 +194,7 @@ async fn async_main(args: CommandLineArgs) -> Result<()> {
let _ = check_for_update_async(&g, None).await;
Ok(())
}
Command::View(view_args) => view::run(view_args).await,
Command::AccessMap(identity_args) => access_map::run(identity_args).await,
command => {
let temp_dir = TempDir::new().context("Failed to create temporary directory")?;
@ -335,6 +338,7 @@ async fn async_main(args: CommandLineArgs) -> Result<()> {
run_rules_list(&list_args)?;
}
},
Command::View(_) => { anyhow::bail!("View command should not reach this branch") },
Command::AccessMap(_) => {
anyhow::bail!("AccessMap command should not reach this branch")
}