feat(ja3-ja4-tls-fingerprinting): M11 forensic pcap report, criterion benches, TLS miss-rate counter
Add a --report flag to the pcap command that prints one forensic summary of a whole capture (endpoint inventory, fingerprint distribution, intel verdicts, detection alerts, and a coverage section with the TLS miss rate and throughput) instead of the per-event stream, folding in intel and detection automatically when a seeded database is present. Add a tls_handshakes_fingerprinted counter and Counters::tls_miss_rate so truncated and multi-segment ClientHellos the capture clipped are reported rather than mistaken for absence. Add criterion benches over the vendored captures and the parse/hash hot path. Plus frontend landing and asset polish.
|
|
@ -79,6 +79,12 @@ dependencies = [
|
|||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
|
|
@ -286,6 +292,12 @@ version = "1.11.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.63"
|
||||
|
|
@ -302,6 +314,33 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"ciborium-ll",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-io"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-ll"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
|
|
@ -400,6 +439,46 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
dependencies = [
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
|
|
@ -452,6 +531,12 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
|
|
@ -662,6 +747,17 @@ dependencies = [
|
|||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
|
|
@ -701,6 +797,12 @@ version = "0.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
|
|
@ -838,12 +940,32 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -1044,6 +1166,12 @@ version = "1.70.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
|
|
@ -1316,6 +1444,15 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
|
|
@ -1538,6 +1675,16 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinytemplate"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tlsfp"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1571,6 +1718,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"criterion",
|
||||
"etherparse",
|
||||
"hex",
|
||||
"hkdf",
|
||||
|
|
@ -1839,6 +1987,16 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
|
|
@ -1958,6 +2116,15 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
|
|
|
|||
|
|
@ -31,3 +31,8 @@ aes-gcm.workspace = true
|
|||
hex.workspace = true
|
||||
proptest = "1"
|
||||
serde_json.workspace = true
|
||||
criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] }
|
||||
|
||||
[[bench]]
|
||||
name = "fingerprint"
|
||||
harness = false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
// ©AngelaMos | 2026
|
||||
// fingerprint.rs
|
||||
|
||||
//! Throughput benchmarks for the fingerprinting hot path.
|
||||
//!
|
||||
//! Two questions matter for a passive sensor: how fast it parses one handshake,
|
||||
//! and how fast it carries a whole capture through the pipeline. The first
|
||||
//! benchmark isolates parsing and hashing a single ClientHello; the second
|
||||
//! replays a vendored capture frame by frame, reporting fingerprints per second
|
||||
//! against the project target of ten thousand. The capture is read into memory
|
||||
//! once at setup so the file system never enters the measured loop.
|
||||
|
||||
use std::hint::black_box;
|
||||
use std::path::Path;
|
||||
|
||||
use criterion::{Criterion, Throughput, criterion_group, criterion_main};
|
||||
|
||||
use tlsfp_core::ja3::ja3;
|
||||
use tlsfp_core::ja4::{Transport, ja4};
|
||||
use tlsfp_core::parse::parse_client_hello;
|
||||
use tlsfp_core::pipeline::source::{PacketSource, PcapFileSource, RawFrame};
|
||||
use tlsfp_core::pipeline::{Pipeline, PipelineConfig};
|
||||
|
||||
/// Captures replayed by the pipeline benchmark, named for the report.
|
||||
const PCAPS: [(&str, &str); 2] = [
|
||||
(
|
||||
"tls-handshake",
|
||||
concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../testdata/pcap/tls-handshake.pcapng"
|
||||
),
|
||||
),
|
||||
(
|
||||
"browsers-x509",
|
||||
concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../testdata/pcap/browsers-x509.pcapng"
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
/// One captured frame copied into an owned buffer for replay.
|
||||
struct OwnedFrame {
|
||||
ts_nanos: u64,
|
||||
link_type: i32,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Reads every frame of a capture into memory so the benchmark loop measures
|
||||
/// the pipeline rather than the file system.
|
||||
fn load_frames(path: &str) -> Vec<OwnedFrame> {
|
||||
let mut source = PcapFileSource::open(Path::new(path)).expect("open capture");
|
||||
let mut frames = Vec::new();
|
||||
while let Some(frame) = source.next_frame().expect("read frame") {
|
||||
frames.push(OwnedFrame {
|
||||
ts_nanos: frame.ts_nanos,
|
||||
link_type: frame.link_type,
|
||||
data: frame.data.to_vec(),
|
||||
});
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
/// Replays preloaded frames through a fresh pipeline, returning the event count.
|
||||
fn replay(frames: &[OwnedFrame]) -> u64 {
|
||||
let mut pipeline = Pipeline::new(PipelineConfig::default());
|
||||
for frame in frames {
|
||||
let raw = RawFrame {
|
||||
ts_nanos: frame.ts_nanos,
|
||||
link_type: frame.link_type,
|
||||
data: &frame.data,
|
||||
};
|
||||
pipeline.feed(&raw, &mut |event| {
|
||||
black_box(&event);
|
||||
});
|
||||
}
|
||||
pipeline.finish();
|
||||
pipeline.counters().events
|
||||
}
|
||||
|
||||
fn bench_pipeline(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("pipeline");
|
||||
for (name, path) in PCAPS {
|
||||
let frames = load_frames(path);
|
||||
let events = replay(&frames).max(1);
|
||||
group.throughput(Throughput::Elements(events));
|
||||
group.bench_function(name, |b| {
|
||||
b.iter(|| black_box(replay(black_box(&frames))));
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_fingerprint(c: &mut Criterion) {
|
||||
let body = client_hello_body();
|
||||
let hello = parse_client_hello(&body).expect("the benchmark hello parses");
|
||||
|
||||
let mut group = c.benchmark_group("fingerprint");
|
||||
group.bench_function("parse_client_hello", |b| {
|
||||
b.iter(|| black_box(parse_client_hello(black_box(&body)).expect("parse")));
|
||||
});
|
||||
group.bench_function("ja3", |b| b.iter(|| black_box(ja3(black_box(&hello)))));
|
||||
group.bench_function("ja4", |b| {
|
||||
b.iter(|| black_box(ja4(black_box(&hello), Transport::Tcp)));
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Assembles a representative TLS 1.3 ClientHello body, the input the parser and
|
||||
/// the hash functions take. The exact bytes are not pinned to any published
|
||||
/// vector here: this is a throughput fixture, and the conformance vectors live
|
||||
/// in the integration tests.
|
||||
fn client_hello_body() -> Vec<u8> {
|
||||
const CIPHERS: [u16; 15] = [
|
||||
0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030, 0xcca9, 0xcca8, 0xc013, 0xc014,
|
||||
0x009c, 0x009d, 0x002f, 0x0035,
|
||||
];
|
||||
const GROUPS: [u16; 4] = [0x001d, 0x0017, 0x0018, 0x0019];
|
||||
const SIG_ALGS: [u16; 8] = [
|
||||
0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806, 0x0601,
|
||||
];
|
||||
const VERSIONS: [u16; 2] = [0x0304, 0x0303];
|
||||
const OPAQUE: [u16; 4] = [0x0005, 0x0017, 0x0023, 0xff01];
|
||||
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&0x0303u16.to_be_bytes());
|
||||
body.extend_from_slice(&[0u8; 32]);
|
||||
body.push(0);
|
||||
|
||||
let mut ciphers = Vec::new();
|
||||
for suite in CIPHERS {
|
||||
ciphers.extend_from_slice(&suite.to_be_bytes());
|
||||
}
|
||||
push_u16(&mut body, &ciphers);
|
||||
push_u8(&mut body, &[0]);
|
||||
|
||||
let mut exts = Vec::new();
|
||||
add_ext(&mut exts, 0x0000, &sni("example.com"));
|
||||
add_ext(&mut exts, 0x000a, &u16_list_u16(&GROUPS));
|
||||
add_ext(&mut exts, 0x000b, &u8_list(&[0]));
|
||||
add_ext(&mut exts, 0x000d, &u16_list_u16(&SIG_ALGS));
|
||||
add_ext(&mut exts, 0x0010, &alpn(&[b"h2", b"http/1.1"]));
|
||||
add_ext(&mut exts, 0x002b, &u8_list_u16(&VERSIONS));
|
||||
for ext in OPAQUE {
|
||||
add_ext(&mut exts, ext, &[]);
|
||||
}
|
||||
push_u16(&mut body, &exts);
|
||||
body
|
||||
}
|
||||
|
||||
fn push_u8(out: &mut Vec<u8>, data: &[u8]) {
|
||||
out.push(u8::try_from(data.len()).expect("length fits a byte"));
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
|
||||
fn push_u16(out: &mut Vec<u8>, data: &[u8]) {
|
||||
out.extend_from_slice(
|
||||
&u16::try_from(data.len())
|
||||
.expect("length fits two bytes")
|
||||
.to_be_bytes(),
|
||||
);
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
|
||||
fn add_ext(out: &mut Vec<u8>, ext_type: u16, data: &[u8]) {
|
||||
out.extend_from_slice(&ext_type.to_be_bytes());
|
||||
push_u16(out, data);
|
||||
}
|
||||
|
||||
fn sni(host: &str) -> Vec<u8> {
|
||||
let mut entry = vec![0u8];
|
||||
push_u16(&mut entry, host.as_bytes());
|
||||
let mut data = Vec::new();
|
||||
push_u16(&mut data, &entry);
|
||||
data
|
||||
}
|
||||
|
||||
fn alpn(protocols: &[&[u8]]) -> Vec<u8> {
|
||||
let mut list = Vec::new();
|
||||
for protocol in protocols {
|
||||
push_u8(&mut list, protocol);
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
push_u16(&mut data, &list);
|
||||
data
|
||||
}
|
||||
|
||||
fn u16_list_u16(values: &[u16]) -> Vec<u8> {
|
||||
let mut list = Vec::new();
|
||||
for value in values {
|
||||
list.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
push_u16(&mut data, &list);
|
||||
data
|
||||
}
|
||||
|
||||
fn u8_list_u16(values: &[u16]) -> Vec<u8> {
|
||||
let mut list = Vec::new();
|
||||
for value in values {
|
||||
list.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
push_u8(&mut data, &list);
|
||||
data
|
||||
}
|
||||
|
||||
fn u8_list(values: &[u8]) -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
push_u8(&mut data, values);
|
||||
data
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_pipeline, bench_fingerprint);
|
||||
criterion_main!(benches);
|
||||
|
|
@ -102,6 +102,10 @@ pub struct Counters {
|
|||
pub segments_dropped: u64,
|
||||
pub events: u64,
|
||||
pub streams_capped: u64,
|
||||
/// TCP streams recognized as TLS that yielded a complete ClientHello or
|
||||
/// ServerHello. The denominator, with `unfinished_tls_streams`, of the miss
|
||||
/// rate: how many handshakes the tool actually read.
|
||||
pub tls_handshakes_fingerprinted: u64,
|
||||
pub unfinished_tls_streams: u64,
|
||||
/// QUIC long header Initial packets observed, both directions.
|
||||
pub quic_initials: u64,
|
||||
|
|
@ -113,6 +117,28 @@ pub struct Counters {
|
|||
pub quic_version_unsupported: u64,
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
/// The share of recognized TLS streams the capture clipped before a
|
||||
/// complete handshake message could be read.
|
||||
///
|
||||
/// A fingerprinting tool that cannot say what it failed to read is one
|
||||
/// whose silence gets mistaken for absence. A truncated or multi segment
|
||||
/// ClientHello whose later segments never arrived counts as a miss here, so
|
||||
/// an operator can tell a clean link from a clipped capture before trusting
|
||||
/// the count of fingerprints. Zero recognized streams reports a zero rate
|
||||
/// rather than dividing by nothing.
|
||||
#[must_use]
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
pub fn tls_miss_rate(&self) -> f64 {
|
||||
let recognized = self.tls_handshakes_fingerprinted + self.unfinished_tls_streams;
|
||||
if recognized == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.unfinished_tls_streams as f64 / recognized as f64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One direction of one tracked flow.
|
||||
struct StreamHalf {
|
||||
reassembler: StreamReassembler,
|
||||
|
|
@ -300,8 +326,15 @@ impl Pipeline {
|
|||
|
||||
if outcome == PushOutcome::Grew {
|
||||
let mut emitted = 0u64;
|
||||
let mut tls_fingerprinted = 0u64;
|
||||
tls::advance(&mut half.protocol, half.reassembler.data(), &mut |event| {
|
||||
emitted += 1;
|
||||
if matches!(
|
||||
event,
|
||||
StreamEvent::ClientHello { .. } | StreamEvent::ServerHello { .. }
|
||||
) {
|
||||
tls_fingerprinted += 1;
|
||||
}
|
||||
sink(FingerprintEvent {
|
||||
ts_nanos: frame.ts_nanos,
|
||||
src,
|
||||
|
|
@ -310,6 +343,7 @@ impl Pipeline {
|
|||
});
|
||||
});
|
||||
self.counters.events += emitted;
|
||||
self.counters.tls_handshakes_fingerprinted += tls_fingerprinted;
|
||||
if half.protocol.finished() && !half.reassembler.released() {
|
||||
half.reassembler.release();
|
||||
}
|
||||
|
|
@ -595,6 +629,47 @@ mod tests {
|
|||
assert!(events[1].to_string().contains("http_request"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miss_rate_arithmetic_is_misses_over_recognized() {
|
||||
use super::Counters;
|
||||
let none = Counters::default();
|
||||
assert!(none.tls_miss_rate().abs() < 1e-9);
|
||||
|
||||
let clean = Counters {
|
||||
tls_handshakes_fingerprinted: 4,
|
||||
unfinished_tls_streams: 0,
|
||||
..Counters::default()
|
||||
};
|
||||
assert!(clean.tls_miss_rate().abs() < 1e-9);
|
||||
|
||||
let clipped = Counters {
|
||||
tls_handshakes_fingerprinted: 3,
|
||||
unfinished_tls_streams: 1,
|
||||
..Counters::default()
|
||||
};
|
||||
assert!((clipped.tls_miss_rate() - 0.25).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_tls_handshake_counts_as_a_miss() {
|
||||
let client = ([10, 0, 0, 1], 40002);
|
||||
let server = ([10, 0, 0, 2], 443);
|
||||
let clipped_client_hello = [
|
||||
0x16, 0x03, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0xfa, 0x03, 0x03,
|
||||
];
|
||||
|
||||
let frames = vec![tcp_frame(client, server, 1000, &clipped_client_hello)];
|
||||
let mut pipeline = Pipeline::new(PipelineConfig::default());
|
||||
let events = feed_all(&mut pipeline, &frames);
|
||||
pipeline.finish();
|
||||
|
||||
assert!(events.is_empty());
|
||||
let counters = pipeline.counters();
|
||||
assert_eq!(counters.tls_handshakes_fingerprinted, 0);
|
||||
assert_eq!(counters.unfinished_tls_streams, 1);
|
||||
assert!((counters.tls_miss_rate() - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_eviction_keeps_the_table_bounded() {
|
||||
let config = PipelineConfig {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
|
@ -12,6 +12,7 @@ use tracing_subscriber::EnvFilter;
|
|||
use tlsfp_core::{FingerprintEvent, PcapFileSource, Pipeline, PipelineConfig, SourceError};
|
||||
|
||||
use crate::live::{DEFAULT_BPF_FILTER, LiveConfig, LiveSource};
|
||||
use crate::report::ReportBuilder;
|
||||
use crate::serve::{self, ServeConfig, Source};
|
||||
use tlsfp_intel::{Alert, FpKind, IntelStore, MatchReport, MatchStrength, default_db_path};
|
||||
|
||||
|
|
@ -54,6 +55,16 @@ pub enum Command {
|
|||
#[arg(long)]
|
||||
detect: bool,
|
||||
|
||||
/// Print a single forensic summary of the whole capture instead of one
|
||||
/// line per handshake. When an intelligence database is present it also
|
||||
/// folds in match verdicts and detection alerts.
|
||||
#[arg(long)]
|
||||
report: bool,
|
||||
|
||||
/// How many rows each ranked section of the report shows.
|
||||
#[arg(long, default_value_t = crate::report::DEFAULT_TOP)]
|
||||
top: usize,
|
||||
|
||||
/// Path to the intelligence database, defaulting to the data directory.
|
||||
#[arg(long)]
|
||||
db: Option<PathBuf>,
|
||||
|
|
@ -251,8 +262,10 @@ impl Cli {
|
|||
json,
|
||||
intel,
|
||||
detect,
|
||||
report,
|
||||
top,
|
||||
db,
|
||||
} => run_pcap(&path, json, intel, detect, db.as_deref()),
|
||||
} => run_pcap(&path, json, intel, detect, report, top, db.as_deref()),
|
||||
Command::Live {
|
||||
interface,
|
||||
json,
|
||||
|
|
@ -318,7 +331,19 @@ impl IntelCommand {
|
|||
/// 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<()> {
|
||||
#[allow(clippy::fn_params_excessive_bools)]
|
||||
fn run_pcap(
|
||||
path: &Path,
|
||||
json: bool,
|
||||
intel: bool,
|
||||
detect: bool,
|
||||
report: bool,
|
||||
top: usize,
|
||||
db: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
if report {
|
||||
return run_pcap_report(path, json, top, db);
|
||||
}
|
||||
let mut source = PcapFileSource::open(path)
|
||||
.with_context(|| format!("cannot open capture {}", path.display()))?;
|
||||
let mut store = open_for_run(intel, detect, db)?;
|
||||
|
|
@ -356,7 +381,9 @@ fn run_pcap(path: &Path, json: bool, intel: bool, detect: bool, db: Option<&Path
|
|||
quic_initials = counters.quic_initials,
|
||||
quic_decrypted = counters.quic_decrypted,
|
||||
quic_version_unsupported = counters.quic_version_unsupported,
|
||||
tls_handshakes = counters.tls_handshakes_fingerprinted,
|
||||
unfinished_tls_streams = counters.unfinished_tls_streams,
|
||||
tls_miss_rate = counters.tls_miss_rate(),
|
||||
segments_dropped = counters.segments_dropped,
|
||||
"capture processed"
|
||||
);
|
||||
|
|
@ -366,6 +393,70 @@ fn run_pcap(path: &Path, json: bool, intel: bool, detect: bool, db: Option<&Path
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads a whole capture and prints one forensic summary instead of a stream
|
||||
/// of events.
|
||||
///
|
||||
/// Unlike the streaming path, the report builds an in process picture of every
|
||||
/// endpoint, fingerprint, and miss before printing, so it folds intelligence
|
||||
/// and detection in automatically whenever a database is present rather than
|
||||
/// asking for the flags. With no database it falls back to a pure fingerprint
|
||||
/// inventory. The intel lookups and detection writes are the same calls the
|
||||
/// streaming path makes, so the report can never disagree with a live sensor
|
||||
/// pointed at the same store.
|
||||
fn run_pcap_report(path: &Path, json: bool, top: usize, db: Option<&Path>) -> Result<()> {
|
||||
let mut source = PcapFileSource::open(path)
|
||||
.with_context(|| format!("cannot open capture {}", path.display()))?;
|
||||
let mut store = open_for_report(db)?;
|
||||
let enabled = store.is_some();
|
||||
let mut pipeline = Pipeline::new(PipelineConfig::default());
|
||||
let mut builder = ReportBuilder::new(&path.display().to_string());
|
||||
|
||||
let started = Instant::now();
|
||||
pipeline.run(&mut source, |event| {
|
||||
let reports = if enabled {
|
||||
enrich(store.as_ref(), &event)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let alerts = detect_event(store.as_mut(), enabled, &event);
|
||||
builder.observe(&event, &reports, &alerts);
|
||||
})?;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
let report = builder.finish(*pipeline.counters(), source.truncated(), elapsed, top);
|
||||
let stdout = std::io::stdout().lock();
|
||||
let mut out = std::io::BufWriter::new(stdout);
|
||||
if json {
|
||||
serde_json::to_writer_pretty(&mut out, &report).context("writing report as JSON")?;
|
||||
writeln!(out).context("writing report as JSON")?;
|
||||
} else {
|
||||
write!(out, "{}", report.render_text()).context("writing report")?;
|
||||
}
|
||||
out.flush().context("flushing the report to stdout")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens the store for a report run. A report folds in intelligence and
|
||||
/// detection whenever a database is there to answer, so this opens an existing
|
||||
/// database but never creates one: a forensic read of a capture should not
|
||||
/// quietly leave a new database behind, and with none present the report is
|
||||
/// still a complete fingerprint inventory.
|
||||
fn open_for_report(db: Option<&Path>) -> Result<Option<IntelStore>> {
|
||||
let path = db.map_or_else(default_db_path, Path::to_path_buf);
|
||||
if path.exists() {
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
"matching and recording detections against the intelligence database"
|
||||
);
|
||||
return Ok(Some(open_or_create(&path)?));
|
||||
}
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
"no intelligence database found; reporting fingerprints only, run 'tlsfp intel seed' to add verdicts"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Builds the dashboard configuration from the command line and starts the
|
||||
/// server. The live feed is a replayed capture, a live interface, or, by
|
||||
/// default, the alerts an external sensor tails into the same database.
|
||||
|
|
@ -507,7 +598,9 @@ async fn drive_live(
|
|||
quic_initials = counters.quic_initials,
|
||||
quic_decrypted = counters.quic_decrypted,
|
||||
quic_version_unsupported = counters.quic_version_unsupported,
|
||||
tls_handshakes = counters.tls_handshakes_fingerprinted,
|
||||
unfinished_tls_streams = counters.unfinished_tls_streams,
|
||||
tls_miss_rate = counters.tls_miss_rate(),
|
||||
segments_dropped = counters.segments_dropped,
|
||||
"live capture stopped"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
mod cli;
|
||||
mod live;
|
||||
mod report;
|
||||
mod serve;
|
||||
|
||||
use anyhow::Result;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,727 @@
|
|||
// ©AngelaMos | 2026
|
||||
// report.rs
|
||||
|
||||
//! The forensic batch report for a capture file.
|
||||
//!
|
||||
//! Where the streaming `pcap` output prints one line per handshake as it is
|
||||
//! read, the report holds the whole capture in mind and answers the questions
|
||||
//! an analyst asks after the fact: who spoke, what they presented, which
|
||||
//! fingerprints the intelligence database recognised, what the detection rules
|
||||
//! flagged, and, just as important, how much of the capture the tool could not
|
||||
//! read. The last part is the honesty section: a miss rate and a throughput
|
||||
//! that let a reader tell a clean capture from a clipped one before trusting an
|
||||
//! absence of fingerprints.
|
||||
//!
|
||||
//! The aggregation is fed one event at a time so a multi gigabyte capture never
|
||||
//! has to be held in memory at once; only the distinct fingerprints, endpoints,
|
||||
//! and names survive between events. The [`Report`] it produces serialises
|
||||
//! straight to JSON and renders to aligned text, and the builder is unit tested
|
||||
//! on synthetic events with no capture file in the picture.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::fmt::Write as _;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use tlsfp_core::{Counters, FingerprintEvent, StreamEvent};
|
||||
use tlsfp_intel::{Alert, MatchReport, Verdict};
|
||||
|
||||
/// How many rows each ranked section shows by default.
|
||||
pub const DEFAULT_TOP: usize = 15;
|
||||
|
||||
/// One endpoint's running inventory while the capture is being read.
|
||||
#[derive(Default)]
|
||||
struct EndpointAgg {
|
||||
events: u64,
|
||||
ja4: BTreeSet<String>,
|
||||
ja4s: BTreeSet<String>,
|
||||
ja4t: BTreeSet<String>,
|
||||
ja4h: BTreeSet<String>,
|
||||
ja4x: BTreeSet<String>,
|
||||
sni: BTreeSet<String>,
|
||||
user_agents: BTreeSet<String>,
|
||||
worst_verdict: Option<Verdict>,
|
||||
alerts: u64,
|
||||
}
|
||||
|
||||
/// One intelligence finding's running aggregate, keyed by fingerprint.
|
||||
struct IntelAgg {
|
||||
verdict: Verdict,
|
||||
threat_score: f64,
|
||||
label: String,
|
||||
source: String,
|
||||
endpoints: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// Accumulates a capture into a [`Report`], one event at a time.
|
||||
pub struct ReportBuilder {
|
||||
source: String,
|
||||
first_ts: Option<u64>,
|
||||
last_ts: Option<u64>,
|
||||
endpoints: HashMap<IpAddr, EndpointAgg>,
|
||||
by_kind: BTreeMap<&'static str, u64>,
|
||||
ja4_counts: HashMap<String, u64>,
|
||||
ja4_endpoints: HashMap<String, HashSet<IpAddr>>,
|
||||
sni_counts: HashMap<String, u64>,
|
||||
intel: HashMap<(String, String), IntelAgg>,
|
||||
alerts: Vec<Alert>,
|
||||
}
|
||||
|
||||
impl ReportBuilder {
|
||||
#[must_use]
|
||||
pub fn new(source: &str) -> Self {
|
||||
Self {
|
||||
source: source.to_owned(),
|
||||
first_ts: None,
|
||||
last_ts: None,
|
||||
endpoints: HashMap::new(),
|
||||
by_kind: BTreeMap::new(),
|
||||
ja4_counts: HashMap::new(),
|
||||
ja4_endpoints: HashMap::new(),
|
||||
sni_counts: HashMap::new(),
|
||||
intel: HashMap::new(),
|
||||
alerts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Folds one event, with any intelligence and alerts it produced, into the
|
||||
/// running aggregates.
|
||||
pub fn observe(&mut self, event: &FingerprintEvent, reports: &[MatchReport], alerts: &[Alert]) {
|
||||
self.first_ts = Some(
|
||||
self.first_ts
|
||||
.map_or(event.ts_nanos, |t| t.min(event.ts_nanos)),
|
||||
);
|
||||
self.last_ts = Some(
|
||||
self.last_ts
|
||||
.map_or(event.ts_nanos, |t| t.max(event.ts_nanos)),
|
||||
);
|
||||
|
||||
let ip = event.src.ip();
|
||||
*self.by_kind.entry(kind_label(&event.event)).or_insert(0) += 1;
|
||||
let endpoint = self.endpoints.entry(ip).or_default();
|
||||
endpoint.events += 1;
|
||||
|
||||
match &event.event {
|
||||
StreamEvent::ClientHello { ja4, sni, .. } => {
|
||||
endpoint.ja4.insert(ja4.hash.clone());
|
||||
*self.ja4_counts.entry(ja4.hash.clone()).or_insert(0) += 1;
|
||||
self.ja4_endpoints
|
||||
.entry(ja4.hash.clone())
|
||||
.or_default()
|
||||
.insert(ip);
|
||||
if let Some(name) = sni {
|
||||
endpoint.sni.insert(name.clone());
|
||||
*self.sni_counts.entry(name.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
StreamEvent::ServerHello { ja4s, .. } => {
|
||||
endpoint.ja4s.insert(ja4s.hash.clone());
|
||||
}
|
||||
StreamEvent::Certificate { ja4x } => {
|
||||
endpoint.ja4x.insert(ja4x.clone());
|
||||
}
|
||||
StreamEvent::HttpRequest {
|
||||
ja4h, user_agent, ..
|
||||
} => {
|
||||
endpoint.ja4h.insert(ja4h.hash.clone());
|
||||
if let Some(agent) = user_agent {
|
||||
endpoint.user_agents.insert(agent.clone());
|
||||
}
|
||||
}
|
||||
StreamEvent::TcpSyn { ja4t } => {
|
||||
endpoint.ja4t.insert(ja4t.clone());
|
||||
}
|
||||
StreamEvent::TcpSynAck { .. } => {}
|
||||
}
|
||||
|
||||
for report in reports {
|
||||
endpoint.worst_verdict = Some(worse(endpoint.worst_verdict, report.verdict));
|
||||
let key = (report.kind.as_str().to_owned(), report.observed.clone());
|
||||
let best = report
|
||||
.hits
|
||||
.iter()
|
||||
.max_by(|a, b| a.strength.weight().total_cmp(&b.strength.weight()));
|
||||
let agg = self.intel.entry(key).or_insert_with(|| IntelAgg {
|
||||
verdict: report.verdict,
|
||||
threat_score: report.threat_score,
|
||||
label: best.map_or_else(|| "-".to_owned(), |hit| hit.label.clone()),
|
||||
source: best.map_or_else(|| "-".to_owned(), |hit| hit.source.clone()),
|
||||
endpoints: BTreeSet::new(),
|
||||
});
|
||||
agg.threat_score = agg.threat_score.max(report.threat_score);
|
||||
agg.endpoints.insert(ip.to_string());
|
||||
}
|
||||
|
||||
endpoint.alerts += u64::try_from(alerts.len()).unwrap_or(u64::MAX);
|
||||
self.alerts.extend_from_slice(alerts);
|
||||
}
|
||||
|
||||
/// Closes the books and produces the report from the running aggregates,
|
||||
/// the pipeline's final counters, and the wall clock the run took.
|
||||
#[must_use]
|
||||
pub fn finish(
|
||||
self,
|
||||
counters: Counters,
|
||||
truncated: bool,
|
||||
elapsed: Duration,
|
||||
top: usize,
|
||||
) -> Report {
|
||||
let duration_secs = match (self.first_ts, self.last_ts) {
|
||||
(Some(first), Some(last)) => nanos_to_secs(last.saturating_sub(first)),
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
let mut endpoints: Vec<EndpointSummary> = self
|
||||
.endpoints
|
||||
.into_iter()
|
||||
.map(|(ip, agg)| EndpointSummary {
|
||||
ip: ip.to_string(),
|
||||
events: agg.events,
|
||||
ja4: agg.ja4.into_iter().collect(),
|
||||
ja4s: agg.ja4s.into_iter().collect(),
|
||||
ja4t: agg.ja4t.into_iter().collect(),
|
||||
ja4h: agg.ja4h.into_iter().collect(),
|
||||
ja4x: agg.ja4x.into_iter().collect(),
|
||||
sni: agg.sni.into_iter().collect(),
|
||||
user_agents: agg.user_agents.into_iter().collect(),
|
||||
verdict: agg.worst_verdict.map(|v| v.as_str().to_owned()),
|
||||
alerts: agg.alerts,
|
||||
})
|
||||
.collect();
|
||||
endpoints.sort_by(|a, b| b.events.cmp(&a.events).then_with(|| a.ip.cmp(&b.ip)));
|
||||
|
||||
let top_ja4 = rank(&self.ja4_counts, top, |value| FpCount {
|
||||
value: value.clone(),
|
||||
count: self.ja4_counts[value],
|
||||
endpoints: self.ja4_endpoints.get(value).map_or(0, HashSet::len),
|
||||
});
|
||||
let top_sni = rank(&self.sni_counts, top, |value| NameCount {
|
||||
name: value.clone(),
|
||||
count: self.sni_counts[value],
|
||||
});
|
||||
let by_kind = self
|
||||
.by_kind
|
||||
.iter()
|
||||
.map(|(kind, count)| KindCount {
|
||||
kind: (*kind).to_owned(),
|
||||
count: *count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut intel: Vec<IntelFinding> = self
|
||||
.intel
|
||||
.into_iter()
|
||||
.map(|((kind, value), agg)| IntelFinding {
|
||||
kind,
|
||||
value,
|
||||
verdict: agg.verdict.as_str().to_owned(),
|
||||
threat_score: agg.threat_score,
|
||||
label: agg.label,
|
||||
source: agg.source,
|
||||
endpoints: agg.endpoints.into_iter().collect(),
|
||||
})
|
||||
.collect();
|
||||
intel.sort_by(|a, b| {
|
||||
b.threat_score
|
||||
.total_cmp(&a.threat_score)
|
||||
.then_with(|| a.value.cmp(&b.value))
|
||||
});
|
||||
intel.truncate(top);
|
||||
|
||||
let alerts = AlertSummary::from_alerts(&self.alerts, top);
|
||||
|
||||
Report {
|
||||
capture: CaptureSummary {
|
||||
source: self.source,
|
||||
frames: counters.frames,
|
||||
bytes: counters.bytes,
|
||||
tcp_segments: counters.tcp_segments,
|
||||
udp_datagrams: counters.udp_datagrams,
|
||||
events: counters.events,
|
||||
flows: counters.flows_created,
|
||||
duration_secs,
|
||||
truncated,
|
||||
},
|
||||
distribution: Distribution {
|
||||
by_kind,
|
||||
top_ja4,
|
||||
top_sni,
|
||||
},
|
||||
endpoints,
|
||||
intel,
|
||||
alerts,
|
||||
coverage: Coverage::derive(&counters, elapsed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The full forensic report, ready to serialise or render.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Report {
|
||||
pub capture: CaptureSummary,
|
||||
pub distribution: Distribution,
|
||||
pub endpoints: Vec<EndpointSummary>,
|
||||
pub intel: Vec<IntelFinding>,
|
||||
pub alerts: AlertSummary,
|
||||
pub coverage: Coverage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CaptureSummary {
|
||||
pub source: String,
|
||||
pub frames: u64,
|
||||
pub bytes: u64,
|
||||
pub tcp_segments: u64,
|
||||
pub udp_datagrams: u64,
|
||||
pub events: u64,
|
||||
pub flows: u64,
|
||||
pub duration_secs: f64,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Distribution {
|
||||
pub by_kind: Vec<KindCount>,
|
||||
pub top_ja4: Vec<FpCount>,
|
||||
pub top_sni: Vec<NameCount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct KindCount {
|
||||
pub kind: String,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FpCount {
|
||||
pub value: String,
|
||||
pub count: u64,
|
||||
pub endpoints: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NameCount {
|
||||
pub name: String,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EndpointSummary {
|
||||
pub ip: String,
|
||||
pub events: u64,
|
||||
pub ja4: Vec<String>,
|
||||
pub ja4s: Vec<String>,
|
||||
pub ja4t: Vec<String>,
|
||||
pub ja4h: Vec<String>,
|
||||
pub ja4x: Vec<String>,
|
||||
pub sni: Vec<String>,
|
||||
pub user_agents: Vec<String>,
|
||||
pub verdict: Option<String>,
|
||||
pub alerts: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IntelFinding {
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
pub verdict: String,
|
||||
pub threat_score: f64,
|
||||
pub label: String,
|
||||
pub source: String,
|
||||
pub endpoints: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AlertSummary {
|
||||
pub total: u64,
|
||||
pub by_rule: Vec<RuleCount>,
|
||||
pub recent: Vec<Alert>,
|
||||
}
|
||||
|
||||
impl AlertSummary {
|
||||
fn from_alerts(alerts: &[Alert], top: usize) -> Self {
|
||||
let mut by_rule: BTreeMap<&'static str, u64> = BTreeMap::new();
|
||||
for alert in alerts {
|
||||
*by_rule.entry(alert.rule.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
let mut counts: Vec<RuleCount> = by_rule
|
||||
.into_iter()
|
||||
.map(|(rule, count)| RuleCount {
|
||||
rule: rule.to_owned(),
|
||||
count,
|
||||
})
|
||||
.collect();
|
||||
counts.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.rule.cmp(&b.rule)));
|
||||
|
||||
let recent = alerts.iter().rev().take(top).cloned().collect();
|
||||
Self {
|
||||
total: u64::try_from(alerts.len()).unwrap_or(u64::MAX),
|
||||
by_rule: counts,
|
||||
recent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RuleCount {
|
||||
pub rule: String,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Coverage {
|
||||
pub counters: Counters,
|
||||
pub tls_miss_rate: f64,
|
||||
pub quic_decrypt_rate: f64,
|
||||
pub events_per_sec: f64,
|
||||
pub frames_per_sec: f64,
|
||||
pub megabits_per_sec: f64,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
fn derive(counters: &Counters, elapsed: Duration) -> Self {
|
||||
let secs = elapsed.as_secs_f64();
|
||||
let per_sec = |n: u64| if secs > 0.0 { n as f64 / secs } else { 0.0 };
|
||||
let quic_decrypt_rate = if counters.quic_initials == 0 {
|
||||
0.0
|
||||
} else {
|
||||
counters.quic_decrypted as f64 / counters.quic_initials as f64
|
||||
};
|
||||
Self {
|
||||
counters: *counters,
|
||||
tls_miss_rate: counters.tls_miss_rate(),
|
||||
quic_decrypt_rate,
|
||||
events_per_sec: per_sec(counters.events),
|
||||
frames_per_sec: per_sec(counters.frames),
|
||||
megabits_per_sec: if secs > 0.0 {
|
||||
(counters.bytes as f64 * 8.0) / 1_000_000.0 / secs
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// Renders the report as aligned, sectioned text for a terminal reader.
|
||||
#[must_use]
|
||||
pub fn render_text(&self) -> String {
|
||||
let mut out = String::new();
|
||||
self.render_capture(&mut out);
|
||||
self.render_distribution(&mut out);
|
||||
self.render_endpoints(&mut out);
|
||||
self.render_intel(&mut out);
|
||||
self.render_alerts(&mut out);
|
||||
self.render_coverage(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn render_capture(&self, out: &mut String) {
|
||||
let c = &self.capture;
|
||||
let _ = writeln!(out, "== capture ==");
|
||||
let _ = writeln!(out, " source {}", c.source);
|
||||
let _ = writeln!(out, " frames {}", c.frames);
|
||||
let _ = writeln!(out, " bytes {}", c.bytes);
|
||||
let _ = writeln!(out, " tcp segments {}", c.tcp_segments);
|
||||
let _ = writeln!(out, " udp datagrams {}", c.udp_datagrams);
|
||||
let _ = writeln!(out, " flows {}", c.flows);
|
||||
let _ = writeln!(out, " fingerprints {}", c.events);
|
||||
let _ = writeln!(out, " capture span {:.3}s", c.duration_secs);
|
||||
if c.truncated {
|
||||
let _ = writeln!(out, " truncated yes (file ended mid packet)");
|
||||
}
|
||||
}
|
||||
|
||||
fn render_distribution(&self, out: &mut String) {
|
||||
let d = &self.distribution;
|
||||
let _ = writeln!(out, "\n== fingerprints by kind ==");
|
||||
if d.by_kind.is_empty() {
|
||||
let _ = writeln!(out, " none");
|
||||
}
|
||||
for row in &d.by_kind {
|
||||
let _ = writeln!(out, " {:<16} {}", row.kind, row.count);
|
||||
}
|
||||
|
||||
let _ = writeln!(out, "\n== top client ja4 ==");
|
||||
if d.top_ja4.is_empty() {
|
||||
let _ = writeln!(out, " none");
|
||||
}
|
||||
for row in &d.top_ja4 {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {:<40} {:>5} across {} endpoint(s)",
|
||||
row.value, row.count, row.endpoints
|
||||
);
|
||||
}
|
||||
|
||||
if !d.top_sni.is_empty() {
|
||||
let _ = writeln!(out, "\n== top server names ==");
|
||||
for row in &d.top_sni {
|
||||
let _ = writeln!(out, " {:<40} {:>5}", row.name, row.count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_endpoints(&self, out: &mut String) {
|
||||
let _ = writeln!(out, "\n== endpoints ==");
|
||||
if self.endpoints.is_empty() {
|
||||
let _ = writeln!(out, " none");
|
||||
}
|
||||
for endpoint in &self.endpoints {
|
||||
let verdict = endpoint.verdict.as_deref().unwrap_or("-");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} ({} event(s), verdict {}, {} alert(s))",
|
||||
endpoint.ip, endpoint.events, verdict, endpoint.alerts
|
||||
);
|
||||
write_list(out, "ja4", &endpoint.ja4);
|
||||
write_list(out, "ja4s", &endpoint.ja4s);
|
||||
write_list(out, "ja4t", &endpoint.ja4t);
|
||||
write_list(out, "ja4h", &endpoint.ja4h);
|
||||
write_list(out, "ja4x", &endpoint.ja4x);
|
||||
write_list(out, "sni", &endpoint.sni);
|
||||
write_list(out, "ua", &endpoint.user_agents);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_intel(&self, out: &mut String) {
|
||||
let _ = writeln!(out, "\n== intelligence ==");
|
||||
if self.intel.is_empty() {
|
||||
let _ = writeln!(out, " no fingerprints matched the database");
|
||||
return;
|
||||
}
|
||||
for finding in &self.intel {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" [{}] {} {} (threat {:.2})",
|
||||
finding.verdict, finding.kind, finding.value, finding.threat_score
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} ({}), {} endpoint(s)",
|
||||
finding.label,
|
||||
finding.source,
|
||||
finding.endpoints.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_alerts(&self, out: &mut String) {
|
||||
let _ = writeln!(out, "\n== alerts ==");
|
||||
if self.alerts.total == 0 {
|
||||
let _ = writeln!(out, " none raised");
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(out, " {} total", self.alerts.total);
|
||||
for row in &self.alerts.by_rule {
|
||||
let _ = writeln!(out, " {:<14} {}", row.rule, row.count);
|
||||
}
|
||||
let _ = writeln!(out, " most recent:");
|
||||
for alert in &self.alerts.recent {
|
||||
let target = alert.ip.as_deref().unwrap_or("-");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" [{}] {} {} {}",
|
||||
alert.severity.as_str(),
|
||||
alert.rule.as_str(),
|
||||
target,
|
||||
alert.title
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_coverage(&self, out: &mut String) {
|
||||
let c = &self.coverage;
|
||||
let _ = writeln!(out, "\n== coverage ==");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" tls miss rate {:.1}% ({} read, {} clipped)",
|
||||
c.tls_miss_rate * 100.0,
|
||||
c.counters.tls_handshakes_fingerprinted,
|
||||
c.counters.unfinished_tls_streams
|
||||
);
|
||||
let _ = writeln!(out, " streams capped {}", c.counters.streams_capped);
|
||||
let _ = writeln!(out, " segments dropped {}", c.counters.segments_dropped);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" quic initials {} ({} decrypted, {:.0}%, {} unsupported version)",
|
||||
c.counters.quic_initials,
|
||||
c.counters.quic_decrypted,
|
||||
c.quic_decrypt_rate * 100.0,
|
||||
c.counters.quic_version_unsupported
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" throughput {:.0} fp/s, {:.0} frames/s, {:.1} Mb/s",
|
||||
c.events_per_sec, c.frames_per_sec, c.megabits_per_sec
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes one labelled line listing a set's values, skipping an empty set.
|
||||
fn write_list(out: &mut String, label: &str, values: &[String]) {
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(out, " {:<5} {}", label, values.join(", "));
|
||||
}
|
||||
|
||||
/// The snake case name a stream event carries, matching its serialised tag.
|
||||
fn kind_label(event: &StreamEvent) -> &'static str {
|
||||
match event {
|
||||
StreamEvent::ClientHello { .. } => "client_hello",
|
||||
StreamEvent::ServerHello { .. } => "server_hello",
|
||||
StreamEvent::Certificate { .. } => "certificate",
|
||||
StreamEvent::HttpRequest { .. } => "http_request",
|
||||
StreamEvent::TcpSyn { .. } => "tcp_syn",
|
||||
StreamEvent::TcpSynAck { .. } => "tcp_syn_ack",
|
||||
}
|
||||
}
|
||||
|
||||
/// Ranks the keys of a count map by count descending then key ascending,
|
||||
/// keeping the top `n` and mapping each through `build`.
|
||||
fn rank<T>(counts: &HashMap<String, u64>, n: usize, build: impl Fn(&String) -> T) -> Vec<T> {
|
||||
let mut keys: Vec<&String> = counts.keys().collect();
|
||||
keys.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then_with(|| a.cmp(b)));
|
||||
keys.into_iter().take(n).map(build).collect()
|
||||
}
|
||||
|
||||
/// The more alarming of two verdicts, used to fold an endpoint's worst case.
|
||||
fn worse(current: Option<Verdict>, next: Verdict) -> Verdict {
|
||||
let rank = |v: Verdict| match v {
|
||||
Verdict::Malicious => 3,
|
||||
Verdict::Suspicious => 2,
|
||||
Verdict::Unknown => 1,
|
||||
Verdict::Benign => 0,
|
||||
};
|
||||
match current {
|
||||
Some(existing) if rank(existing) >= rank(next) => existing,
|
||||
_ => next,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts whole nanoseconds to fractional seconds without the precision loss
|
||||
/// lint, accepting that a capture span beyond `u32::MAX` seconds is unreachable.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
fn nanos_to_secs(nanos: u64) -> f64 {
|
||||
nanos as f64 / 1_000_000_000.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use tlsfp_core::{Counters, FingerprintEvent, Ja3, Ja4Family, StreamEvent};
|
||||
use tlsfp_intel::{Alert, AlertSeverity, FpKind, IntelHit, MatchReport, MatchStrength, Rule};
|
||||
|
||||
use super::ReportBuilder;
|
||||
|
||||
fn client_hello(ip: &str, ja4: &str, sni: &str) -> FingerprintEvent {
|
||||
FingerprintEvent {
|
||||
ts_nanos: 1_000_000_000,
|
||||
src: format!("{ip}:40000").parse().unwrap(),
|
||||
dst: "10.0.0.2:443".parse().unwrap(),
|
||||
event: StreamEvent::ClientHello {
|
||||
ja3: Ja3::from_digest([0u8; 16]),
|
||||
ja3_raw: String::new(),
|
||||
ja4: Ja4Family::new(ja4.to_owned(), String::new()),
|
||||
sni: Some(sni.to_owned()),
|
||||
alpn: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_endpoints_and_distribution() {
|
||||
let mut builder = ReportBuilder::new("test.pcap");
|
||||
builder.observe(
|
||||
&client_hello("10.0.0.1", "t13d1516h2_aaaa_bbbb", "a.example"),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
builder.observe(
|
||||
&client_hello("10.0.0.1", "t13d1516h2_aaaa_bbbb", "b.example"),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
builder.observe(
|
||||
&client_hello("10.0.0.9", "t13d1516h2_aaaa_bbbb", "a.example"),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
|
||||
let report = builder.finish(Counters::default(), false, Duration::from_millis(10), 15);
|
||||
|
||||
assert_eq!(report.endpoints.len(), 2);
|
||||
assert_eq!(report.endpoints[0].ip, "10.0.0.1");
|
||||
assert_eq!(report.endpoints[0].events, 2);
|
||||
assert_eq!(report.distribution.top_ja4.len(), 1);
|
||||
assert_eq!(report.distribution.top_ja4[0].count, 3);
|
||||
assert_eq!(report.distribution.top_ja4[0].endpoints, 2);
|
||||
assert_eq!(report.distribution.top_sni[0].count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folds_intel_and_alerts_into_worst_verdict() {
|
||||
let mut builder = ReportBuilder::new("test.pcap");
|
||||
let event = client_hello("10.0.0.1", "t13d1516h2_aaaa_bbbb", "evil.example");
|
||||
let report = MatchReport::from_hits(
|
||||
FpKind::Ja4,
|
||||
"t13d1516h2_aaaa_bbbb".to_owned(),
|
||||
vec![IntelHit {
|
||||
kind: FpKind::Ja4,
|
||||
value: "t13d1516h2_aaaa_bbbb".to_owned(),
|
||||
label: "Cobalt Strike".to_owned(),
|
||||
category: tlsfp_intel::Category::Malware,
|
||||
source: "test-feed".to_owned(),
|
||||
reference: None,
|
||||
strength: MatchStrength::Exact,
|
||||
}],
|
||||
);
|
||||
let alert = Alert {
|
||||
ts_nanos: 1_000_000_000,
|
||||
rule: Rule::KnownBad,
|
||||
severity: AlertSeverity::Critical,
|
||||
ip: Some("10.0.0.1".to_owned()),
|
||||
fp_kind: Some(FpKind::Ja4),
|
||||
fp_value: Some("t13d1516h2_aaaa_bbbb".to_owned()),
|
||||
title: "known bad fingerprint".to_owned(),
|
||||
detail: "matched test-feed".to_owned(),
|
||||
score: Some(1.0),
|
||||
};
|
||||
builder.observe(&event, &[report], &[alert]);
|
||||
|
||||
let built = builder.finish(Counters::default(), false, Duration::from_millis(5), 15);
|
||||
assert_eq!(built.endpoints[0].verdict.as_deref(), Some("malicious"));
|
||||
assert_eq!(built.endpoints[0].alerts, 1);
|
||||
assert_eq!(built.intel.len(), 1);
|
||||
assert_eq!(built.intel[0].label, "Cobalt Strike");
|
||||
assert_eq!(built.alerts.total, 1);
|
||||
assert_eq!(built.alerts.by_rule[0].rule, "known_bad");
|
||||
|
||||
let text = built.render_text();
|
||||
assert!(text.contains("== coverage =="));
|
||||
assert!(text.contains("Cobalt Strike"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miss_rate_and_throughput_reach_the_report() {
|
||||
let counters = Counters {
|
||||
frames: 1000,
|
||||
bytes: 1_000_000,
|
||||
events: 200,
|
||||
tls_handshakes_fingerprinted: 3,
|
||||
unfinished_tls_streams: 1,
|
||||
..Counters::default()
|
||||
};
|
||||
let builder = ReportBuilder::new("test.pcap");
|
||||
let report = builder.finish(counters, true, Duration::from_secs(1), 15);
|
||||
assert!((report.coverage.tls_miss_rate - 0.25).abs() < 1e-9);
|
||||
assert!((report.coverage.events_per_sec - 200.0).abs() < 1e-9);
|
||||
assert!(report.capture.truncated);
|
||||
assert!(report.render_text().contains("truncated"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
// ©AngelaMos | 2026
|
||||
// report.rs
|
||||
|
||||
//! End to end coverage of the forensic report mode.
|
||||
//!
|
||||
//! These drive the real binary against a vendored capture the way an analyst
|
||||
//! would, then assert on what it prints. The capture is read with a database
|
||||
//! path that does not exist, so the report runs as a pure fingerprint inventory
|
||||
//! with no intelligence side effects and no dependence on a seeded store, which
|
||||
//! keeps the test hermetic.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// The vendored capture used throughout: a browser session with TLS, QUIC, and
|
||||
/// certificate handshakes, enough to exercise every section of the report.
|
||||
fn capture() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata/pcap/tls-handshake.pcapng")
|
||||
}
|
||||
|
||||
/// A database path under the temp directory that is guaranteed not to exist, so
|
||||
/// the report stays a pure inventory and never writes a store behind the test.
|
||||
fn absent_db() -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"tlsfp-report-test-{}-absent.db",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_tlsfp"))
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("running the tlsfp binary")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_report_has_every_section() {
|
||||
let db = absent_db();
|
||||
let output = run(&[
|
||||
"pcap",
|
||||
capture().to_str().unwrap(),
|
||||
"--report",
|
||||
"--db",
|
||||
db.to_str().unwrap(),
|
||||
]);
|
||||
assert!(output.status.success(), "report run failed");
|
||||
let text = String::from_utf8(output.stdout).expect("utf8 report");
|
||||
|
||||
for section in [
|
||||
"== capture ==",
|
||||
"== fingerprints by kind ==",
|
||||
"== top client ja4 ==",
|
||||
"== endpoints ==",
|
||||
"== intelligence ==",
|
||||
"== alerts ==",
|
||||
"== coverage ==",
|
||||
] {
|
||||
assert!(text.contains(section), "missing section {section}");
|
||||
}
|
||||
assert!(text.contains("t13d1516h2_8daaf6152771_e5627efa2ab1"));
|
||||
assert!(text.contains("q13d0310h3_55b375c5d22e_cd85d2d88918"));
|
||||
assert!(text.contains("tls miss rate"));
|
||||
assert!(text.contains("fp/s"));
|
||||
assert!(!db.exists(), "report must not create a database");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_report_is_well_formed() {
|
||||
let db = absent_db();
|
||||
let output = run(&[
|
||||
"pcap",
|
||||
capture().to_str().unwrap(),
|
||||
"--report",
|
||||
"--json",
|
||||
"--db",
|
||||
db.to_str().unwrap(),
|
||||
]);
|
||||
assert!(output.status.success(), "json report run failed");
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&output.stdout).expect("report is valid JSON");
|
||||
|
||||
assert!(value["capture"]["frames"].as_u64().unwrap() > 0);
|
||||
assert!(
|
||||
value["coverage"]["counters"]["tls_handshakes_fingerprinted"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
> 0
|
||||
);
|
||||
assert!(value["coverage"]["events_per_sec"].as_f64().unwrap() > 0.0);
|
||||
assert!(
|
||||
!value["distribution"]["top_ja4"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let endpoints = value["endpoints"].as_array().unwrap();
|
||||
assert!(endpoints.iter().any(|e| e["ip"] == "192.168.1.168"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_flag_caps_ranked_rows() {
|
||||
let db = absent_db();
|
||||
let output = run(&[
|
||||
"pcap",
|
||||
capture().to_str().unwrap(),
|
||||
"--report",
|
||||
"--json",
|
||||
"--top",
|
||||
"2",
|
||||
"--db",
|
||||
db.to_str().unwrap(),
|
||||
]);
|
||||
assert!(output.status.success(), "capped report run failed");
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&output.stdout).expect("report is valid JSON");
|
||||
assert!(value["distribution"]["top_sni"].as_array().unwrap().len() <= 2);
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; form-action 'self'; worker-src 'self' blob:;"
|
||||
/>
|
||||
<title>JA3/JA4 TLS Fingerprinting</title>
|
||||
<title>MKUtra Valedictorian</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="*Cracked*"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 891 B After Width: | Height: | Size: 901 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 173 KiB |
|
|
@ -1 +1 @@
|
|||
{"name":"JA3/JA4 TLS Fingerprinting","short_name":"TLSFP","icons":[{"src":"/assets/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/assets/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#000000","background_color":"#000000","display":"standalone"}
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ export function Component(): React.ReactElement {
|
|||
|
||||
<header className={styles.head}>
|
||||
<span className={styles.kicker}>tls dossier / passive</span>
|
||||
<span className={styles.stamp}>specimen 001 / handshake</span>
|
||||
<span className={styles.stamp}>specimen 006 / handshake</span>
|
||||
</header>
|
||||
|
||||
<main className={styles.center}>
|
||||
<span className={styles.over}>surveillance aesthetics</span>
|
||||
<span className={styles.over}>sleep is counter surveillance</span>
|
||||
<h1 className={styles.title}>TLSFP</h1>
|
||||
<span className={styles.under}>
|
||||
the fingerprint of every client that speaks tls
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ run-backend *ARGS:
|
|||
test-backend:
|
||||
cargo test --workspace
|
||||
|
||||
[group('backend')]
|
||||
bench *ARGS:
|
||||
cargo bench --bench fingerprint {{ARGS}}
|
||||
|
||||
[group('backend')]
|
||||
clippy:
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
|
|
|||