diff --git a/CHANGELOG.md b/CHANGELOG.md index ddf8171..31410c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.toml b/Cargo.toml index 471be4b..74491f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 7807c53..5a55354 100644 --- a/README.md +++ b/README.md @@ -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 `. + ### Pipe any text directly into Kingfisher by passing `-` diff --git a/docs/access-map-viewer/app.js b/docs/access-map-viewer/app.js new file mode 100644 index 0000000..4c6cd4d --- /dev/null +++ b/docs/access-map-viewer/app.js @@ -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 = + '
No access map data found in report.
'; + + 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) => ` + + ${escapeHtml(f.ruleId)} + ${escapeHtml(f.ruleName)} + ${escapeHtml(f.findingType)} + ${escapeHtml(f.severity)} + ${escapeHtml(f.message)} + ${escapeHtml(f.path)} + ${escapeHtml(f.line)} + ${escapeHtml(f.validationStatus)} + ${escapeHtml(f.validationConfidence)} + + `) + .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 = "

No access map entries.

"; + 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 = `
${escapeHtml(provider)}
`; + + Object.entries(accounts).forEach(([account, resources]) => { + const accountEl = document.createElement("div"); + accountEl.className = "tree-node tree-node--child"; + accountEl.innerHTML = `
${escapeHtml(account)}
`; + + resources.forEach((r) => { + const resEl = document.createElement("div"); + resEl.className = "tree-node tree-node--grandchild"; + resEl.innerHTML = ` +
${escapeHtml(r.resource || "(resource)")}
+
${escapeHtml((r.permissions || []).join(", "))}
+ `; + 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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +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(); + diff --git a/docs/access-map-viewer/index.html b/docs/access-map-viewer/index.html index 44e62e4..5141e23 100644 --- a/docs/access-map-viewer/index.html +++ b/docs/access-map-viewer/index.html @@ -573,7 +573,7 @@

Upload Report

-

Analyze Kingfisher JSON / JSONL output

+

Analyze Kingfisher JSON / JSONL output (auto-loads if provided on the CLI)

@@ -581,6 +581,7 @@
đź“„
Drag & drop a report here
…or click to choose a file
+
Your file stays in the browser—load JSON or JSONL reports locally.
diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 3c969c9..2281a0d 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -9,3 +9,4 @@ pub mod inputs; pub mod output; pub mod rules; pub mod scan; +pub mod view; diff --git a/src/cli/commands/view.rs b/src/cli/commands/view.rs new file mode 100644 index 0000000..d941422 --- /dev/null +++ b/src/cli/commands/view.rs @@ -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, + + /// Local port for the embedded viewer (default 7890) + #[arg(long, default_value_t = DEFAULT_PORT)] + pub port: u16, +} + +#[derive(Clone)] +struct AppState { + report: Option>, +} + +/// 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 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>) -> 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 { + 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 +} diff --git a/src/cli/global.rs b/src/cli/global.rs index ea44dad..b358419 100644 --- a/src/cli/global.rs +++ b/src/cli/global.rs @@ -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, diff --git a/src/main.rs b/src/main.rs index cdf019d..e8a7cb2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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") }