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
f79b7f4b0c
commit
33412d04be
9 changed files with 787 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
12
README.md
12
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 <PORT>`.
|
||||
|
||||
|
||||
### Pipe any text directly into Kingfisher by passing `-`
|
||||
|
||||
|
|
|
|||
571
docs/access-map-viewer/app.js
Normal file
571
docs/access-map-viewer/app.js
Normal 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, "&")
|
||||
.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();
|
||||
|
||||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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
191
src/cli/commands/view.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue