the hallway is four steps longer at night. i've confirmed this.
This commit is contained in:
parent
4bb95fdb97
commit
56c2b0233c
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,8 +258,10 @@ pub(crate) fn run(
|
|||
|
||||
let observation_id = insert_observation(conn, ts, &ip_text, kind, &value, &summary, &context)?;
|
||||
|
||||
if let Some(alert) = known_bad(conn, ts, &ip_text, &reports, kind, &value)? {
|
||||
alerts.push(alert);
|
||||
if !ip_fp_known {
|
||||
if let Some(alert) = known_bad(conn, ts, &ip_text, &reports, kind, &value)? {
|
||||
alerts.push(alert);
|
||||
}
|
||||
}
|
||||
|
||||
if !fp_known {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pnpm-debug.log*
|
|||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
|
|
|||
|
|
@ -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.
|
Before Width: | Height: | Size: 144 KiB |
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 |
|
|
@ -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%
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue