the hallway is four steps longer at night. i've confirmed this.

This commit is contained in:
CarterPerez-dev 2026-06-18 17:40:31 -04:00
parent 4bb95fdb97
commit 56c2b0233c
30 changed files with 465 additions and 87 deletions

View File

@ -109,6 +109,10 @@ jobs:
- name: monitor-the-situation-dashboard-frontend
type: biome
path: PROJECTS/advanced/monitor-the-situation-dashboard/frontend
# Frontend (biome + stylelint + tsc)
- name: ja3-ja4-tls-fingerprinting-frontend
type: frontend
path: PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend
# Go
- name: simple-vulnerability-scanner
type: go
@ -142,6 +146,9 @@ jobs:
- name: binary-analysis-tool-backend
type: rust
path: PROJECTS/intermediate/binary-analysis-tool/backend
- name: ja3-ja4-tls-fingerprinting
type: rust
path: PROJECTS/intermediate/ja3-ja4-tls-fingerprinting
# Crystal
- name: credential-rotation-enforcer
type: crystal
@ -184,21 +191,21 @@ jobs:
if: matrix.type == 'ruff'
run: pip install ruff
# Biome Setup
# Biome / Frontend Setup
- name: Setup Node.js
if: matrix.type == 'biome'
if: matrix.type == 'biome' || matrix.type == 'frontend'
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup pnpm
if: matrix.type == 'biome'
if: matrix.type == 'biome' || matrix.type == 'frontend'
uses: pnpm/action-setup@v4
with:
version: 10
- name: Cache pnpm store
if: matrix.type == 'biome'
if: matrix.type == 'biome' || matrix.type == 'frontend'
uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store/v10
@ -208,7 +215,7 @@ jobs:
${{ runner.os }}-pnpm-
- name: Install frontend dependencies
if: matrix.type == 'biome'
if: matrix.type == 'biome' || matrix.type == 'frontend'
run: pnpm install --frozen-lockfile
# Go Setup
@ -333,6 +340,41 @@ jobs:
cat biome-output.txt
continue-on-error: true
# Frontend Linting (biome + stylelint + tsc)
- name: Run Biome, Stylelint, and tsc
if: matrix.type == 'frontend'
id: frontend
run: |
FRONTEND_OK=true
echo "Running Biome check..."
if pnpm biome check . > frontend-output.txt 2>&1; then
echo "biome: no issues"
else
FRONTEND_OK=false
echo "biome: issues found"
fi
echo "Running Stylelint..."
if pnpm stylelint "**/*.scss" >> frontend-output.txt 2>&1; then
echo "stylelint: no issues"
else
FRONTEND_OK=false
echo "stylelint: issues found"
fi
echo "Running tsc --noEmit..."
if pnpm tsc --noEmit >> frontend-output.txt 2>&1; then
echo "tsc: no issues"
else
FRONTEND_OK=false
echo "tsc: type errors found"
fi
if [[ "$FRONTEND_OK" == "true" ]]; then
echo "FRONTEND_PASSED=true" >> $GITHUB_ENV
else
echo "FRONTEND_PASSED=false" >> $GITHUB_ENV
fi
cat frontend-output.txt
continue-on-error: true
# Go Linting
- name: Run golangci-lint
if: matrix.type == 'go'
@ -589,6 +631,35 @@ jobs:
fi
} >> $GITHUB_STEP_SUMMARY
# Create Summary for Frontend
- name: Create Frontend Lint Summary
if: matrix.type == 'frontend'
run: |
{
echo "## Lint Results: ${{ matrix.name }}"
echo ''
if [[ "${{ env.FRONTEND_PASSED }}" == "true" ]]; then
echo '### Biome + Stylelint + tsc: **Passed**'
echo 'No frontend issues found.'
else
echo '### Biome + Stylelint + tsc: **Issues Found**'
echo '<details><summary>View output</summary>'
echo ''
echo '```'
head -100 frontend-output.txt
echo '```'
echo '</details>'
fi
echo ''
if [[ "${{ env.FRONTEND_PASSED }}" == "true" ]]; then
echo '---'
echo '### All checks passed!'
else
echo '---'
echo '### Review the issues above'
fi
} >> $GITHUB_STEP_SUMMARY
# Create Summary for Go
- name: Create Go Lint Summary
if: matrix.type == 'go'
@ -809,6 +880,11 @@ jobs:
echo "Biome lint checks failed"
exit 1
fi
elif [[ "${{ matrix.type }}" == "frontend" ]]; then
if [[ "${{ env.FRONTEND_PASSED }}" == "false" ]]; then
echo "Frontend lint checks failed"
exit 1
fi
elif [[ "${{ matrix.type }}" == "go" ]]; then
if [[ "${{ env.GOLANGCI_PASSED }}" == "false" ]]; then
echo "Go lint checks failed"

View File

