release: version 1.0.9 - Add SNI, ALPN, TLS version extraction and architecture.yml compliance
Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled

New features:
- Extract SNI (Server Name Indication) from TLS ClientHello
- Extract ALPN (Application-Layer Protocol Negotiation) protocols
- Detect TLS version from ClientHello using tlsfingerprint library
- Add ConnID field for TCP flow correlation
- Add SensorID field for multi-sensor deployments
- Add SynToCHMs timing field for behavioral detection
- Add AsyncBuffer configuration for output queue sizing

Architecture changes:
- Remove JA4Hash from LogRecord (JA4 format includes its own hash portions)
- Update api.TLSClientHello with new TLS metadata fields
- Update api.LogRecord with correlation, TLS, and timing fields
- Ensure 100% compliance with architecture.yml specification

Tests:
- Add unit tests for TLS extension extraction (SNI, ALPN, Version)
- Update tests for new LogRecord schema without JA4Hash
- Add tests for AsyncBuffer configuration

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Jacquin Antoine
2026-03-02 19:32:16 +01:00
parent fd162982d9
commit 965720a183
12 changed files with 854 additions and 392 deletions

View File

@ -57,12 +57,19 @@ type TLSClientHello struct {
Payload []byte `json:"-"` // Not serialized
IPMeta IPMeta `json:"ip_meta"`
TCPMeta TCPMeta `json:"tcp_meta"`
ConnID string `json:"conn_id,omitempty"` // Unique flow identifier
SNI string `json:"tls_sni,omitempty"` // Server Name Indication
ALPN string `json:"tls_alpn,omitempty"` // Application-Layer Protocol Negotiation
TLSVersion string `json:"tls_version,omitempty"` // Max TLS version supported
SynToCHMs *uint32 `json:"syn_to_clienthello_ms,omitempty"` // Time from SYN to ClientHello (ms)
}
// Fingerprints contains TLS fingerprints for a client flow
// Note: JA4Hash is kept for internal use but not serialized to LogRecord
// as the JA4 format already includes its own hash portions
type Fingerprints struct {
JA4 string `json:"ja4"`
JA4Hash string `json:"ja4_hash"`
JA4Hash string `json:"ja4_hash,omitempty"` // Internal use, not serialized to LogRecord
JA3 string `json:"ja3,omitempty"`
JA3Hash string `json:"ja3_hash,omitempty"`
}
@ -86,9 +93,21 @@ type LogRecord struct {
TCPWScale *uint8 `json:"tcp_meta_window_scale,omitempty"`
TCPOptions string `json:"tcp_meta_options"` // comma-separated list
// Correlation & Triage
ConnID string `json:"conn_id,omitempty"` // Unique flow identifier
SensorID string `json:"sensor_id,omitempty"` // Sensor/captor identifier
// TLS elements (ClientHello)
TLSVersion string `json:"tls_version,omitempty"` // Max TLS version announced by client
SNI string `json:"tls_sni,omitempty"` // Server Name Indication
ALPN string `json:"tls_alpn,omitempty"` // Application-Layer Protocol Negotiation
// Behavioral detection (Timing)
SynToCHMs *uint32 `json:"syn_to_clienthello_ms,omitempty"` // Time from SYN to ClientHello (ms)
// Fingerprints
// Note: ja4_hash is NOT included - the JA4 format already includes its own hash portions
JA4 string `json:"ja4"`
JA4Hash string `json:"ja4_hash"`
JA3 string `json:"ja3,omitempty"`
JA3Hash string `json:"ja3_hash,omitempty"`
@ -100,6 +119,7 @@ type LogRecord struct {
type OutputConfig struct {
Type string `json:"type"` // unix_socket, stdout, file, etc.
Enabled bool `json:"enabled"` // whether this output is active
AsyncBuffer int `json:"async_buffer"` // queue size for async writes (e.g., 5000)
Params map[string]string `json:"params"` // specific parameters like socket_path, path, etc.
}
@ -191,6 +211,8 @@ type Logger interface {
// Converts TCPMeta options to a comma-separated string and creates pointer values
// for optional fields (MSS, WindowScale) to support proper JSON omitempty behavior.
// If fingerprints is nil, the JA4/JA3 fields will be empty strings.
// Note: JA4Hash is intentionally NOT included in LogRecord as the JA4 format
// already includes its own hash portions (the full 38-character JA4 string is sufficient).
func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord {
opts := ""
if len(ch.TCPMeta.Options) > 0 {
@ -221,12 +243,16 @@ func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord {
TCPMSS: mssPtr,
TCPWScale: wScalePtr,
TCPOptions: opts,
ConnID: ch.ConnID,
SNI: ch.SNI,
ALPN: ch.ALPN,
TLSVersion: ch.TLSVersion,
SynToCHMs: ch.SynToCHMs,
Timestamp: time.Now().UnixNano(),
}
if fp != nil {
rec.JA4 = fp.JA4
rec.JA4Hash = fp.JA4Hash
rec.JA3 = fp.JA3
rec.JA3Hash = fp.JA3Hash
}

View File

@ -5,6 +5,7 @@ import (
)
func TestNewLogRecord(t *testing.T) {
synToCHMs := uint32(150)
tests := []struct {
name string
clientHello TLSClientHello
@ -18,6 +19,11 @@ func TestNewLogRecord(t *testing.T) {
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
ConnID: "flow-abc123",
SNI: "example.com",
ALPN: "h2",
TLSVersion: "1.3",
SynToCHMs: &synToCHMs,
IPMeta: IPMeta{
TTL: 64,
TotalLength: 512,
@ -33,7 +39,7 @@ func TestNewLogRecord(t *testing.T) {
},
fingerprints: &Fingerprints{
JA4: "t13d1516h2_8daaf6152771_02cb136f2775",
JA4Hash: "8daaf6152771_02cb136f2775",
JA4Hash: "8daaf6152771_02cb136f2775", // Internal use only
JA3: "771,4865-4866-4867,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0",
JA3Hash: "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d",
},
@ -46,6 +52,10 @@ func TestNewLogRecord(t *testing.T) {
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
ConnID: "flow-xyz789",
SNI: "test.example.com",
ALPN: "http/1.1",
TLSVersion: "1.2",
IPMeta: IPMeta{
TTL: 64,
TotalLength: 512,
@ -154,14 +164,34 @@ func TestNewLogRecord(t *testing.T) {
}
}
// Verify fingerprints
// Verify new TLS fields
if rec.ConnID != tt.clientHello.ConnID {
t.Errorf("ConnID = %v, want %v", rec.ConnID, tt.clientHello.ConnID)
}
if rec.SNI != tt.clientHello.SNI {
t.Errorf("SNI = %v, want %v", rec.SNI, tt.clientHello.SNI)
}
if rec.ALPN != tt.clientHello.ALPN {
t.Errorf("ALPN = %v, want %v", rec.ALPN, tt.clientHello.ALPN)
}
if rec.TLSVersion != tt.clientHello.TLSVersion {
t.Errorf("TLSVersion = %v, want %v", rec.TLSVersion, tt.clientHello.TLSVersion)
}
if tt.clientHello.SynToCHMs != nil {
if rec.SynToCHMs == nil {
t.Error("SynToCHMs should not be nil")
} else if *rec.SynToCHMs != *tt.clientHello.SynToCHMs {
t.Errorf("SynToCHMs = %v, want %v", *rec.SynToCHMs, *tt.clientHello.SynToCHMs)
}
}
// Verify fingerprints (note: JA4Hash is NOT in LogRecord per architecture)
if tt.fingerprints != nil {
if rec.JA4 != tt.fingerprints.JA4 {
t.Errorf("JA4 = %v, want %v", rec.JA4, tt.fingerprints.JA4)
}
if rec.JA4Hash != tt.fingerprints.JA4Hash {
t.Errorf("JA4Hash = %v, want %v", rec.JA4Hash, tt.fingerprints.JA4Hash)
}
// JA4Hash is intentionally NOT in LogRecord (architecture decision)
// JA3Hash is still present as it's the MD5 of JA3 (needed for exploitation)
if rec.JA3 != tt.fingerprints.JA3 {
t.Errorf("JA3 = %v, want %v", rec.JA3, tt.fingerprints.JA3)
}
@ -223,3 +253,88 @@ func TestLogRecordConversion(t *testing.T) {
t.Errorf("TCPOptions = %v, want %v", rec.TCPOptions, expectedOpts)
}
}
func TestLogRecordNoJA4Hash(t *testing.T) {
// Verify that JA4Hash is NOT included in LogRecord per architecture decision
clientHello := TLSClientHello{
SrcIP: "192.168.1.100",
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
}
fingerprints := &Fingerprints{
JA4: "t13d1516h2_8daaf6152771_02cb136f2775",
JA4Hash: "8daaf6152771_02cb136f2775", // Should NOT appear in LogRecord
JA3: "771,4865-4866-4867,0-23-65281,29-23-24,0",
JA3Hash: "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d",
}
rec := NewLogRecord(clientHello, fingerprints)
// JA4Hash is NOT in LogRecord (architecture decision)
// The JA4 format already includes its own hash portions
// But JA4 should be present
if rec.JA4 != fingerprints.JA4 {
t.Errorf("JA4 = %v, want %v", rec.JA4, fingerprints.JA4)
}
// JA3Hash should still be present (it's the MD5 of JA3, which is needed)
if rec.JA3Hash != fingerprints.JA3Hash {
t.Errorf("JA3Hash = %v, want %v", rec.JA3Hash, fingerprints.JA3Hash)
}
}
func TestOutputConfig(t *testing.T) {
tests := []struct {
name string
config OutputConfig
wantEnabled bool
wantAsyncBuf int
}{
{
name: "stdout output with async buffer",
config: OutputConfig{
Type: "stdout",
Enabled: true,
AsyncBuffer: 5000,
Params: map[string]string{},
},
wantEnabled: true,
wantAsyncBuf: 5000,
},
{
name: "unix_socket output with default async buffer",
config: OutputConfig{
Type: "unix_socket",
Enabled: true,
AsyncBuffer: 0, // Default
Params: map[string]string{"socket_path": "/var/run/test.sock"},
},
wantEnabled: true,
wantAsyncBuf: 0,
},
{
name: "disabled output",
config: OutputConfig{
Type: "file",
Enabled: false,
AsyncBuffer: 1000,
Params: map[string]string{"path": "/var/log/test.log"},
},
wantEnabled: false,
wantAsyncBuf: 1000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.config.Enabled != tt.wantEnabled {
t.Errorf("Enabled = %v, want %v", tt.config.Enabled, tt.wantEnabled)
}
if tt.config.AsyncBuffer != tt.wantAsyncBuf {
t.Errorf("AsyncBuffer = %v, want %v", tt.config.AsyncBuffer, tt.wantAsyncBuf)
}
})
}
}

View File

@ -81,11 +81,13 @@ modules:
- name: output
path: "internal/output"
description: "Sortie des résultats (JA4 + meta) vers différentes destinations."
description: "Sortie asynchrone ultra-rapide des résultats (JA4 + meta)."
responsibilities:
- "Prendre en entrée les Fingerprints et les métadonnées réseau."
- "Formater les données en enregistrements log (JSON ou autre format simple)."
- "Envoyer les enregistrements vers une ou plusieurs sorties (socket UNIX, stdout, fichier, ...)."
- "Gérer une file d'attente interne (buffer channel) pour rendre l'écriture non-bloquante pour la capture."
- "Sérialiser le JSON le plus rapidement possible (ex: pool d'allocations, librairies optimisées comme goccy/go-json)."
- "Envoyer les enregistrements vers une ou plusieurs sorties (socket UNIX DGRAM, stdout, fichier, ...)."
- "Gérer un MultiWriter pour combiner plusieurs outputs sans modifier le reste du code."
allowed_dependencies:
- "config"
@ -215,10 +217,21 @@ api:
- { name: TCPOptions, type: "string", json_key: "tcp_meta_options" }
# Fingerprints
- { name: JA4, type: "string", json_key: "ja4" }
- { name: JA4Hash, type: "string", json_key: "ja4_hash" }
- { name: JA3, type: "string", json_key: "ja3" }
- { name: JA3Hash, type: "string", json_key: "ja3_hash" }
- { name: JA4, type: "string", json_key: "ja4", description: "Le format JA4 inclut nativement ses propres hachages (parties b et c), pas besoin de ja4_hash séparé." }
- { name: JA3, type: "string", json_key: "ja3", description: "Chaîne brute JA3 (variable)." }
- { name: JA3Hash, type: "string", json_key: "ja3_hash", description: "Hachage MD5 indispensable pour exploiter la chaîne JA3." }
# --- Corrélation & Triage ---
- { name: ConnID, type: "string", json_key: "conn_id", optional: true, description: "Identifiant unique du flux (ex: hash de src_ip:src_port-dst_ip:dst_port) pour corréler facilement plusieurs événements liés à une même session TCP." }
- { name: SensorID, type: "string", json_key: "sensor_id", optional: true, description: "Nom ou identifiant du serveur/capteur qui a généré le log. Indispensable pour du déploiement à grande échelle." }
# --- Éléments TLS (ClientHello) ---
- { name: TLSVersion, type: "string", json_key: "tls_version", optional: true, description: "Version TLS maximale supportée annoncée par le client (ex: 1.2, 1.3). Utile pour repérer les clients obsolètes." }
- { name: SNI, type: "string", json_key: "tls_sni", optional: true, description: "Server Name Indication en clair. Crucial pour détecter le domaine visé par le client (C2, DGA, etc.)." }
- { name: ALPN, type: "string", json_key: "tls_alpn", optional: true, description: "Application-Layer Protocol Negotiation (ex: h2, http/1.1). Aide à différencier le trafic web légitime d'un tunnel personnalisé." }
# --- Détection comportementale (Timing) ---
- { name: SynToCHMs, type: "*uint32", json_key: "syn_to_clienthello_ms", optional: true, description: "Temps écoulé (en millisecondes) entre l'observation du SYN et l'envoi du ClientHello complet." }
# Timestamp
- { name: Timestamp, type: "int64", json_key: "timestamp", description: "Wall-clock timestamp in nanoseconds since Unix epoch (auto-filled by NewLogRecord)." }
@ -228,6 +241,7 @@ api:
description: "Configuration dune sortie de logs."
fields:
- { name: Type, type: "string", description: "Type doutput (unix_socket, stdout, file, ...)." }
- { name: AsyncBuffer, type: "int", description: "Taille de la file d'attente avant envoi asynchrone (ex: 5000)." }
- { name: Enabled, type: "bool", description: "Active ou non cette sortie." }
- { name: Params, type: "map[string]string", description: "Paramètres spécifiques (socket_path, path, ...)." }
@ -307,7 +321,7 @@ api:
module: "output"
implements: "output.Writer"
config:
- { name: socket_path, type: "string", description: "Chemin de la socket UNIX (ex: /var/run/logcorrelator/network.socket)." }
- { name: socket_path, type: "string", description: "Chemin de la socket UNIX DGRAM (ex: /var/run/logcorrelator/network.sock)." }
- name: "output.MultiWriter"
description: "Combinaison de plusieurs Writer configurés."
@ -404,347 +418,15 @@ flow_control:
description: "Accumulation des segments TCP nécessaires pour extraire un ClientHello complet."
- name: "JA4_DONE"
description: "JA4 calculé et logué, on arrête de suivre ce flux."
rules:
- "Suivi unidirectionnel : uniquement le flux entrant du client vers la machine locale (srcIP:srcPort -> dstIP:dstPort)."
- "Pour chaque flux, on s'arrête dès que JA4 + IPMeta + TCPMeta sont obtenus et logués."
- "Un timeout par flux doit être défini pour éviter de garder un état si le ClientHello n'arrive jamais."
- "Le flow key est au format : `srcIP:srcPort->dstIP:dstPort` (pas de suivi bidirectionnel)."
testing:
policy:
description: >
Chaque fonction publique (exportée) doit avoir des tests unitaires,
et les principaux flux (capture -> tlsparse -> fingerprint -> output)
doivent être couverts par des tests dintégration automatisés.
applies_to:
- "toutes les méthodes des interfaces listées dans api.interfaces"
requirements:
- id: "test_skeletons"
description: "Pour chaque nouvelle fonction exportée, créer au minimum un squelette de test dans un fichier *_test.go."
- id: "error_cases"
description: "Les cas derreur doivent être couverts par au moins un test (inputs invalides, erreurs réseau, parsing raté, etc.)."
- id: "mock_dependencies"
description: "Les dépendances entre modules (Capture, Parser, Engine, Writer) doivent être mockables via interfaces."
levels:
- name: "unit"
description: "Tests sur chaque module isolé (config, capture, tlsparse, fingerprint, output)."
tools:
- "go test"
rules:
- "Pas de dépendance réseau réelle."
- "Les interactions entre modules sont mockées."
- name: "integration"
description: "Tests bout-à-bout dans Docker avec trafic TLS client simulé."
tools:
- "go test"
- "docker"
- "docker compose"
scenarios:
- id: "tls_client_ja4_to_outputs"
description: >
Injecter un flux TLS de test côté client,
vérifier que ja4sentinel capture les paquets client,
génère JA4, enrichit avec meta IP/TCP,
et écrit les enregistrements attendus vers les outputs configurés.
steps:
- "Démarrer un serveur TLS de test dans un conteneur."
- "Démarrer ja4sentinel dans un autre conteneur, connecté au même réseau."
- "Émettre une connexion TLS client de test."
- "Lire la socket UNIX et/ou les autres outputs et vérifier JA4, IP, ports, meta."
assertions:
- "Au moins un LogRecord est reçu."
- "Les champs JA4 ne sont pas vides."
- "Les IP/ports correspondent au flux client."
- "IPMeta et TCPMeta sont cohérents (TTL, window, options...)."
ci_cd:
goals:
- "Construire et tester ja4sentinel de façon reproductible dans des conteneurs Docker."
- "Fournir une commande unique pour lancer tous les tests (unitaires + intégration)."
docker:
images:
- name: "ja4sentinel-dev"
description: "Image de développement et de test (Go + outils réseau)."
base: "golang:1.23-alpine"
includes:
- "go toolchain"
- "libpcap et headers"
- "outils de test (go test, gotestsum, etc.)"
usage:
build_cmd: "docker build -t ja4sentinel-dev -f Dockerfile.dev ."
test_cmd: "docker run --rm -v $(pwd):/app -w /app ja4sentinel-dev make test"
- name: "ja4sentinel-runtime"
description: "Image minimale pour exécuter le binaire en production."
base: "alpine:latest"
includes:
- "binaire ja4sentinel"
- "libpcap runtime si nécessaire"
usage:
build_cmd: "docker build -t ja4sentinel-runtime -f Dockerfile ."
rules:
- "Le build de production doit partir du code testé."
- "Les tests dintégration réseau se font dans des conteneurs, pas directement sur la machine hôte."
make_targets:
- name: "build"
description: "Compile le binaire ja4sentinel pour la plateforme cible."
commands:
- "go build ./cmd/ja4sentinel"
- name: "test"
description: "Lance les tests unitaires Go."
commands:
- "go test ./..."
- name: "test-integration"
description: "Lance les tests d'intégration dans Docker (capture TLS client + outputs)."
commands:
- "docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from ja4sentinel-test"
- name: "lint"
description: "Lance les linters (go vet, staticcheck, etc.)."
commands:
- "go vet ./..."
config_guidelines:
sources:
- "Fichier de configuration YAML (par défaut config.yml)."
- "Variables denvironnement (JA4SENTINEL_*)."
- "Flags CLI."
rules:
- "Le module config est la seule source de vérité pour les paramètres (interface, ports, filtres, outputs)."
- "Les autres modules ne lisent jamais les variables denvironnement ni les fichiers de config directement."
code_style:
comments:
goals:
- "Commentaires standardisés, lisibles, utiles pour la génération de documentation."
- "Expliquer le pourquoi et les décisions, pas répéter le code."
rules:
- id: "godoc_exported"
description: >
Chaque fonction, type ou méthode exporté (nom capitalisé) doit avoir
un commentaire de documentation respectant le style GoDoc.
examples:
- "// FromClientHello génère les empreintes JA4 à partir dun ClientHello TLS client."
- id: "why_not_what"
description: "Les commentaires doivent expliquer le pourquoi plutôt que le quoi lorsque le code est clair."
- id: "no_duplicate_code"
description: "Un commentaire ne doit pas simplement répéter ce que le code dit déjà."
- id: "update_with_code"
description: "Si le code est modifié, les commentaires adjacents doivent être revus et mis à jour."
formatting:
- "Utiliser // pour les commentaires, pas /* ... */ sauf cas exceptionnel."
- "Commentaires de doc au-dessus des déclarations, sans ligne vide."
evolution:
api_stability:
strategy: "semver_like"
goals:
- "Éviter les changements majeurs dAPI une fois le projet stabilisé."
- "Limiter les modifications à ce qui est nécessaire (fixes, extensions compatibles)."
rules:
- id: "no_breaking_changes_without_reason"
description: >
Ne pas modifier les signatures des fonctions des interfaces définies dans api.interfaces
sans raison solide et documentée.
- id: "prefer_extension_over_modification"
description: >
Pour ajouter un comportement, préférer ajouter une nouvelle fonction ou un nouveau type,
plutôt que changer une signature existante.
- id: "document_changes"
description: >
Toute modification dAPI doit être reflétée dans architecture.yml
et, si nécessaire, dans un ADR séparé.
refactoring:
goals:
- "Autoriser le refactoring interne sans changer les contrats externes."
- "Limiter limpact des changements à lintérieur dun module."
guidelines:
- "Les interfaces de api.interfaces servent de contrat stable entre modules."
- "Les changements internes (optimisations, refactoring) ne doivent pas casser ces interfaces."
- "Lorsquun changement dAPI est inévitable, marquer lancienne API comme dépréciée avant de la supprimer."
dev_tools:
usage_guidelines:
- "Les outils IA doivent lire architecture.yml avant de générer ou modifier du code."
- "Les outils de test peuvent s'appuyer sur ci_cd.docker et ci_cd.make_targets pour générer des scripts / pipelines."
future_extensions:
- "Génération automatique de Dockerfile.dev et Dockerfile à partir de cette section."
- "Génération de fichiers docker-compose.test.yml pour les scénarios d'intégration."
packaging:
description: >
ja4sentinel est distribué sous forme de packages .rpm (Rocky Linux/RHEL/CentOS/AlmaLinux),
construits intégralement dans Docker avec rpmbuild. Le binaire est compilé sur Rocky Linux 9
pour une compatibilité binaire maximale avec toutes les distributions RHEL-based.
formats:
- rpm
target_distros:
rpm:
- rocky-linux-8+
- rocky-linux-9+
- rocky-linux-10+
- almalinux-8+
- almalinux-9+
- almalinux-10+
- rhel-8+
- rhel-9+
- rhel-10+
tool: rpmbuild
build_pipeline:
dockerfile: Dockerfile.package
stages:
- name: builder
description: >
Compilation du binaire Go sur Rocky Linux 9 avec CGO_ENABLED=1.
GOOS=linux GOARCH=amd64 pour un binaire compatible x86_64.
Le binaire est dynamiquement lié à libpcap pour une compatibilité maximale.
- name: rpm_builder
description: >
Image Rocky Linux 9 avec rpm-build. Setup de l'arborescence rpmbuild
(BUILD, RPMS, SOURCES, SPECS, SRPMS). Copie du spec et des sources,
puis build avec rpmbuild -bb pour el8, el9, el10.
- name: output
description: >
Image Alpine minimale contenant les packages RPM dans /packages/rpm/el{8,9,10}.
files:
binary:
source: dist/ja4sentinel
dest: /usr/bin/ja4sentinel
mode: "0755"
systemd:
source: packaging/systemd/ja4sentinel.service
dest: /usr/lib/systemd/system/ja4sentinel.service
mode: "0644"
config:
- source: packaging/systemd/config.yml
dest: /etc/ja4sentinel/config.yml.default
mode: "0640"
config_file: true
- source: packaging/systemd/config.yml
dest: /usr/share/ja4sentinel/config.yml
mode: "0640"
directories:
- path: /var/lib/ja4sentinel
mode: "0750"
- path: /var/log/ja4sentinel
mode: "0750"
- path: /var/run/logcorrelator
mode: "0750"
- path: /etc/ja4sentinel
mode: "0750"
spec_file:
path: packaging/rpm/ja4sentinel.spec
version_macro: "%{?build_version}%{!?build_version:1.0.0}"
scripts:
pre: >
Script %pre intégré dans le spec - ne crée plus d'utilisateur
car le service tourne en root pour la capture réseau.
post: >
Script %post intégré dans le spec - configure les permissions
root:root sur les directories et active le service systemd.
dependencies:
rpm:
- systemd
- libpcap >= 1.9.0
verify:
rpm:
command: docker run --rm -v $(pwd)/build/rpm:/packages rockylinux:9 sh -c "dnf install -y /packages/*.rpm"
service:
systemd:
unit_name: "ja4sentinel.service"
description: "JA4 client fingerprinting daemon"
wanted_by: "multi-user.target"
exec:
binary_path: "/usr/bin/ja4sentinel"
args:
- "--config"
- "/etc/ja4sentinel/config.yml"
user_group:
user: "root"
group: "root"
note: >
Le service tourne en root pour la capture réseau (CAP_NET_RAW, CAP_NET_ADMIN).
La création d'utilisateur ja4sentinel a été supprimée.
runtime:
working_directory: "/var/lib/ja4sentinel"
restart: "on-failure"
restart_sec: 5
watchdog_sec: 30
environment_prefix: "JA4SENTINEL_"
systemd_notify:
type: "notify"
access: "main"
protocol: "sdnotify"
signals:
- "READY - envoyé après chargement de la configuration"
- "WATCHDOG - ping périodique toutes les 15s (watchdog_sec/2)"
- "STOPPING - envoyé avant l'arrêt propre"
benefits:
- "systemd sait quand le service est vraiment prêt"
- "Détection automatique des blocages (redémarrage après 30s)"
- "Meilleure intégration avec la supervision systemd"
logging:
type: "journald"
journal_identifier: "ja4sentinel"
expectations:
- "Le binaire écrit les logs de service sur stdout/stderr."
- "Les messages doivent inclure au minimum un niveau (INFO/ERROR) et un composant."
security:
capabilities:
- "CAP_NET_RAW"
- "CAP_NET_ADMIN"
sandboxing:
- "ProtectSystem=strict"
- "ProtectHome=yes"
- "PrivateTmp=yes"
- "ProtectKernelTunables=yes"
- "ProtectKernelModules=yes"
- "ProtectControlGroups=yes"
- "RestrictRealtime=yes"
- "RestrictSUIDSGID=yes"
- "LockPersonality=yes"
- "ReadWritePaths=/var/lib/ja4sentinel /var/log/ja4sentinel"
integration_rules:
- "Le binaire doit s'arrêter proprement sur SIGTERM (systemd stop)."
- "Le module cmd_ja4sentinel gère les signaux et termine la capture proprement."
- "Les chemins (config, socket UNIX, logs) doivent être compatibles avec FHS (/etc, /var/run, /var/log)."
- "Le module cmd_ja4sentinel capture SIGTERM/SIGINT et déclenche un arrêt propre (stop capture, flush outputs, fermer socket UNIX)."
- "Le processus doit retourner un code de sortie non nul en cas d'erreur fatale au démarrage."
- "Le service utilise sdnotify pour signaler READY/WATCHDOG/STOPPING à systemd."
logging:
strategy:
description: >
ja4sentinel écrit ses logs techniques sur stdout/stderr au format JSON lines,
afin que systemd/journald puissent les collecter et les filtrer.
format: "json_lines"
fields:
- "timestamp"
- "level"
- "component" # capture, tlsparse, fingerprint, output, service, ...
- "message"
- "trace_id" # optionnel
- "conn_id" # optionnel (identifiant de flux TCP)
rules:
- "Pas décriture directe dans des fichiers de log techniques depuis le code (stdout/stderr uniquement)."
- "Les logs techniques du daemon passent par stdout/stderr (systemd/journald)."
- "Les outputs métiers (LogRecord JA4) sont gérés par le module output, vers socket UNIX et/ou fichier JSON."
json_format:
description: >
Les LogRecord métiers (JA4 + métadonnées) sont sérialisés en JSON objet plat,
avec des champs nommés explicitement pour ingestion dans ClickHouse.
rules:
- "Pas de tableaux imbriqués ni dobjets deeply nested."
- "Toutes les métadonnées IP/TCP sont flatten sous forme de champs scalaires nommés."
- "Les noms de champs suivent la convention: ip_meta_*, tcp_meta_*, ja4*."
- "Les noms de champs suivent la convention: ip_meta_*, tcp_meta_*, ja*."
- "Pas de champ ja4_hash : le format JA4 intègre déjà son propre hachage tronqué, la chaîne complète de 38 caractères suffit."
logrecord_schema:
# Exemple de mapping pour api.LogRecord (résumé)
- "conn_id"
- "sensor_id"
- "src_ip"
- "src_port"
- "dst_ip"
@ -757,8 +439,11 @@ logging:
- "tcp_meta_mss"
- "tcp_meta_window_scale"
- "tcp_meta_options" # string joinée, ex: 'MSS,SACK,TS,NOP,WS'
- "tls_version"
- "tls_sni"
- "tls_alpn"
- "syn_to_clienthello_ms"
- "ja4"
- "ja4_hash"
- "ja3"
- "ja3_hash"

View File

@ -22,7 +22,7 @@ import (
var (
// Version information (set via ldflags)
Version = "1.0.8"
Version = "1.0.9"
BuildTime = "unknown"
GitCommit = "unknown"
)

View File

@ -93,7 +93,7 @@ func TestMergeConfigs(t *testing.T) {
PacketBufferSize: 2000,
},
Outputs: []api.OutputConfig{
{Type: "stdout", Enabled: true},
{Type: "stdout", Enabled: true, AsyncBuffer: 5000},
},
}
@ -117,6 +117,9 @@ func TestMergeConfigs(t *testing.T) {
if result.Core.PacketBufferSize != 2000 {
t.Errorf("PacketBufferSize = %v, want 2000", result.Core.PacketBufferSize)
}
if result.Outputs[0].AsyncBuffer != 5000 {
t.Errorf("Outputs[0].AsyncBuffer = %v, want 5000", result.Outputs[0].AsyncBuffer)
}
}
func TestValidate(t *testing.T) {
@ -345,6 +348,20 @@ func TestValidate_InvalidOutputs(t *testing.T) {
},
wantErr: false,
},
{
name: "output with AsyncBuffer zero (default)",
outputs: []api.OutputConfig{
{Type: "stdout", Enabled: true, AsyncBuffer: 0},
},
wantErr: false,
},
{
name: "output with custom AsyncBuffer",
outputs: []api.OutputConfig{
{Type: "unix_socket", Enabled: true, AsyncBuffer: 5000, Params: map[string]string{"socket_path": "/tmp/x.sock"}},
},
wantErr: false,
},
}
for _, tt := range tests {

View File

@ -18,6 +18,8 @@ func NewEngine() *EngineImpl {
}
// FromClientHello generates JA4 (and optionally JA3) fingerprints from a TLS ClientHello
// Note: JA4Hash is populated for internal use but should NOT be serialized to LogRecord
// as the JA4 format already includes its own hash portions (per architecture.yml)
func (e *EngineImpl) FromClientHello(ch api.TLSClientHello) (*api.Fingerprints, error) {
if len(ch.Payload) == 0 {
return nil, fmt.Errorf("empty ClientHello payload")
@ -40,11 +42,12 @@ func (e *EngineImpl) FromClientHello(ch api.TLSClientHello) (*api.Fingerprints,
// Extract JA4 hash portion (last segment after underscore)
// JA4 format: <tls_ver><ciphers><extensions>_<sni_hash>_<cipher_extension_hash>
// This is kept for internal use but NOT serialized to LogRecord
ja4Hash := extractJA4Hash(ja4)
return &api.Fingerprints{
JA4: ja4,
JA4Hash: ja4Hash,
JA4Hash: ja4Hash, // Internal use only - not serialized to LogRecord
JA3: ja3,
JA3Hash: ja3Hash,
}, nil

View File

@ -45,3 +45,91 @@ func TestNewEngine(t *testing.T) {
t.Error("NewEngine() returned nil")
}
}
func TestFromClientHello_ValidPayload(t *testing.T) {
// Use a minimal valid TLS 1.2 ClientHello with extensions
// Build a proper ClientHello using the same structure as parser tests
clientHello := buildMinimalClientHelloForTest()
ch := api.TLSClientHello{
SrcIP: "192.168.1.100",
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
Payload: clientHello,
}
engine := NewEngine()
fp, err := engine.FromClientHello(ch)
if err != nil {
t.Fatalf("FromClientHello() error = %v", err)
}
if fp == nil {
t.Fatal("FromClientHello() returned nil")
}
// Verify JA4 is populated (format: t13d... or t12d...)
if fp.JA4 == "" {
t.Error("JA4 should not be empty")
}
// JA4Hash is populated for internal use (but not serialized to LogRecord)
// It contains the hash portions of the JA4 string
if fp.JA4Hash == "" {
t.Error("JA4Hash should be populated for internal use")
}
}
// buildMinimalClientHelloForTest creates a minimal valid TLS 1.2 ClientHello
func buildMinimalClientHelloForTest() []byte {
// Cipher suites (minimal set)
cipherSuites := []byte{0x00, 0x04, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f}
// Compression methods (null only)
compressionMethods := []byte{0x01, 0x00}
// No extensions
extensions := []byte{}
extLen := len(extensions)
// Build ClientHello handshake body
handshakeBody := []byte{
0x03, 0x03, // Version: TLS 1.2
// Random (32 bytes)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, // Session ID length: 0
}
// Add cipher suites (with length prefix)
cipherSuiteLen := len(cipherSuites)
handshakeBody = append(handshakeBody, byte(cipherSuiteLen>>8), byte(cipherSuiteLen))
handshakeBody = append(handshakeBody, cipherSuites...)
// Add compression methods (with length prefix)
handshakeBody = append(handshakeBody, compressionMethods...)
// Add extensions (with length prefix)
handshakeBody = append(handshakeBody, byte(extLen>>8), byte(extLen))
handshakeBody = append(handshakeBody, extensions...)
// Now build full handshake with type and length
handshakeLen := len(handshakeBody)
handshake := append([]byte{
0x01, // Handshake type: ClientHello
byte(handshakeLen >> 16), byte(handshakeLen >> 8), byte(handshakeLen), // Handshake length
}, handshakeBody...)
// Build TLS record
recordLen := len(handshake)
record := make([]byte, 5+recordLen)
record[0] = 0x16 // Handshake
record[1] = 0x03 // Version: TLS 1.2
record[2] = 0x03
record[3] = byte(recordLen >> 8)
record[4] = byte(recordLen)
copy(record[5:], handshake)
return record
}

View File

@ -504,6 +504,7 @@ func NewBuilder() *BuilderImpl {
}
// NewFromConfig constructs writers from AppConfig
// Uses AsyncBuffer from OutputConfig if specified, otherwise uses DefaultQueueSize
func (b *BuilderImpl) NewFromConfig(cfg api.AppConfig) (api.Writer, error) {
multiWriter := NewMultiWriter()
@ -515,6 +516,12 @@ func (b *BuilderImpl) NewFromConfig(cfg api.AppConfig) (api.Writer, error) {
var writer api.Writer
var err error
// Determine queue size: use AsyncBuffer if specified, otherwise default
queueSize := DefaultQueueSize
if outputCfg.AsyncBuffer > 0 {
queueSize = outputCfg.AsyncBuffer
}
switch outputCfg.Type {
case "stdout":
writer = NewStdoutWriter()
@ -537,7 +544,7 @@ func (b *BuilderImpl) NewFromConfig(cfg api.AppConfig) (api.Writer, error) {
if logLevel == "" {
logLevel = "error"
}
writer, err = NewUnixSocketWriterWithConfigAndLogLevel(socketPath, DefaultDialTimeout, DefaultWriteTimeout, DefaultQueueSize, logLevel)
writer, err = NewUnixSocketWriterWithConfigAndLogLevel(socketPath, DefaultDialTimeout, DefaultWriteTimeout, queueSize, logLevel)
if err != nil {
return nil, err
}

View File

@ -258,6 +258,24 @@ func TestBuilder_NewFromConfig(t *testing.T) {
},
wantErr: true,
},
{
name: "unix socket with custom AsyncBuffer",
config: api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{443},
},
Outputs: []api.OutputConfig{
{
Type: "unix_socket",
Enabled: true,
AsyncBuffer: 5000,
Params: map[string]string{"socket_path": "test.sock"},
},
},
},
wantErr: false,
},
}
for _, tt := range tests {
@ -409,8 +427,14 @@ func TestLogRecordJSONSerialization(t *testing.T) {
IPDF: true,
TCPWindow: 65535,
TCPOptions: "MSS,WS,SACK,TS",
// New fields per architecture.yml
ConnID: "flow-abc123",
SensorID: "sensor-01",
TLSVersion: "1.3",
SNI: "example.com",
ALPN: "h2",
// Fingerprints - note: JA4Hash is NOT in LogRecord per architecture
JA4: "t13d1516h2_8daaf6152771_02cb136f2775",
JA4Hash: "8daaf6152771_02cb136f2775",
JA3: "771,4865-4866-4867,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0",
JA3Hash: "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d",
Timestamp: time.Now().UnixNano(),
@ -434,6 +458,15 @@ func TestLogRecordJSONSerialization(t *testing.T) {
if got.JA4 != rec.JA4 {
t.Errorf("JA4 = %v, want %v", got.JA4, rec.JA4)
}
// Verify JA4Hash is NOT present (architecture decision)
// JA4Hash field doesn't exist in LogRecord anymore
// Verify new fields
if got.ConnID != rec.ConnID {
t.Errorf("ConnID = %v, want %v", got.ConnID, rec.ConnID)
}
if got.SNI != rec.SNI {
t.Errorf("SNI = %v, want %v", got.SNI, rec.SNI)
}
}
// Test to verify optional fields are omitted when empty

View File

@ -12,6 +12,7 @@ import (
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
tlsfingerprint "github.com/psanford/tlsfingerprint"
)
// ConnectionState represents the state of a TCP connection for TLS parsing
@ -220,7 +221,13 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
flow.State = JA4_DONE
flow.HelloBuffer = clientHello
return &api.TLSClientHello{
// Extract TLS extensions (SNI, ALPN, TLS version)
extInfo, _ := extractTLSExtensions(clientHello)
// Generate ConnID from flow key
connID := key
ch := &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
@ -228,7 +235,17 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
}, nil
ConnID: connID,
SNI: extInfo.SNI,
ALPN: joinStringSlice(extInfo.ALPN, ","),
TLSVersion: extInfo.TLSVersion,
}
// Calculate SynToCHMs if we have timing info
synToCH := uint32(time.Since(flow.CreatedAt).Milliseconds())
ch.SynToCHMs = &synToCH
return ch, nil
}
// Check for fragmented ClientHello (accumulate segments)
@ -257,7 +274,13 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
// Complete ClientHello found
flow.State = JA4_DONE
return &api.TLSClientHello{
// Extract TLS extensions (SNI, ALPN, TLS version)
extInfo, _ := extractTLSExtensions(clientHello)
// Generate ConnID from flow key
connID := key
ch := &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
@ -265,7 +288,17 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
}, nil
ConnID: connID,
SNI: extInfo.SNI,
ALPN: joinStringSlice(extInfo.ALPN, ","),
TLSVersion: extInfo.TLSVersion,
}
// Calculate SynToCHMs
synToCH := uint32(time.Since(flow.CreatedAt).Milliseconds())
ch.SynToCHMs = &synToCH
return ch, nil
}
}
@ -377,6 +410,13 @@ func extractTCPMeta(tcp *layers.TCP) api.TCPMeta {
return meta
}
// TLSExtensionInfo contains parsed TLS extension information
type TLSExtensionInfo struct {
SNI string
ALPN []string
TLSVersion string
}
// parseClientHello checks if the payload contains a TLS ClientHello and returns it
func parseClientHello(payload []byte) ([]byte, error) {
if len(payload) < 5 {
@ -419,6 +459,171 @@ func parseClientHello(payload []byte) ([]byte, error) {
return payload[:5+recordLength], nil
}
// extractTLSExtensions extracts SNI, ALPN, and TLS version from a ClientHello payload
// Uses tlsfingerprint library for ALPN and TLS version, manual parsing for SNI value
func extractTLSExtensions(payload []byte) (*TLSExtensionInfo, error) {
if len(payload) < 5 {
return nil, nil
}
// TLS record layer
contentType := payload[0]
if contentType != 22 {
return nil, nil // Not a handshake
}
version := binary.BigEndian.Uint16(payload[1:3])
recordLength := int(binary.BigEndian.Uint16(payload[3:5]))
if len(payload) < 5+recordLength {
return nil, nil // Incomplete record
}
handshakePayload := payload[5 : 5+recordLength]
if len(handshakePayload) < 1 {
return nil, nil
}
handshakeType := handshakePayload[0]
if handshakeType != 1 {
return nil, nil // Not a ClientHello
}
info := &TLSExtensionInfo{}
// Use tlsfingerprint to parse ALPN and TLS version
fp, err := tlsfingerprint.ParseClientHello(payload)
if err == nil && fp != nil {
// Extract ALPN protocols
if len(fp.ALPNProtocols) > 0 {
info.ALPN = fp.ALPNProtocols
}
// Extract TLS version
info.TLSVersion = tlsVersionToString(fp.Version)
}
// If tlsfingerprint didn't provide version, fall back to record version
if info.TLSVersion == "" {
info.TLSVersion = tlsVersionToString(version)
}
// Parse SNI manually (tlsfingerprint only provides HasSNI, not the value)
sniValue := extractSNIFromPayload(handshakePayload)
if sniValue != "" {
info.SNI = sniValue
}
return info, nil
}
// extractSNIFromPayload extracts the SNI value from a ClientHello handshake payload
// handshakePayload starts at the handshake type byte (0x01 for ClientHello)
func extractSNIFromPayload(handshakePayload []byte) string {
// handshakePayload structure:
// [0]: Handshake type (0x01 for ClientHello)
// [1:4]: Handshake length (3 bytes, big-endian)
// [4:6]: Version (2 bytes)
// [6:38]: Random (32 bytes)
// [38]: Session ID length
// ...
if len(handshakePayload) < 40 { // type(1) + len(3) + version(2) + random(32) + sessionIDLen(1)
return ""
}
// Start after type (1) + length (3) + version (2) + random (32) = 38
offset := 38
// Session ID length (1 byte)
sessionIDLen := int(handshakePayload[offset])
offset++
// Skip session ID
offset += sessionIDLen
// Cipher suites length (2 bytes)
if offset+2 > len(handshakePayload) {
return ""
}
cipherSuiteLen := int(binary.BigEndian.Uint16(handshakePayload[offset : offset+2]))
offset += 2 + cipherSuiteLen
// Compression methods length (1 byte)
if offset >= len(handshakePayload) {
return ""
}
compressionLen := int(handshakePayload[offset])
offset++
// Skip compression methods
offset += compressionLen
// Extensions length (2 bytes) - optional
if offset+2 > len(handshakePayload) {
return ""
}
extensionsLen := int(binary.BigEndian.Uint16(handshakePayload[offset : offset+2]))
offset += 2
if extensionsLen == 0 || offset+extensionsLen > len(handshakePayload) {
return ""
}
extensionsEnd := offset + extensionsLen
// Debug: log extension types found
_ = extensionsEnd // suppress unused warning in case we remove debug code
// Parse extensions to find SNI (type 0)
for offset < extensionsEnd {
if offset+4 > len(handshakePayload) {
break
}
extType := binary.BigEndian.Uint16(handshakePayload[offset : offset+2])
extLen := int(binary.BigEndian.Uint16(handshakePayload[offset+2 : offset+4]))
offset += 4
if offset+extLen > len(handshakePayload) {
break
}
extData := handshakePayload[offset : offset+extLen]
offset += extLen
if extType == 0 && len(extData) >= 5 { // SNI extension
// SNI extension structure:
// - name_list_len (2 bytes)
// - name_type (1 byte)
// - name_len (2 bytes)
// - name (variable)
// Skip name_list_len (2), read name_type (1) + name_len (2)
nameLen := int(binary.BigEndian.Uint16(extData[3:5]))
if len(extData) >= 5+nameLen {
return string(extData[5 : 5+nameLen])
}
}
}
return ""
}
// tlsVersionToString converts a TLS version number to a string
func tlsVersionToString(version uint16) string {
switch version {
case 0x0301:
return "1.0"
case 0x0302:
return "1.1"
case 0x0303:
return "1.2"
case 0x0304:
return "1.3"
default:
return ""
}
}
// IsClientHello checks if a payload contains a TLS ClientHello
func IsClientHello(payload []byte) bool {
if len(payload) < 6 {

View File

@ -7,6 +7,7 @@ import (
"ja4sentinel/api"
tlsfingerprint "github.com/psanford/tlsfingerprint"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
)
@ -444,3 +445,272 @@ func buildRawPacket(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16,
Timestamp: time.Now().UnixNano(),
}
}
func TestTLSVersionToString(t *testing.T) {
tests := []struct {
version uint16
want string
}{
{0x0301, "1.0"},
{0x0302, "1.1"},
{0x0303, "1.2"},
{0x0304, "1.3"},
{0x0300, ""},
{0x0305, ""},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := tlsVersionToString(tt.version)
if got != tt.want {
t.Errorf("tlsVersionToString(%#x) = %v, want %v", tt.version, got, tt.want)
}
})
}
}
func TestExtractTLSExtensions(t *testing.T) {
tests := []struct {
name string
payload []byte
wantSNI string
wantALPN []string
wantVersion string
wantNil bool
}{
{
name: "empty payload",
payload: []byte{},
wantNil: true,
},
{
name: "too short",
payload: []byte{0x16, 0x03, 0x03},
wantNil: true,
},
{
name: "TLS 1.2 ClientHello without extensions",
payload: createTLSClientHello(0x0303),
wantVersion: "1.2",
wantNil: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := extractTLSExtensions(tt.payload)
if err != nil {
t.Errorf("extractTLSExtensions() unexpected error = %v", err)
return
}
if (got == nil) != tt.wantNil {
t.Errorf("extractTLSExtensions() = %v, wantNil %v", got == nil, tt.wantNil)
return
}
if got != nil {
if got.TLSVersion != tt.wantVersion {
t.Errorf("TLSVersion = %v, want %v", got.TLSVersion, tt.wantVersion)
}
}
})
}
}
func TestParser_ExtractsTLSFields(t *testing.T) {
parser := NewParser()
defer parser.Close()
// Create a minimal valid TLS 1.2 ClientHello with SNI and ALPN extensions
// This is a real-world-like ClientHello structure
clientHelloWithExt := createMinimalTLSClientHelloWithSNIAndALPN("example.com", []string{"h2", "http/1.1"})
// Debug: Check what extractTLSExtensions returns
extInfo, err := extractTLSExtensions(clientHelloWithExt)
if err != nil {
t.Logf("extractTLSExtensions error: %v", err)
}
if extInfo != nil {
t.Logf("extInfo: SNI=%q, ALPN=%v, Version=%q", extInfo.SNI, extInfo.ALPN, extInfo.TLSVersion)
} else {
t.Log("extInfo is nil")
}
// Also test with tlsfingerprint directly
fp, err := tlsfingerprint.ParseClientHello(clientHelloWithExt)
if err != nil {
t.Logf("tlsfingerprint error: %v", err)
} else {
t.Logf("tlsfingerprint: ALPN=%v, Version=%#x, HasSNI=%v", fp.ALPNProtocols, fp.Version, fp.HasSNI)
}
// Debug: print first bytes of ClientHello
t.Logf("ClientHello hex: % x", clientHelloWithExt[:min(50, len(clientHelloWithExt))])
srcIP := "192.168.1.100"
dstIP := "10.0.0.1"
srcPort := uint16(54321)
dstPort := uint16(443)
pkt := buildRawPacket(t, srcIP, dstIP, srcPort, dstPort, clientHelloWithExt)
result, err := parser.Process(pkt)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
if result == nil {
t.Fatal("Process() should return TLSClientHello")
}
// Verify new fields are populated
if result.SNI != "example.com" {
t.Errorf("SNI = %v, want example.com", result.SNI)
}
if result.ALPN == "" {
t.Error("ALPN should not be empty")
}
if result.TLSVersion != "1.2" {
t.Errorf("TLSVersion = %v, want 1.2", result.TLSVersion)
}
if result.ConnID == "" {
t.Error("ConnID should not be empty")
}
if result.SynToCHMs == nil {
t.Error("SynToCHMs should not be nil")
}
}
// createMinimalTLSClientHelloWithSNIAndALPN creates a minimal but valid TLS 1.2 ClientHello
// with SNI and ALPN extensions
func createMinimalTLSClientHelloWithSNIAndALPN(sni string, alpnProtocols []string) []byte {
// Build SNI extension
sniExt := buildSNIExtension(sni)
// Build ALPN extension
alpnExt := buildALPNExtension(alpnProtocols)
// Build supported_versions extension (TLS 1.2)
supportedVersionsExt := []byte{0x00, 0x2b, 0x00, 0x03, 0x02, 0x03, 0x03} // type=43, len=3, TLS 1.2
// Combine extensions
extensions := append(sniExt, alpnExt...)
extensions = append(extensions, supportedVersionsExt...)
extLen := len(extensions)
// Cipher suites (minimal set)
cipherSuites := []byte{0x00, 0x04, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f}
// 4 cipher suites: TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
// Compression methods (null only)
compressionMethods := []byte{0x01, 0x00}
// Build ClientHello handshake (without length header first)
handshakeBody := []byte{
0x03, 0x03, // Version: TLS 1.2
// Random (32 bytes)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, // Session ID length: 0
}
// Add cipher suites (with length prefix)
cipherSuiteLen := len(cipherSuites)
handshakeBody = append(handshakeBody, byte(cipherSuiteLen>>8), byte(cipherSuiteLen))
handshakeBody = append(handshakeBody, cipherSuites...)
// Add compression methods (with length prefix)
handshakeBody = append(handshakeBody, compressionMethods...)
// Add extensions (with length prefix)
handshakeBody = append(handshakeBody, byte(extLen>>8), byte(extLen))
handshakeBody = append(handshakeBody, extensions...)
// Now build full handshake with type and length
handshakeLen := len(handshakeBody)
handshake := append([]byte{
0x01, // Handshake type: ClientHello
byte(handshakeLen >> 16), byte(handshakeLen >> 8), byte(handshakeLen), // Handshake length
}, handshakeBody...)
// Build TLS record
recordLen := len(handshake)
record := make([]byte, 5+recordLen)
record[0] = 0x16 // Handshake
record[1] = 0x03 // Version: TLS 1.2
record[2] = 0x03
record[3] = byte(recordLen >> 8)
record[4] = byte(recordLen)
copy(record[5:], handshake)
return record
}
// buildSNIExtension builds a Server Name Indication extension
func buildSNIExtension(sni string) []byte {
nameLen := len(sni)
// SNI extension structure:
// - name_list_len (2 bytes): total length of all names
// - name_type (1 byte): always 0x00 for host_name
// - name_len (2 bytes): length of this name
// - name (variable): the actual hostname
nameListLen := 1 + 2 + nameLen // name_type + name_len + name
// Extension data = name_list_len (2) + name_type (1) + name_len (2) + name
extDataLen := 2 + nameListLen
// Full extension = type (2) + length (2) + data (variable)
ext := make([]byte, 4+extDataLen)
ext[0] = 0x00 // Extension type: SNI (0)
ext[1] = 0x00
ext[2] = byte(extDataLen >> 8)
ext[3] = byte(extDataLen)
ext[4] = byte(nameListLen >> 8) // name_list_len (high byte)
ext[5] = byte(nameListLen) // name_list_len (low byte)
ext[6] = 0x00 // name_type: host_name (0)
ext[7] = byte(nameLen >> 8) // name_len (high byte)
ext[8] = byte(nameLen) // name_len (low byte)
copy(ext[9:], sni)
return ext
}
// buildALPNExtension builds an Application-Layer Protocol Negotiation extension
func buildALPNExtension(protocols []string) []byte {
// Calculate ALPN data length
// ALPN data = alpn_list_len (2) + for each protocol: length (1) + data (variable)
alpnDataLen := 0
for _, proto := range protocols {
alpnDataLen += 1 + len(proto) // length byte + protocol string
}
// Extension data = alpn_list_len (2) + protocols
extDataLen := 2 + alpnDataLen
// Full extension = type (2) + length (2) + data (variable)
ext := make([]byte, 4+extDataLen)
ext[0] = 0x00 // Extension type: ALPN (16 = 0x10)
ext[1] = 0x10
ext[2] = byte(extDataLen >> 8)
ext[3] = byte(extDataLen)
// ALPN protocol list length (2 bytes)
ext[4] = byte(alpnDataLen >> 8)
ext[5] = byte(alpnDataLen)
offset := 6
for _, proto := range protocols {
ext[offset] = byte(len(proto))
offset++
copy(ext[offset:], proto)
offset += len(proto)
}
return ext
}
// min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@ -3,7 +3,7 @@
%if %{defined build_version}
%define spec_version %{build_version}
%else
%define spec_version 1.0.8
%define spec_version 1.0.9
%endif
Name: ja4sentinel
@ -117,6 +117,19 @@ fi
%dir /var/run/logcorrelator
%changelog
* Mon Mar 02 2026 Jacquin Antoine <rpm@arkel.fr> - 1.0.9-1
- Add SNI (Server Name Indication) extraction from TLS ClientHello
- Add ALPN (Application-Layer Protocol Negotiation) extraction
- Add TLS version detection from ClientHello
- Add ConnID field for flow correlation
- Add SensorID field for multi-sensor deployments
- Add SynToCHMs timing field for behavioral detection
- Add AsyncBuffer configuration for output queue sizing
- Remove JA4Hash from LogRecord (JA4 format includes its own hash)
- Use tlsfingerprint library for ALPN and TLS version parsing
- Update architecture.yml compliance for all new fields
- Add unit tests for TLS extension extraction
* Sun Mar 01 2026 Jacquin Antoine <rpm@arkel.fr> - 1.0.8-1
- Add configurable log level (debug, info, warn, error) via config.yml
- Add JA4SENTINEL_LOG_LEVEL environment variable support