790 lines
25 KiB
Rust
790 lines
25 KiB
Rust
// ©AngelaMos | 2026
|
|
// cli.rs
|
|
|
|
use std::io::Write as _;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
use clap::{Parser, Subcommand};
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
use tlsfp_core::{FingerprintEvent, PcapFileSource, Pipeline, PipelineConfig, SourceError};
|
|
|
|
use crate::live::{DEFAULT_BPF_FILTER, LiveConfig, LiveSource};
|
|
use tlsfp_intel::{Alert, FpKind, IntelStore, MatchReport, MatchStrength, default_db_path};
|
|
|
|
/// How many alerts `intel alerts` shows when no count is given, and the floor a
|
|
/// zero or negative count is raised to.
|
|
const DEFAULT_ALERT_LIMIT: i64 = 50;
|
|
|
|
/// JA3/JA4 TLS fingerprinting tool.
|
|
///
|
|
/// Fingerprints TLS clients and servers from live capture or packet captures,
|
|
/// matches them against a local intelligence database, and flags anomalies such
|
|
/// as a fingerprint that disagrees with its own User-Agent.
|
|
#[derive(Debug, Parser)]
|
|
#[command(name = "tlsfp", version, about, long_about = None)]
|
|
pub struct Cli {
|
|
/// Increase log verbosity (repeat for more detail).
|
|
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
|
pub verbose: u8,
|
|
|
|
#[command(subcommand)]
|
|
pub command: Command,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
pub enum Command {
|
|
/// Fingerprint every TLS and QUIC handshake in a packet capture file.
|
|
Pcap {
|
|
/// Path to a pcap or pcapng file.
|
|
path: PathBuf,
|
|
|
|
/// Emit one JSON object per event instead of readable lines.
|
|
#[arg(long)]
|
|
json: bool,
|
|
|
|
/// Match each fingerprint against the intelligence database.
|
|
#[arg(long)]
|
|
intel: bool,
|
|
|
|
/// Run the detection rules, recording observations and raising alerts.
|
|
#[arg(long)]
|
|
detect: bool,
|
|
|
|
/// Path to the intelligence database, defaulting to the data directory.
|
|
#[arg(long)]
|
|
db: Option<PathBuf>,
|
|
},
|
|
|
|
/// Capture live from a network interface and fingerprint in real time.
|
|
///
|
|
/// Live capture opens a raw socket, which an unprivileged user cannot
|
|
/// do. Rather than running the whole tool as root, grant the binary the
|
|
/// two capabilities libpcap needs:
|
|
///
|
|
/// sudo setcap cap_net_raw,cap_net_admin=eip "$(command -v tlsfp)"
|
|
///
|
|
/// cap_net_raw opens the capture socket, cap_net_admin covers
|
|
/// promiscuous mode, and =eip marks both permitted, inheritable, and
|
|
/// effective when the binary runs. File capabilities live on the binary
|
|
/// itself, so repeat the grant after every rebuild, and point it at
|
|
/// target/debug/tlsfp or target/release/tlsfp when running from cargo.
|
|
///
|
|
/// The default --filter keeps all TCP (TLS lives on any port, JA4T
|
|
/// wants the SYNs) plus UDP 443 for QUIC, and drops everything else in
|
|
/// the kernel. Tighten it on busy links, for example:
|
|
///
|
|
/// tlsfp live eth0 --filter "tcp port 443"
|
|
///
|
|
/// Stop with ctrl-c: the first one drains and prints final counters, a
|
|
/// second one exits immediately.
|
|
#[command(verbatim_doc_comment)]
|
|
Live {
|
|
/// Interface name, for example eth0, or any for every interface.
|
|
interface: String,
|
|
|
|
/// Emit one JSON object per event instead of readable lines.
|
|
#[arg(long)]
|
|
json: bool,
|
|
|
|
/// BPF filter compiled into the kernel before capture begins.
|
|
#[arg(long, default_value = DEFAULT_BPF_FILTER)]
|
|
filter: String,
|
|
|
|
/// Capture only traffic the interface would normally receive
|
|
/// instead of switching it to promiscuous mode.
|
|
#[arg(long)]
|
|
no_promisc: bool,
|
|
|
|
/// Match each fingerprint against the intelligence database.
|
|
#[arg(long)]
|
|
intel: bool,
|
|
|
|
/// Run the detection rules, recording observations and raising alerts.
|
|
#[arg(long)]
|
|
detect: bool,
|
|
|
|
/// Path to the intelligence database, defaulting to the data directory.
|
|
#[arg(long)]
|
|
db: Option<PathBuf>,
|
|
},
|
|
|
|
/// Serve the web dashboard and HTTP API.
|
|
Serve {
|
|
/// Address to bind, for example 127.0.0.1:8080.
|
|
#[arg(default_value = "127.0.0.1:8080")]
|
|
bind: String,
|
|
},
|
|
|
|
/// Manage the local threat intelligence database.
|
|
Intel {
|
|
#[command(subcommand)]
|
|
action: IntelCommand,
|
|
},
|
|
}
|
|
|
|
/// The subcommands under `tlsfp intel`.
|
|
#[derive(Debug, Subcommand)]
|
|
pub enum IntelCommand {
|
|
/// Create the database if needed and load the three bundled feeds.
|
|
Seed {
|
|
/// Path to the intelligence database, defaulting to the data directory.
|
|
#[arg(long)]
|
|
db: Option<PathBuf>,
|
|
},
|
|
|
|
/// Import a ja4db.com JSON export, validating every record on the way in.
|
|
Import {
|
|
/// Path to the JSON file, or - to read standard input.
|
|
path: PathBuf,
|
|
|
|
/// Path to the intelligence database, defaulting to the data directory.
|
|
#[arg(long)]
|
|
db: Option<PathBuf>,
|
|
},
|
|
|
|
/// Look up one fingerprint and print its verdict.
|
|
Lookup {
|
|
/// Fingerprint kind: ja3, ja3s, ja4, ja4s, ja4h, ja4x, ja4t, or ja4ts.
|
|
kind: String,
|
|
|
|
/// The fingerprint value to look up.
|
|
value: String,
|
|
|
|
/// Emit the report as JSON instead of readable lines.
|
|
#[arg(long)]
|
|
json: bool,
|
|
|
|
/// Path to the intelligence database, defaulting to the data directory.
|
|
#[arg(long)]
|
|
db: Option<PathBuf>,
|
|
},
|
|
|
|
/// Show what the database holds, by feed and by category.
|
|
Stats {
|
|
/// Emit the summary as JSON instead of readable lines.
|
|
#[arg(long)]
|
|
json: bool,
|
|
|
|
/// Path to the intelligence database, defaulting to the data directory.
|
|
#[arg(long)]
|
|
db: Option<PathBuf>,
|
|
},
|
|
|
|
/// Show the most recent alerts the detection rules have raised.
|
|
Alerts {
|
|
/// Emit the alerts as JSON instead of readable lines.
|
|
#[arg(long)]
|
|
json: bool,
|
|
|
|
/// How many alerts to show, newest first.
|
|
#[arg(long, default_value_t = DEFAULT_ALERT_LIMIT)]
|
|
limit: i64,
|
|
|
|
/// Path to the intelligence database, defaulting to the data directory.
|
|
#[arg(long)]
|
|
db: Option<PathBuf>,
|
|
},
|
|
}
|
|
|
|
impl Cli {
|
|
pub fn init_tracing(&self) {
|
|
let default = match self.verbose {
|
|
0 => "tlsfp=info",
|
|
1 => "tlsfp=debug",
|
|
_ => "tlsfp=trace",
|
|
};
|
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(filter)
|
|
.with_writer(std::io::stderr)
|
|
.init();
|
|
}
|
|
|
|
pub fn run(self) -> Result<()> {
|
|
match self.command {
|
|
Command::Pcap {
|
|
path,
|
|
json,
|
|
intel,
|
|
detect,
|
|
db,
|
|
} => run_pcap(&path, json, intel, detect, db.as_deref()),
|
|
Command::Live {
|
|
interface,
|
|
json,
|
|
filter,
|
|
no_promisc,
|
|
intel,
|
|
detect,
|
|
db,
|
|
} => run_live(
|
|
&interface,
|
|
json,
|
|
filter,
|
|
!no_promisc,
|
|
intel,
|
|
detect,
|
|
db.as_deref(),
|
|
),
|
|
Command::Serve { bind } => {
|
|
anyhow::bail!("dashboard on {bind} is not wired up yet")
|
|
}
|
|
Command::Intel { action } => action.run(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntelCommand {
|
|
fn run(self) -> Result<()> {
|
|
match self {
|
|
IntelCommand::Seed { db } => run_intel_seed(db),
|
|
IntelCommand::Import { path, db } => run_intel_import(&path, db),
|
|
IntelCommand::Lookup {
|
|
kind,
|
|
value,
|
|
json,
|
|
db,
|
|
} => run_intel_lookup(&kind, &value, json, db),
|
|
IntelCommand::Stats { json, db } => run_intel_stats(json, db),
|
|
IntelCommand::Alerts { json, limit, db } => run_intel_alerts(json, limit, db),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fingerprints a capture file and prints one event per line on stdout.
|
|
///
|
|
/// The summary goes to the log rather than stdout so that piping the output
|
|
/// into a tool sees only events, while a human still learns how much of the
|
|
/// capture was readable and whether the file was cut short mid packet.
|
|
fn run_pcap(path: &Path, json: bool, intel: bool, detect: bool, db: Option<&Path>) -> Result<()> {
|
|
let mut source = PcapFileSource::open(path)
|
|
.with_context(|| format!("cannot open capture {}", path.display()))?;
|
|
let mut store = open_for_run(intel, detect, db)?;
|
|
let mut pipeline = Pipeline::new(PipelineConfig::default());
|
|
|
|
let stdout = std::io::stdout().lock();
|
|
let mut out = std::io::BufWriter::new(stdout);
|
|
let mut write_failure: Option<std::io::Error> = None;
|
|
pipeline.run(&mut source, |event| {
|
|
if write_failure.is_some() {
|
|
return;
|
|
}
|
|
let reports = if intel {
|
|
enrich(store.as_ref(), &event)
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let alerts = detect_event(store.as_mut(), detect, &event);
|
|
if let Err(error) = write_event(&mut out, &event, reports, alerts, json) {
|
|
write_failure = Some(error);
|
|
}
|
|
})?;
|
|
if let Some(error) = write_failure {
|
|
return Err(anyhow::Error::from(error).context("writing events to stdout"));
|
|
}
|
|
out.flush().context("flushing events to stdout")?;
|
|
|
|
let counters = pipeline.counters();
|
|
tracing::info!(
|
|
frames = counters.frames,
|
|
tcp_segments = counters.tcp_segments,
|
|
udp_datagrams = counters.udp_datagrams,
|
|
events = counters.events,
|
|
flows = counters.flows_created,
|
|
quic_initials = counters.quic_initials,
|
|
quic_decrypted = counters.quic_decrypted,
|
|
quic_version_unsupported = counters.quic_version_unsupported,
|
|
unfinished_tls_streams = counters.unfinished_tls_streams,
|
|
segments_dropped = counters.segments_dropped,
|
|
"capture processed"
|
|
);
|
|
if source.truncated() {
|
|
tracing::warn!("capture file ended mid packet; the tail was not read");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Captures from an interface until ctrl-c and fingerprints in real time.
|
|
///
|
|
/// The capture itself runs on a dedicated OS thread inside [`LiveSource`];
|
|
/// this function owns the tokio side of the bridge. The runtime is built
|
|
/// here rather than in main so the file path stays a plain synchronous
|
|
/// program.
|
|
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
|
|
fn run_live(
|
|
interface: &str,
|
|
json: bool,
|
|
filter: String,
|
|
promiscuous: bool,
|
|
intel: bool,
|
|
detect: bool,
|
|
db: Option<&Path>,
|
|
) -> Result<()> {
|
|
let config = LiveConfig {
|
|
filter,
|
|
promiscuous,
|
|
};
|
|
let store = open_for_run(intel, detect, db)?;
|
|
let source = LiveSource::open(interface, &config)?;
|
|
tracing::info!(interface, filter = %config.filter, "live capture started");
|
|
|
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.context("building the async runtime")?;
|
|
runtime.block_on(drive_live(source, json, intel, detect, store))
|
|
}
|
|
|
|
/// Drains the live source through the same pipeline the file path uses.
|
|
///
|
|
/// Events flush per line so the stream is followable as it happens. The
|
|
/// first ctrl-c asks the capture thread to stop and lets the channel
|
|
/// drain, which makes the final counters trustworthy; a second ctrl-c
|
|
/// exits without ceremony. A closed stdout pipe is a normal way for a
|
|
/// live session to end, so it stops the capture instead of reporting an
|
|
/// error.
|
|
async fn drive_live(
|
|
mut source: LiveSource,
|
|
json: bool,
|
|
intel: bool,
|
|
detect: bool,
|
|
mut store: Option<IntelStore>,
|
|
) -> Result<()> {
|
|
let stop = source.stop_handle();
|
|
tokio::spawn(async move {
|
|
if tokio::signal::ctrl_c().await.is_ok() {
|
|
tracing::info!("ctrl-c received; draining capture");
|
|
stop.stop();
|
|
}
|
|
if tokio::signal::ctrl_c().await.is_ok() {
|
|
std::process::exit(130);
|
|
}
|
|
});
|
|
|
|
let mut pipeline = Pipeline::new(PipelineConfig::default());
|
|
let stdout = std::io::stdout().lock();
|
|
let mut out = std::io::BufWriter::new(stdout);
|
|
let mut write_failure: Option<std::io::Error> = None;
|
|
|
|
let capture_failure: Option<SourceError> = loop {
|
|
let received = source.next_frame_async().await;
|
|
let frame = match received {
|
|
Ok(Some(frame)) => frame,
|
|
Ok(None) => break None,
|
|
Err(error) => break Some(error),
|
|
};
|
|
pipeline.feed(&frame, &mut |event| {
|
|
write_live_event(
|
|
&mut out,
|
|
&mut store,
|
|
intel,
|
|
detect,
|
|
&event,
|
|
json,
|
|
&mut write_failure,
|
|
);
|
|
});
|
|
if write_failure.is_some() {
|
|
break None;
|
|
}
|
|
};
|
|
pipeline.finish();
|
|
|
|
let counters = pipeline.counters();
|
|
tracing::info!(
|
|
frames = counters.frames,
|
|
tcp_segments = counters.tcp_segments,
|
|
udp_datagrams = counters.udp_datagrams,
|
|
events = counters.events,
|
|
flows = counters.flows_created,
|
|
quic_initials = counters.quic_initials,
|
|
quic_decrypted = counters.quic_decrypted,
|
|
quic_version_unsupported = counters.quic_version_unsupported,
|
|
unfinished_tls_streams = counters.unfinished_tls_streams,
|
|
segments_dropped = counters.segments_dropped,
|
|
"live capture stopped"
|
|
);
|
|
|
|
if let Some(stats) = source.close() {
|
|
tracing::info!(
|
|
received = stats.received,
|
|
kernel_dropped = stats.kernel_dropped,
|
|
interface_dropped = stats.interface_dropped,
|
|
"kernel capture statistics"
|
|
);
|
|
if stats.kernel_dropped > 0 || stats.interface_dropped > 0 {
|
|
tracing::warn!(
|
|
"the kernel dropped frames; an absent fingerprint is not proof of an absent handshake"
|
|
);
|
|
}
|
|
}
|
|
|
|
match (write_failure, capture_failure) {
|
|
(Some(error), _) if error.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
|
|
(Some(error), _) => Err(anyhow::Error::from(error).context("writing events to stdout")),
|
|
(None, Some(error)) => Err(anyhow::Error::from(error).context("live capture failed")),
|
|
(None, None) => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// Writes one event and flushes it immediately, recording the first
|
|
/// failure instead of panicking inside the pipeline's sink. Later calls
|
|
/// become no-ops once a write has failed.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn write_live_event(
|
|
out: &mut impl std::io::Write,
|
|
store: &mut Option<IntelStore>,
|
|
intel: bool,
|
|
detect: bool,
|
|
event: &FingerprintEvent,
|
|
json: bool,
|
|
failure: &mut Option<std::io::Error>,
|
|
) {
|
|
if failure.is_some() {
|
|
return;
|
|
}
|
|
let reports = if intel {
|
|
enrich(store.as_ref(), event)
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let alerts = detect_event(store.as_mut(), detect, event);
|
|
let result = write_event(out, event, reports, alerts, json).and_then(|()| out.flush());
|
|
if let Err(error) = result {
|
|
*failure = Some(error);
|
|
}
|
|
}
|
|
|
|
/// One event plus any intelligence that matched it, the shape both the file and
|
|
/// the live path serialise. The intel field is omitted when nothing matched, so
|
|
/// a run without enrichment produces exactly the same JSON as before.
|
|
#[derive(serde::Serialize)]
|
|
struct EnrichedEvent<'a> {
|
|
#[serde(flatten)]
|
|
event: &'a FingerprintEvent,
|
|
#[serde(rename = "intel", skip_serializing_if = "Vec::is_empty")]
|
|
reports: Vec<MatchReport>,
|
|
#[serde(rename = "alerts", skip_serializing_if = "Vec::is_empty")]
|
|
alerts: Vec<Alert>,
|
|
}
|
|
|
|
/// Looks every fingerprint in an event up against the store, returning the
|
|
/// reports that found intelligence. A lookup error degrades to no enrichment
|
|
/// with a warning rather than ending the capture.
|
|
fn enrich(store: Option<&IntelStore>, event: &FingerprintEvent) -> Vec<MatchReport> {
|
|
let Some(store) = store else {
|
|
return Vec::new();
|
|
};
|
|
match store.match_event(event) {
|
|
Ok(reports) => reports,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "intelligence lookup failed for an event");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs the detection rules for one event when detection is enabled, recording
|
|
/// the observation and any alerts. A per-event failure degrades to a warning so
|
|
/// one bad record cannot end the capture.
|
|
fn detect_event(
|
|
store: Option<&mut IntelStore>,
|
|
detect: bool,
|
|
event: &FingerprintEvent,
|
|
) -> Vec<Alert> {
|
|
if !detect {
|
|
return Vec::new();
|
|
}
|
|
let Some(store) = store else {
|
|
return Vec::new();
|
|
};
|
|
match store.detect(event) {
|
|
Ok(alerts) => alerts,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "detection failed for an event");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Writes one event, as JSON or as a readable line, followed by any intel.
|
|
fn write_event(
|
|
out: &mut impl std::io::Write,
|
|
event: &FingerprintEvent,
|
|
reports: Vec<MatchReport>,
|
|
alerts: Vec<Alert>,
|
|
json: bool,
|
|
) -> std::io::Result<()> {
|
|
if json {
|
|
let enriched = EnrichedEvent {
|
|
event,
|
|
reports,
|
|
alerts,
|
|
};
|
|
serde_json::to_writer(&mut *out, &enriched).map_err(std::io::Error::from)?;
|
|
writeln!(out)
|
|
} else {
|
|
writeln!(out, "{event}")?;
|
|
write_intel_lines(out, &reports)?;
|
|
write_alert_lines(out, &alerts)
|
|
}
|
|
}
|
|
|
|
/// Writes one indented pair of lines per alert beneath its event: the rule and
|
|
/// severity that name it, then the evidence that tripped it.
|
|
fn write_alert_lines(out: &mut impl std::io::Write, alerts: &[Alert]) -> std::io::Result<()> {
|
|
for alert in alerts {
|
|
writeln!(
|
|
out,
|
|
" alert [{}] {}: {}",
|
|
alert.severity.as_str(),
|
|
alert.rule.as_str(),
|
|
alert.title,
|
|
)?;
|
|
writeln!(out, " {}", alert.detail)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Writes one indented line per intel report beneath its event.
|
|
fn write_intel_lines(
|
|
out: &mut impl std::io::Write,
|
|
reports: &[MatchReport],
|
|
) -> std::io::Result<()> {
|
|
for report in reports {
|
|
let labels = report
|
|
.hits
|
|
.iter()
|
|
.map(|hit| {
|
|
if hit.strength == MatchStrength::Exact {
|
|
format!("{} ({})", hit.label, hit.source)
|
|
} else {
|
|
format!("{} ({}, {})", hit.label, hit.source, hit.strength.as_str())
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
writeln!(
|
|
out,
|
|
" intel {}={} score={:.2} confidence={:.2} {labels}",
|
|
report.kind.as_str(),
|
|
report.verdict.as_str(),
|
|
report.threat_score,
|
|
report.confidence,
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Opens the store for a capture run. Enrichment that finds no database runs
|
|
/// without annotation rather than failing. Detection needs somewhere to record
|
|
/// observations, so it creates the database, warning that known-bad matching
|
|
/// stays dark until the feeds are seeded.
|
|
fn open_for_run(intel: bool, detect: bool, db: Option<&Path>) -> Result<Option<IntelStore>> {
|
|
if !intel && !detect {
|
|
return Ok(None);
|
|
}
|
|
let path = db.map_or_else(default_db_path, Path::to_path_buf);
|
|
if !path.exists() {
|
|
if detect {
|
|
tracing::warn!(
|
|
path = %path.display(),
|
|
"no intelligence database found; creating one to record detections, run 'tlsfp intel seed' to enable known-bad matching"
|
|
);
|
|
return Ok(Some(open_or_create(&path)?));
|
|
}
|
|
tracing::warn!(
|
|
path = %path.display(),
|
|
"no intelligence database found; run 'tlsfp intel seed' first, continuing without it"
|
|
);
|
|
return Ok(None);
|
|
}
|
|
Ok(Some(open_or_create(&path)?))
|
|
}
|
|
|
|
/// Resolves the database path from the flag or the default data directory.
|
|
fn resolve_db(db: Option<PathBuf>) -> PathBuf {
|
|
db.unwrap_or_else(default_db_path)
|
|
}
|
|
|
|
/// Opens or creates a store at `path`, used by the commands allowed to build
|
|
/// the database from scratch.
|
|
fn open_or_create(path: &Path) -> Result<IntelStore> {
|
|
IntelStore::open(path)
|
|
.with_context(|| format!("opening intelligence database {}", path.display()))
|
|
}
|
|
|
|
/// Opens a store that is expected to already exist, with a hint to seed first.
|
|
fn open_existing(path: &Path) -> Result<IntelStore> {
|
|
if !path.exists() {
|
|
anyhow::bail!(
|
|
"no intelligence database at {}; run 'tlsfp intel seed' first",
|
|
path.display()
|
|
);
|
|
}
|
|
open_or_create(path)
|
|
}
|
|
|
|
fn run_intel_seed(db: Option<PathBuf>) -> Result<()> {
|
|
let path = resolve_db(db);
|
|
let mut store = open_or_create(&path)?;
|
|
let summary = store.seed_bundled()?;
|
|
println!(
|
|
"seeded {} fingerprints into {}",
|
|
summary.parsed(),
|
|
path.display()
|
|
);
|
|
for feed in &summary.feeds {
|
|
println!(
|
|
" {:<24} {} new, {} total",
|
|
feed.name, feed.inserted, feed.parsed
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_intel_import(path: &Path, db: Option<PathBuf>) -> Result<()> {
|
|
let json = read_input(path)?;
|
|
let mut store = open_or_create(&resolve_db(db))?;
|
|
let summary = store.import_ja4db(&json)?;
|
|
println!(
|
|
"imported {} fingerprints from {} ja4db records, {} skipped as invalid",
|
|
summary.imported, summary.records, summary.skipped
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn run_intel_lookup(kind: &str, value: &str, json: bool, db: Option<PathBuf>) -> Result<()> {
|
|
let kind = FpKind::from_token(&kind.to_ascii_lowercase()).with_context(|| {
|
|
format!(
|
|
"unknown fingerprint kind '{kind}'; expected ja3, ja3s, ja4, ja4s, ja4h, ja4x, ja4t, or ja4ts"
|
|
)
|
|
})?;
|
|
let store = open_existing(&resolve_db(db))?;
|
|
let report = store.match_fingerprint(kind, value)?;
|
|
if json {
|
|
let stdout = std::io::stdout().lock();
|
|
serde_json::to_writer_pretty(stdout, &report).context("writing report as JSON")?;
|
|
println!();
|
|
} else {
|
|
print_report(&report);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_intel_stats(json: bool, db: Option<PathBuf>) -> Result<()> {
|
|
let store = open_existing(&resolve_db(db))?;
|
|
let stats = store.stats()?;
|
|
if json {
|
|
let stdout = std::io::stdout().lock();
|
|
serde_json::to_writer_pretty(stdout, &stats).context("writing stats as JSON")?;
|
|
println!();
|
|
return Ok(());
|
|
}
|
|
println!("{} fingerprints total", stats.total);
|
|
println!("feeds:");
|
|
for source in &stats.sources {
|
|
println!(
|
|
" {:<24} {:<8} {:<14} {}",
|
|
source.name,
|
|
source.kind,
|
|
source.license.as_deref().unwrap_or("-"),
|
|
source.records,
|
|
);
|
|
}
|
|
println!("by category:");
|
|
for category in &stats.by_category {
|
|
println!(" {:<10} {}", category.category, category.records);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_intel_alerts(json: bool, limit: i64, db: Option<PathBuf>) -> Result<()> {
|
|
let store = open_existing(&resolve_db(db))?;
|
|
let limit = if limit <= 0 {
|
|
DEFAULT_ALERT_LIMIT
|
|
} else {
|
|
limit
|
|
};
|
|
let alerts = store.recent_alerts(limit)?;
|
|
if json {
|
|
let stdout = std::io::stdout().lock();
|
|
serde_json::to_writer_pretty(stdout, &alerts).context("writing alerts as JSON")?;
|
|
println!();
|
|
return Ok(());
|
|
}
|
|
if alerts.is_empty() {
|
|
println!("no alerts recorded; run a capture with --detect first");
|
|
return Ok(());
|
|
}
|
|
for alert in &alerts {
|
|
print_alert(alert);
|
|
}
|
|
let counts = store.alert_counts()?;
|
|
if !counts.is_empty() {
|
|
println!("by rule:");
|
|
for (rule, count) in &counts {
|
|
println!(" {:<14} {count}", rule.as_str());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Prints one alert as readable lines: a header naming when, how urgent, which
|
|
/// rule, and the subject, then the evidence beneath it.
|
|
fn print_alert(alert: &Alert) {
|
|
let secs = alert.ts_nanos / 1_000_000_000;
|
|
let millis = alert.ts_nanos % 1_000_000_000 / 1_000_000;
|
|
let target = alert.ip.as_deref().unwrap_or("-");
|
|
println!(
|
|
"{secs}.{millis:03} [{}] {} {target} {}",
|
|
alert.severity.as_str(),
|
|
alert.rule.as_str(),
|
|
alert.title,
|
|
);
|
|
println!(" {}", alert.detail);
|
|
}
|
|
|
|
/// Prints a lookup report as readable lines.
|
|
fn print_report(report: &MatchReport) {
|
|
println!(
|
|
"{} {} => {} (threat {:.2}, confidence {:.2})",
|
|
report.kind.as_str(),
|
|
report.observed,
|
|
report.verdict.as_str(),
|
|
report.threat_score,
|
|
report.confidence,
|
|
);
|
|
if report.hits.is_empty() {
|
|
println!(" no intelligence on this fingerprint");
|
|
return;
|
|
}
|
|
for hit in &report.hits {
|
|
println!(
|
|
" {:<8} {:<18} {:<22} {}",
|
|
hit.category.as_str(),
|
|
hit.strength.as_str(),
|
|
hit.label,
|
|
hit.source,
|
|
);
|
|
if let Some(reference) = &hit.reference {
|
|
println!(" {reference}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reads the importer's input from a file or from standard input for `-`.
|
|
fn read_input(path: &Path) -> Result<String> {
|
|
if path.as_os_str() == "-" {
|
|
let mut buffer = String::new();
|
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer)
|
|
.context("reading ja4db JSON from standard input")?;
|
|
Ok(buffer)
|
|
} else {
|
|
std::fs::read_to_string(path)
|
|
.with_context(|| format!("reading ja4db JSON from {}", path.display()))
|
|
}
|
|
}
|