@ -258,6 +258,33 @@ repos:
files: ^PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/
pass_filenames: false
- id: biome-ja3-ja4-tls-fingerprinting
name: biome check (ja3-ja4-tls-fingerprinting frontend)
entry: bash -c 'cd PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend && pnpm biome check .'
language: system
files: ^PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/
pass_filenames: false
# Stylelint SCSS Checks
- repo: local
hooks:
- id: stylelint-ja3-ja4-tls-fingerprinting
name: stylelint (ja3-ja4-tls-fingerprinting frontend)
entry: bash -c 'cd PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend && pnpm stylelint "**/*.scss"'
language: system
files: ^PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/
pass_filenames: false
# TypeScript tsc Checks
- repo: local
hooks:
- id: tsc-ja3-ja4-tls-fingerprinting
name: tsc typecheck (ja3-ja4-tls-fingerprinting frontend)
entry: bash -c 'cd PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend && pnpm tsc --noEmit'
language: system
files: ^PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/
pass_filenames: false
# Nim nph Checks
- repo: local
hooks:
@ -287,6 +314,22 @@ repos:
types: [rust]
pass_filenames: false
- id: cargo-fmt-ja3-ja4-tls-fingerprinting
name: cargo fmt (ja3-ja4-tls-fingerprinting)
entry: bash -c 'cd PROJECTS/intermediate/ja3-ja4-tls-fingerprinting && cargo fmt --all -- --check'
language: system
files: ^PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/
types: [rust]
pass_filenames: false
- id: cargo-clippy-ja3-ja4-tls-fingerprinting
name: cargo clippy (ja3-ja4-tls-fingerprinting)
entry: bash -c 'cd PROJECTS/intermediate/ja3-ja4-tls-fingerprinting && cargo clippy --workspace --all-targets -- -D warnings'
language: system
files: ^PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/
types: [rust]
pass_filenames: false
# Crystal Checks (format + ameba + compile)
- repo: local
hooks:

View File

@ -8,6 +8,7 @@ pub mod tag {
pub const OBJECT_IDENTIFIER: u8 = 0x06;
pub const SEQUENCE: u8 = 0x30;
pub const SET: u8 = 0x31;
pub const CONTEXT_0: u8 = 0xa0;
pub const CONTEXT_3: u8 = 0xa3;
}

View File

@ -62,7 +62,7 @@ mod tests {
}
#[test]
fn foxio_linux_default_vector() {
fn foxio_windows_default_vector() {
let i = input(64240, &[2, 1, 3, 1, 1, 4], 1460, 8);
assert_eq!(ja4t(&i), "64240_2-1-3-1-1-4_1460_8");
}

View File

@ -32,6 +32,15 @@ pub fn ja4x(cert_der: &[u8]) -> Result<String> {
type OidList = SmallVec<[String; 8]>;
/// Walks a certificate down to the issuer Name, the subject Name, and the
/// extension block, the three field groups JA4X reads.
///
/// The TBSCertificate fields sit in a fixed order (RFC 5280): an optional
/// explicit version tagged context zero, then the serial number, signature
/// algorithm, issuer, validity, subject, and public key, with the extensions
/// tagged context three at the end. The walk names every field it steps over and
/// bounds checks each one, so a certificate truncated before the public key or
/// the extensions is an error rather than a panic.
fn certificate_oids(cert_der: &[u8]) -> Result<(OidList, OidList, OidList)> {
let mut outer = Der::new(cert_der);
let (_, certificate) = outer.read_tlv()?;
@ -44,19 +53,24 @@ fn certificate_oids(cert_der: &[u8]) -> Result<(OidList, OidList, OidList)> {
fields.push(walker.read_tlv()?);
}
let mut idx = 0;
if fields.first().is_some_and(|(t, _)| *t == 0xa0) {
idx += 1;
let mut cursor = 0usize;
if fields.first().is_some_and(|(t, _)| *t == tag::CONTEXT_0) {
cursor += 1;
}
idx += 2;
let issuer = field_content(&fields, idx)?;
idx += 1;
idx += 1;
let subject = field_content(&fields, idx)?;
idx += 1;
idx += 1;
let _serial = field_content(&fields, cursor)?;
cursor += 1;
let _signature = field_content(&fields, cursor)?;
cursor += 1;
let issuer = field_content(&fields, cursor)?;
cursor += 1;
let _validity = field_content(&fields, cursor)?;
cursor += 1;
let subject = field_content(&fields, cursor)?;
cursor += 1;
let extensions = fields[idx..]
let extensions = fields
.get(cursor..)
.unwrap_or(&[])
.iter()
.find(|(t, _)| *t == tag::CONTEXT_3)
.map(|(_, c)| *c);
@ -156,6 +170,31 @@ mod tests {
assert_eq!(oids.as_slice(), &["550406", "55040a", "55040b", "550403"]);
}
#[test]
fn certificate_ending_after_subject_is_handled_without_panic() {
let rdn = tlv(
0x31,
&tlv(0x30, &{
let mut atv = oid(&[0x55, 0x04, 0x03]);
atv.extend(tlv(0x13, b"x"));
atv
}),
);
let tbs = tlv(
0x30,
&[
tlv(0x02, &[0x01]),
tlv(0x30, &[]),
tlv(0x30, &rdn),
tlv(0x30, &[]),
tlv(0x30, &rdn),
]
.concat(),
);
let cert = tlv(0x30, &tbs);
assert!(super::ja4x(&cert).is_ok());
}
#[test]
fn extracts_extension_oids() {
let mut sequence = Vec::new();

View File

@ -372,7 +372,7 @@ mod tests {
}
#[test]
fn ja4t_walk_reproduces_the_linux_default_vector() {
fn ja4t_walk_reproduces_the_windows_default_vector() {
let options = [
0x02, 0x04, 0x05, 0xb4, 0x01, 0x03, 0x03, 0x08, 0x01, 0x01, 0x04, 0x02,
];

View File

@ -399,7 +399,7 @@ impl Pipeline {
let opened = if let Some(keys) = flow.keys.as_ref() {
packet.open(keys, flow.largest_pn).ok()
} else {
let candidate = InitialKeys::client(packet.dcid);
let candidate = packet.client_keys();
match packet.open(&candidate, None) {
Ok(opened) => {
flow.keys = Some(candidate);

View File

@ -49,6 +49,9 @@ use crate::registry::handshake_type;
/// The QUIC version 1 code point, from RFC 9000.
pub const VERSION_1: u32 = 0x0000_0001;
/// The QUIC version 2 code point, from RFC 9369.
pub const VERSION_2: u32 = 0x6b33_43cf;
/// The version 1 Initial salt, from RFC 9001 Section 5.2. This constant is
/// what binds a set of Initial keys to one QUIC version: a different version
/// uses a different salt, so keys derived under the wrong salt simply fail
@ -58,6 +61,15 @@ const INITIAL_SALT_V1: [u8; 20] = [
0xcc, 0xbb, 0x7f, 0x0a,
];
/// The version 2 Initial salt, from RFC 9369 Section 3.3.1. QUIC v2 deliberately
/// changes the salt, the key derivation labels, and the long header type code
/// for Initial away from their v1 values, so an observer must implement the
/// version on purpose rather than assume v1 framing.
const INITIAL_SALT_V2: [u8; 20] = [
0x0d, 0xed, 0xe3, 0xde, 0xf7, 0x00, 0xa6, 0xdb, 0x81, 0x93, 0x81, 0xbe, 0x6e, 0x26, 0x9d, 0xcb,
0xf9, 0xbd, 0x2e, 0xd9,
];
const KEY_LEN: usize = 16;
const IV_LEN: usize = 12;
const HP_LEN: usize = 16;
@ -79,10 +91,11 @@ const PN_SAMPLE_OFFSET: usize = 4;
const MAX_CID_LEN: usize = 20;
/// The high bit of the first byte marks a long header; the next bit is the
/// version independent fixed bit. Initial is long header packet type `0b00`.
/// version independent fixed bit. The two type bits identify an Initial, but
/// their value differs by version, `0b00` in v1 and `0b01` in v2, so the type
/// code is carried per version rather than as one constant.
const LONG_HEADER_FORM: u8 = 0x80;
const PACKET_TYPE_MASK: u8 = 0x30;
const PACKET_TYPE_INITIAL: u8 = 0x00;
/// Header protection masks the low four bits of a long header's first byte.
const LONG_HEADER_PN_MASK: u8 = 0x0f;
@ -174,6 +187,51 @@ fn hkdf_expand_label(secret: &[u8], label: &[u8], out: &mut [u8]) {
.expect("Initial output never exceeds the HKDF limit");
}
/// The version specific inputs to Initial key derivation: the HKDF salt, the
/// key, IV, and header protection labels, and the long header type code that
/// marks an Initial packet.
///
/// QUIC v1 (RFC 9001) and v2 (RFC 9369) differ in all of these. Binding them to
/// the version is what makes keys derived under the wrong version fail the AEAD
/// tag rather than silently misread a packet, and the `client in` secret label
/// is shared because it comes from the TLS 1.3 derivation, which v2 left alone.
#[derive(Clone, Copy)]
struct QuicVersion {
salt: &'static [u8; 20],
key_label: &'static [u8],
iv_label: &'static [u8],
hp_label: &'static [u8],
initial_type: u8,
}
impl QuicVersion {
const V1: QuicVersion = QuicVersion {
salt: &INITIAL_SALT_V1,
key_label: b"quic key",
iv_label: b"quic iv",
hp_label: b"quic hp",
initial_type: 0x00,
};
const V2: QuicVersion = QuicVersion {
salt: &INITIAL_SALT_V2,
key_label: b"quicv2 key",
iv_label: b"quicv2 iv",
hp_label: b"quicv2 hp",
initial_type: 0x10,
};
/// Returns the parameters for a known QUIC version code, or `None` for a
/// version this build has no Initial salt for.
fn from_code(code: u32) -> Option<QuicVersion> {
match code {
VERSION_1 => Some(Self::V1),
VERSION_2 => Some(Self::V2),
_ => None,
}
}
}
/// The AEAD and header protection keys for one direction of one QUIC Initial.
pub struct InitialKeys {
key: [u8; KEY_LEN],
@ -182,16 +240,16 @@ pub struct InitialKeys {
}
impl InitialKeys {
/// Derives the client's Initial keys from a Destination Connection ID,
/// for QUIC version 1.
/// Derives the client's Initial keys from a Destination Connection ID, under
/// the salt and labels of the given QUIC version.
///
/// A passive observer derives these from the connection ID alone, with no
/// secret input, which is exactly why the AEAD tag rather than secrecy is
/// what tells a client Initial apart from a server one: only a packet the
/// client actually protected under these keys will verify.
#[must_use]
pub fn client(dcid: &[u8]) -> Self {
let (initial_secret, _) = Hkdf::<Sha256>::extract(Some(&INITIAL_SALT_V1), dcid);
fn client(dcid: &[u8], version: QuicVersion) -> Self {
let (initial_secret, _) = Hkdf::<Sha256>::extract(Some(version.salt), dcid);
let mut client_secret = [0u8; 32];
hkdf_expand_label(&initial_secret, b"client in", &mut client_secret);
@ -200,9 +258,9 @@ impl InitialKeys {
iv: [0u8; IV_LEN],
hp: [0u8; HP_LEN],
};
hkdf_expand_label(&client_secret, b"quic key", &mut keys.key);
hkdf_expand_label(&client_secret, b"quic iv", &mut keys.iv);
hkdf_expand_label(&client_secret, b"quic hp", &mut keys.hp);
hkdf_expand_label(&client_secret, version.key_label, &mut keys.key);
hkdf_expand_label(&client_secret, version.iv_label, &mut keys.iv);
hkdf_expand_label(&client_secret, version.hp_label, &mut keys.hp);
keys
}
}
@ -218,6 +276,9 @@ pub struct InitialPacket<'pkt> {
datagram: &'pkt [u8],
/// The Destination Connection ID, which seeds key derivation.
pub dcid: &'pkt [u8],
/// The QUIC version this packet declared, selecting the salt and labels its
/// client keys derive under.
version: QuicVersion,
/// Absolute offset of this packet's first byte within the datagram.
start: usize,
/// Absolute offset of the packet number field within the datagram.
@ -250,16 +311,16 @@ impl<'pkt> InitialPacket<'pkt> {
return Err(ParseError::NotQuicInitial);
}
let version = u32::from_be_bytes(
let version_code = u32::from_be_bytes(
buf.get(1..5)
.ok_or(ParseError::NotQuicInitial)?
.try_into()
.expect("a four byte slice is four bytes"),
);
if version != VERSION_1 {
return Err(ParseError::UnsupportedQuicVersion(version));
}
if first & PACKET_TYPE_MASK != PACKET_TYPE_INITIAL {
let Some(version) = QuicVersion::from_code(version_code) else {
return Err(ParseError::UnsupportedQuicVersion(version_code));
};
if first & PACKET_TYPE_MASK != version.initial_type {
return Err(ParseError::NotQuicInitial);
}
@ -291,6 +352,7 @@ impl<'pkt> InitialPacket<'pkt> {
Ok(Self {
datagram,
dcid,
version,
start,
pn_offset,
end,
@ -298,6 +360,13 @@ impl<'pkt> InitialPacket<'pkt> {
})
}
/// Derives the client Initial keys for this packet, from its own Destination
/// Connection ID under the salt and labels of the version it declared.
#[must_use]
pub fn client_keys(&self) -> InitialKeys {
InitialKeys::client(self.dcid, self.version)
}
/// Removes header protection and decrypts the payload, returning the
/// cleartext QUIC frames.
///
@ -622,8 +691,8 @@ pub enum ClientHelloState<'a> {
#[cfg(test)]
mod tests {
use super::{
ClientHelloState, CryptoAssembler, InitialKeys, InitialPacket, decode_packet_number,
hkdf_expand_label, read_varint, walk_crypto_frames,
ClientHelloState, CryptoAssembler, InitialKeys, InitialPacket, QuicVersion,
decode_packet_number, hkdf_expand_label, read_varint, walk_crypto_frames,
};
/// RFC 9000 Appendix A.1 sample variable length integer decodings.
@ -654,12 +723,23 @@ mod tests {
#[test]
fn client_initial_keys_match_rfc9001_appendix_a1() {
let dcid = hex("8394c8f03e515708");
let keys = InitialKeys::client(&dcid);
let keys = InitialKeys::client(&dcid, QuicVersion::V1);
assert_eq!(keys.key.to_vec(), hex("1f369613dd76d5467730efcbe3b1a22d"));
assert_eq!(keys.iv.to_vec(), hex("fa044b2f42a3fd3b46fb255c"));
assert_eq!(keys.hp.to_vec(), hex("9f50449e04a0e810283a1e9933adedd2"));
}
/// RFC 9369 Appendix A derives the QUIC v2 client Initial keys from the same
/// sample Destination Connection ID, under the v2 salt and labels.
#[test]
fn client_initial_keys_match_rfc9369_appendix_a() {
let dcid = hex("8394c8f03e515708");
let keys = InitialKeys::client(&dcid, QuicVersion::V2);
assert_eq!(keys.key.to_vec(), hex("8b1a0bc121284290a29e0971b5cd045d"));
assert_eq!(keys.iv.to_vec(), hex("91f73e2351d8fa91660e909f"));
assert_eq!(keys.hp.to_vec(), hex("45b95e15235d6f45a6b19cbcb0294ba9"));
}
/// RFC 9001 Appendix A.1 also pins the intermediate expand label output.
#[test]
fn expand_label_reproduces_rfc9001_client_secret() {
@ -683,7 +763,7 @@ mod tests {
let packet = InitialPacket::parse(&datagram, 0).unwrap();
assert_eq!(packet.dcid, &hex("8394c8f03e515708")[..]);
let keys = InitialKeys::client(packet.dcid);
let keys = packet.client_keys();
let opened = packet.open(&keys, None).unwrap();
assert_eq!(opened.packet_number, 2);
@ -732,6 +812,30 @@ mod tests {
assert!(InitialPacket::parse(udp, 0).is_err());
}
/// A QUIC v2 long header Initial is recognized by its version code and its
/// remapped Initial type bits, `0b01`, where the v1 Initial code `0b00` is
/// not an Initial in v2. This proves the version specific parse logic; the v2
/// key schedule is pinned by the keys KAT above, and the AEAD decryption path
/// is the one the RFC 9001 Appendix A.2 test already exercises unchanged.
#[test]
fn parses_a_quic_v2_initial_header() {
let mut packet = vec![0xd3];
packet.extend_from_slice(&super::VERSION_2.to_be_bytes());
packet.push(8);
packet.extend_from_slice(&hex("8394c8f03e515708"));
packet.push(0);
packet.push(0);
packet.push(0x10);
packet.extend_from_slice(&[0u8; 16]);
let parsed = InitialPacket::parse(&packet, 0).unwrap();
assert_eq!(parsed.dcid, &hex("8394c8f03e515708")[..]);
let mut v1_type = packet.clone();
v1_type[0] = 0xc3;
assert!(InitialPacket::parse(&v1_type, 0).is_err());
}
fn hex(s: &str) -> Vec<u8> {
::hex::decode(s).unwrap()
}

View File

@ -0,0 +1,21 @@
// ©AngelaMos | 2026
// ja4x.rs
//! Fuzz harness proving the X.509 certificate walk behind JA4X never panics.
//!
//! JA4X reads certificate bytes a passive observer takes straight off a cleartext
//! TLS 1.2 handshake, so a malformed or hostile certificate must be an ordinary
//! error rather than a crash, the same contract the TLS parser and the stream
//! reassembler hold and fuzz. This pairs with the constructed truncated
//! certificate unit test: arbitrary bytes in, never a panic out.
use proptest::prelude::*;
proptest! {
#[test]
fn ja4x_never_panics_on_arbitrary_bytes(
bytes in proptest::collection::vec(any::<u8>(), 0..4096),
) {
let _ = tlsfp_core::ja4x(&bytes);
}
}

View File

@ -11,7 +11,9 @@
//!
//! The rules, in the order they are evaluated:
//!
//! - known bad: the fingerprint matches malicious or suspicious intelligence.
//! - known bad: the fingerprint matches malicious or suspicious intelligence,
//! raised the first time an address presents it so a repeating beacon from one
//! host is one alert rather than one per flow.
//! - User-Agent mismatch: a request calls itself a browser while the same
//! client's TLS fingerprint is a script or a tool. This is the headline, the
//! lie that a fingerprint catches and a User-Agent string cannot tell.
@ -256,9 +258,11 @@ pub(crate) fn run(
let observation_id = insert_observation(conn, ts, &ip_text, kind, &value, &summary, &context)?;
if !ip_fp_known {
if let Some(alert) = known_bad(conn, ts, &ip_text, &reports, kind, &value)? {
alerts.push(alert);
}
}
if !fp_known {
alerts.push(first_seen(ts, &ip_text, kind, &value, &summary));

View File

@ -12,6 +12,25 @@ use std::fmt;
use serde::Serialize;
/// The threat score at or above which a fingerprint is judged malicious.
const MALICIOUS_THRESHOLD: f64 = 0.8;
/// The threat score at or below which a fingerprint is judged benign; between
/// the two thresholds it is suspicious.
const BENIGN_THRESHOLD: f64 = 0.2;
/// How much a suspicious, dual use hit counts toward the threat score, half a
/// malicious vote.
const SUSPICIOUS_VOTE: f64 = 0.5;
/// The share of confidence the best match earns before corroboration, so a lone
/// exact hit is already fairly trusted and agreeing hits raise it the rest of
/// the way without ever exceeding the best match weight.
const CONFIDENCE_FLOOR: f64 = 0.6;
/// The remaining share of confidence that corroborating, aligned hits supply.
const CONFIDENCE_CORROBORATION: f64 = 0.4;
/// Which fingerprint algorithm a stored value belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
@ -284,11 +303,11 @@ impl MatchReport {
}
}
let total = malicious + suspicious + benign;
let threat_score = (malicious + 0.5 * suspicious) / total;
let threat_score = (malicious + SUSPICIOUS_VOTE * suspicious) / total;
let verdict = if threat_score >= 0.8 {
let verdict = if threat_score >= MALICIOUS_THRESHOLD {
Verdict::Malicious
} else if threat_score <= 0.2 {
} else if threat_score <= BENIGN_THRESHOLD {
Verdict::Benign
} else {
Verdict::Suspicious
@ -299,7 +318,7 @@ impl MatchReport {
.filter(|hit| verdict_aligns(verdict, hit.category.severity()))
.count();
let corroboration = 1.0 - 1.0 / (1.0 + count_to_f64(aligned));
let confidence = best * (0.6 + 0.4 * corroboration);
let confidence = best * (CONFIDENCE_FLOOR + CONFIDENCE_CORROBORATION * corroboration);
Self {
kind,

View File

@ -28,6 +28,16 @@
use crate::model::Category;
/// Whether `needle` appears in `haystack` as a whole word, bounded by characters
/// that are not ASCII alphanumeric. Short, ambiguous tokens like "tor" and
/// "java" are matched this way so a label such as "Factory" is not read as Tor
/// and a User-Agent mentioning "javascript" is not read as Java.
fn contains_word(haystack: &str, needle: &str) -> bool {
haystack
.split(|c: char| !c.is_ascii_alphanumeric())
.any(|word| word == needle)
}
/// A coarse operating-system class, the resolution the SYN signature can carry
/// without guessing. Finer naming from one packet is not reliable, so the
/// mismatch rule works at this granularity on purpose.
@ -216,7 +226,7 @@ pub fn ua_family(user_agent: &str) -> Option<Family> {
if ua.contains("okhttp") {
return Some(Family::OkHttp);
}
if ua.contains("java") {
if contains_word(&ua, "java") {
return Some(Family::Java);
}
None
@ -263,10 +273,10 @@ pub fn label_family(label: &str) -> Option<Family> {
if label.contains("okhttp") {
return Some(Family::OkHttp);
}
if label.contains("java") {
if contains_word(&label, "java") {
return Some(Family::Java);
}
if label.contains("tor") {
if contains_word(&label, "tor") {
return Some(Family::Tor);
}
None
@ -414,4 +424,13 @@ mod tests {
assert!(!label_is_non_browser("Google Chrome", Category::Benign));
assert!(!label_is_non_browser("unrecognised", Category::Benign));
}
#[test]
fn ambiguous_substrings_do_not_misclassify() {
assert_eq!(label_family("Factory C2"), None);
assert_eq!(label_family("Tor Browser"), Some(Family::Tor));
assert_eq!(label_family("JavaScript Runtime"), None);
assert_eq!(label_family("Java"), Some(Family::Java));
assert_eq!(ua_family("Mozilla/5.0 javascript engine"), None);
}
}

View File

@ -105,6 +105,33 @@ fn known_bad_fires_on_malicious_fingerprint() {
assert_eq!(known_bad.score, Some(1.0));
}
#[test]
fn known_bad_does_not_refire_for_the_same_address_and_fingerprint() {
let mut store = store();
let config = cfg();
let first = store
.detect_with(
&client_hello("10.0.0.1", 1_000_000_000, MALWARE_JA4),
&config,
)
.unwrap();
let second = store
.detect_with(
&client_hello("10.0.0.1", 2_000_000_000, MALWARE_JA4),
&config,
)
.unwrap();
let other = store
.detect_with(
&client_hello("10.0.0.99", 3_000_000_000, MALWARE_JA4),
&config,
)
.unwrap();
assert!(fires(&first, Rule::KnownBad));
assert!(!fires(&second, Rule::KnownBad));
assert!(fires(&other, Rule::KnownBad));
}
#[test]
fn ua_mismatch_browser_claim_over_tool_handshake() {
let mut store = store();

View File

@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
.pnpm-store
dist
dist-ssr
*.local

View File

@ -1,12 +1,21 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
"includes": [
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"**/*.json",
"!**/node_modules",
"!**/.pnpm-store",
"!**/dist"
]
},
"formatter": {
"enabled": true,

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

View File

@ -40,7 +40,7 @@
border-left-color: var(--accent);
background: linear-gradient(
90deg,
rgba(255, 77, 18, 0.07),
rgb(255, 77, 18, 7%),
transparent 50%
);
}

View File

@ -40,7 +40,7 @@
position: relative;
z-index: 1;
padding: $space-3;
background: rgba(8, 9, 10, 0.82);
background: rgb(8, 9, 10, 82%);
border: 1px solid $line;
border-left: 2px solid var(--accent);
box-shadow: 0 0 20px -10px var(--accent);
@ -61,7 +61,7 @@
line-height: 1.1;
letter-spacing: -0.01em;
color: $chalk;
text-shadow: 0 0 11px rgba(255, 77, 18, 0.18);
text-shadow: 0 0 11px rgb(255, 77, 18, 18%);
}
.subject {

View File

@ -23,7 +23,7 @@
padding: 0 6px;
color: var(--accent);
border: 1px solid var(--accent);
background: rgba(0, 0, 0, 0.35);
background: rgb(0, 0, 0, 35%);
}
.cat {

View File

@ -55,8 +55,8 @@
90deg,
transparent 0,
transparent 7px,
rgba(255, 255, 255, 0.03) 7px,
rgba(255, 255, 255, 0.03) 8px
rgb(255, 255, 255, 3%) 7px,
rgb(255, 255, 255, 3%) 8px
),
$ink-700;
border: 1px solid $line-faint;
@ -68,7 +68,7 @@
background: var(--accent);
box-shadow:
0 0 7px -4px var(--accent),
inset -1px 0 0 rgba(255, 255, 255, 0.3);
inset -1px 0 0 rgb(255, 255, 255, 30%);
transition: width $duration-slow $ease-out;
}

View File

@ -48,8 +48,7 @@
.scroll {
position: absolute;
inset: 0;
overflow-y: auto;
overflow-x: hidden;
overflow: hidden auto;
}
.row {
@ -159,14 +158,14 @@
border-left: 2px solid $fire;
background: linear-gradient(
90deg,
rgba(255, 77, 18, 0.14),
rgb(255, 77, 18, 14%),
transparent 55%
);
&:hover {
background: linear-gradient(
90deg,
rgba(255, 77, 18, 0.2),
rgb(255, 77, 18, 20%),
transparent 55%
);
}
@ -176,7 +175,7 @@
border-left: 2px solid var(--accent);
background: linear-gradient(
90deg,
rgba(255, 174, 58, 0.08),
rgb(255, 174, 58, 8%),
transparent 60%
);
}
@ -222,7 +221,7 @@
from {
opacity: 0;
transform: translateY(-7px);
background: rgba(47, 123, 255, 0.09);
background: rgb(47, 123, 255, 9%);
}
to {

View File

@ -21,7 +21,7 @@
align-items: center;
gap: $space-4;
padding: $space-4 $space-7;
background: rgba(5, 5, 6, 0.86);
background: rgb(5, 5, 6, 86%);
backdrop-filter: blur(10px);
border-bottom: 1px solid $line;
}

View File

@ -31,7 +31,7 @@
transform: translateY(-50%);
mix-blend-mode: screen;
opacity: 0.5;
filter: drop-shadow(0 0 40px rgba(47, 123, 255, 0.3));
filter: drop-shadow(0 0 40px rgb(47, 123, 255, 30%));
pointer-events: none;
z-index: 0;

View File

@ -26,7 +26,7 @@
transform: translateY(-50%);
mix-blend-mode: screen;
opacity: 0.92;
filter: drop-shadow(0 0 60px rgba(47, 123, 255, 0.28));
filter: drop-shadow(0 0 60px rgb(47, 123, 255, 28%));
pointer-events: none;
z-index: 0;
@ -103,7 +103,7 @@
letter-spacing: 0.34em;
text-transform: uppercase;
color: $fire;
text-shadow: 0 0 10px rgba(255, 77, 18, 0.28);
text-shadow: 0 0 10px rgb(255, 77, 18, 28%);
margin-bottom: $space-5;
}
@ -114,7 +114,7 @@
letter-spacing: 0.01em;
color: $chalk;
margin: 0 0 $space-5;
text-shadow: 0 0 48px rgba(47, 123, 255, 0.16);
text-shadow: 0 0 48px rgb(47, 123, 255, 16%);
}
// The mono tag reads as a tight stamp under the masthead, so it is kept short

View File

@ -83,22 +83,20 @@
.grid {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 1.62fr) minmax(0, 1fr);
grid-template-rows: minmax(300px, auto) minmax(260px, auto) auto;
grid-template-areas:
'live anomaly'
'live dist'
'alerts alerts';
grid-template:
'live anomaly' minmax(300px, auto)
'live dist' minmax(260px, auto)
'alerts alerts' auto
/ minmax(0, 1.62fr) minmax(0, 1fr);
gap: $space-4;
@include breakpoint-down('lg') {
grid-template-columns: 1fr;
grid-template-rows: none;
grid-template-areas:
grid-template:
'live'
'anomaly'
'dist'
'alerts';
'alerts'
/ 1fr;
}
}

View File

@ -49,7 +49,7 @@
}
@font-face {
font-family: 'Anton';
font-family: Anton;
font-style: normal;
font-weight: 400;
font-display: swap;
@ -66,24 +66,25 @@ body {
font-family: $font-grotesk;
font-size: $font-size-sm;
letter-spacing: 0.01em;
// A breath of grain over the whole field so the black reads as a printed
// surface, not a switched-off screen.
background-image:
radial-gradient(
ellipse 120% 80% at 50% -10%,
rgba(47, 123, 255, 0.05),
rgb(47, 123, 255, 5%),
transparent 60%
),
radial-gradient(
ellipse 90% 60% at 100% 110%,
rgba(255, 77, 18, 0.04),
rgb(255, 77, 18, 4%),
transparent 55%
);
background-attachment: fixed;
}
::selection {
background: rgba(255, 77, 18, 0.85);
background: rgb(255, 77, 18, 85%);
color: $void;
}
@ -135,15 +136,19 @@ body {
[data-sev='info'] {
--accent: #{$sev-info};
}
[data-sev='low'] {
--accent: #{$sev-low};
}
[data-sev='medium'] {
--accent: #{$sev-medium};
}
[data-sev='high'] {
--accent: #{$sev-high};
}
[data-sev='critical'] {
--accent: #{$sev-critical};
}
@ -151,12 +156,15 @@ body {
[data-verdict='malicious'] {
--accent: #{$verdict-malicious};
}
[data-verdict='suspicious'] {
--accent: #{$verdict-suspicious};
}
[data-verdict='benign'] {
--accent: #{$verdict-benign};
}
[data-verdict='unknown'] {
--accent: #{$verdict-unknown};
}
@ -164,18 +172,23 @@ body {
[data-cat='malware'] {
--accent: #{$cat-malware};
}
[data-cat='c2'] {
--accent: #{$cat-c2};
}
[data-cat='tool'] {
--accent: #{$cat-tool};
}
[data-cat='benign'] {
--accent: #{$cat-benign};
}
[data-cat='os'] {
--accent: #{$cat-os};
}
[data-cat='unknown'] {
--accent: #{$cat-unknown};
}
@ -183,18 +196,23 @@ body {
[data-rule='known_bad'] {
--accent: #{$blood};
}
[data-rule='ua_mismatch'] {
--accent: #{$fire};
}
[data-rule='os_mismatch'] {
--accent: #{$magma};
}
[data-rule='fp_rotation'] {
--accent: #{$volt};
}
[data-rule='monoculture'] {
--accent: #{$orchid};
}
[data-rule='first_seen'] {
--accent: #{$thermal};
}

View File

@ -9,7 +9,7 @@
// the instrument itself. Neutral type sits between.
// The void and the panels lifted just off it.
$void: #000000;
$void: #000;
$ink-1000: #050506;
$ink-900: #0a0b0d;
$ink-800: #0f1113;
@ -18,7 +18,7 @@ $ink-600: #1c2024;
$ink-500: #262b30;
// Bone white for the artifacts, chalk for the few things that must shout.
$chalk: #ffffff;
$chalk: #fff;
$bone: #f2f0e9;
$paper: #d8d6cd;
@ -28,10 +28,10 @@ $dust: #5a5f65;
$soot: #34383d;
// Hairlines. The grid the specimens are pinned to.
$line-faint: rgba(255, 255, 255, 0.06);
$line: rgba(255, 255, 255, 0.11);
$line-strong: rgba(255, 255, 255, 0.2);
$line-hot: rgba(255, 77, 18, 0.55);
$line-faint: rgb(255, 255, 255, 6%);
$line: rgb(255, 255, 255, 11%);
$line-strong: rgb(255, 255, 255, 20%);
$line-hot: rgb(255, 77, 18, 55%);
// The hot channels: what the x-ray exposes, what bleeds through the black.
$fire: #ff4d12; // the butterfly's fire, the snake; primary anomaly