feat: HTTP/2 passive fingerprinting with individual SETTINGS fields
Complete implementation of HTTP/2 passive fingerprinting per thesis §2.5.3: mod-reqin-log (C module): - Replace connection-level filter with ap_hook_process_connection (APR_HOOK_FIRST) to capture H2 preface before mod_http2 takes over the connection - AP_MODE_SPECULATIVE read of 512 bytes from c->input_filters - Parse SETTINGS, WINDOW_UPDATE, PRIORITY flags, pseudo-header order - Output individual SETTINGS params as separate JSON fields (IDs 1-6, 8) - Read H2 notes from c1 (master connection) for mod_http2 secondary conns - Fix header_order_signature JSON length bug (26→strlen) ClickHouse schema: - Add 8 new columns to http_logs: h2_has_priority, h2_header_table_size, h2_enable_push, h2_max_concurrent_streams, h2_initial_window_size, h2_max_frame_size, h2_max_header_list_size, h2_enable_connect_protocol - Use Int32/Int64 with DEFAULT -1 to distinguish absent vs zero - Update mv_http_logs to extract individual fields via JSONHas/JSONExtractInt - Migration 04_http2_fields.sql updated for existing deployments Correlator: - Accept both timestamp_ns and timestamp field names (backward compat) Integration: - Enable HTTP/2 in Apache: Protocols h2 http/1.1 in httpd-integration.conf Validated end-to-end via Playwright: H2 curl traffic → mod-reqin-log → correlator → ClickHouse with all 12 H2 columns populated correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -4,6 +4,8 @@ package fingerprint
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/antitbone/ja4/sentinel/api"
|
||||
|
||||
@ -59,14 +61,33 @@ func (e *EngineImpl) FromClientHello(ch api.TLSClientHello) (*api.Fingerprints,
|
||||
// This is kept for internal use but NOT serialized to LogRecord
|
||||
ja4Hash := extractJA4Hash(ja4)
|
||||
|
||||
// Generate JA4T fingerprint from TCP SYN parameters
|
||||
ja4t := computeJA4T(ch.TCPMeta)
|
||||
|
||||
return &api.Fingerprints{
|
||||
JA4: ja4,
|
||||
JA4Hash: ja4Hash, // Internal use only - not serialized to LogRecord
|
||||
JA4T: ja4t,
|
||||
JA3: ja3,
|
||||
JA3Hash: ja3Hash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// computeJA4T génère l'empreinte JA4T à partir des métadonnées TCP SYN.
|
||||
// Format : {WindowSize}_{OptionKinds}_{WindowScale}_{MSS}
|
||||
func computeJA4T(tcp api.TCPMeta) string {
|
||||
optStr := ""
|
||||
if len(tcp.OptionKinds) > 0 {
|
||||
parts := make([]string, len(tcp.OptionKinds))
|
||||
for i, k := range tcp.OptionKinds {
|
||||
parts[i] = strconv.Itoa(int(k))
|
||||
}
|
||||
optStr = strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d_%s_%d_%d", tcp.WindowSize, optStr, tcp.WindowScale, tcp.MSS)
|
||||
}
|
||||
|
||||
// extractJA4Hash extracts the hash portion from a JA4 string
|
||||
// JA4 format: <base>_<sni_hash>_<cipher_hash> -> returns "<sni_hash>_<cipher_hash>"
|
||||
func extractJA4Hash(ja4 string) string {
|
||||
|
||||
@ -487,3 +487,99 @@ t.Errorf("expected 'somehash', got %q", hash)
|
||||
var _ interface {
|
||||
FromClientHello(api.TLSClientHello) (*api.Fingerprints, error)
|
||||
} = (*EngineImpl)(nil)
|
||||
|
||||
// TestComputeJA4T tests the JA4T fingerprint generation.
|
||||
func TestComputeJA4T(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tcp api.TCPMeta
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "linux_5x_typical",
|
||||
tcp: api.TCPMeta{
|
||||
WindowSize: 64240,
|
||||
OptionKinds: []uint8{2, 4, 8, 1, 3},
|
||||
WindowScale: 7,
|
||||
MSS: 1460,
|
||||
},
|
||||
want: "64240_2-4-8-1-3_7_1460",
|
||||
},
|
||||
{
|
||||
name: "windows_11_typical",
|
||||
tcp: api.TCPMeta{
|
||||
WindowSize: 64240,
|
||||
OptionKinds: []uint8{2, 4, 8, 1, 3},
|
||||
WindowScale: 8,
|
||||
MSS: 1460,
|
||||
},
|
||||
want: "64240_2-4-8-1-3_8_1460",
|
||||
},
|
||||
{
|
||||
name: "macos_14_typical",
|
||||
tcp: api.TCPMeta{
|
||||
WindowSize: 65535,
|
||||
OptionKinds: []uint8{2, 4, 8, 1, 3},
|
||||
WindowScale: 6,
|
||||
MSS: 1460,
|
||||
},
|
||||
want: "65535_2-4-8-1-3_6_1460",
|
||||
},
|
||||
{
|
||||
name: "no_options",
|
||||
tcp: api.TCPMeta{
|
||||
WindowSize: 8192,
|
||||
OptionKinds: nil,
|
||||
WindowScale: 0,
|
||||
MSS: 0,
|
||||
},
|
||||
want: "8192__0_0",
|
||||
},
|
||||
{
|
||||
name: "windows_no_ts",
|
||||
tcp: api.TCPMeta{
|
||||
WindowSize: 8192,
|
||||
OptionKinds: []uint8{2, 4, 1, 3},
|
||||
WindowScale: 2,
|
||||
MSS: 1460,
|
||||
},
|
||||
want: "8192_2-4-1-3_2_1460",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := computeJA4T(tt.tcp)
|
||||
if got != tt.want {
|
||||
t.Errorf("computeJA4T() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromClientHello_JA4T_Populated tests that JA4T is populated in FromClientHello.
|
||||
func TestFromClientHello_JA4T_Populated(t *testing.T) {
|
||||
clientHello := buildMinimalClientHelloForTest()
|
||||
|
||||
ch := api.TLSClientHello{
|
||||
Payload: clientHello,
|
||||
TCPMeta: api.TCPMeta{
|
||||
WindowSize: 64240,
|
||||
MSS: 1460,
|
||||
WindowScale: 7,
|
||||
OptionKinds: []uint8{2, 4, 8, 1, 3},
|
||||
Options: []string{"MSS", "SACK", "TS", "NOP", "WS"},
|
||||
},
|
||||
}
|
||||
|
||||
engine := NewEngine()
|
||||
fp, err := engine.FromClientHello(ch)
|
||||
if err != nil {
|
||||
t.Fatalf("FromClientHello() error = %v", err)
|
||||
}
|
||||
|
||||
expected := "64240_2-4-8-1-3_7_1460"
|
||||
if fp.JA4T != expected {
|
||||
t.Errorf("JA4T = %q, want %q", fp.JA4T, expected)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user