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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue