Files
tianxuan/tests/test_e2e.py
T

366 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""End-to-end integration test for the full TLS analysis pipeline.
Pipeline
1. Create synthetic TLS flow CSV with known entity clusters.
2. Load via ``load_csv_directory``.
3. Store in ``SessionStore``.
4. Auto-detect entity column (``detect_entity_column``).
5. Aggregate flows → entity profiles (``aggregate_by_entity``).
6. Run HDBSCAN clustering on aggregated numeric features.
7. Extract per-cluster distinguishing features.
All data is synthetic, deterministic (seed=42), and self-contained —
no external files or network access required.
"""
import tempfile
from pathlib import Path
import numpy as np
import polars as pl
import pytest
from analysis.data_loader import load_csv_directory
from analysis.entity_detector import detect_entity_column
from analysis.entity_aggregator import aggregate_by_entity
from analysis.session_store import SessionStore
# ═══════════════════════════════════════════════════════════════════════
# Fixtures
# ═══════════════════════════════════════════════════════════════════════
@pytest.fixture(autouse=True)
def clean_store():
"""Reset the SessionStore singleton before and after each test."""
store = SessionStore()
store.drop_all()
yield
store.drop_all()
# ═══════════════════════════════════════════════════════════════════════
# Synthetic data generator
# ═══════════════════════════════════════════════════════════════════════
def create_synthetic_flows(seed: int = 42) -> pl.DataFrame:
"""Generate deterministic TLS flow data with three known entity groups.
Uses the user's exact column naming convention only.
Returns
-------
pl.DataFrame
~1000 rows with columns.
Entities (``:ips``) are partitioned into three behavioural
groups that HDBSCAN should recover after aggregation.
"""
rng = np.random.default_rng(seed)
# ── Entity profiles ──────────────────────────────────────────────
# Group A: 10 high-volume entities (50 flows each)
group_a = [
{
':ips': f'10.0.0.{i+1}',
'n_flows': 50,
'8ack_mu': 50_000, '8ack_sigma': 5_000,
'4dur_mu': 30.0, '4dur_sigma': 5.0,
'dst_range': (100, 150),
'0ver': 'TLSv1.3',
'cipher_suite': 'TLS_AES_256_GCM_SHA384',
}
for i in range(10)
]
# Group B: 15 medium-volume entities (20 flows each)
group_b = [
{
':ips': f'10.0.0.{i+11}',
'n_flows': 20,
'8ack_mu': 5_000, '8ack_sigma': 1_000,
'4dur_mu': 15.0, '4dur_sigma': 3.0,
'dst_range': (150, 200),
'0ver': 'TLSv1.2',
'cipher_suite': 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256',
}
for i in range(15)
]
# Group C: 25 low-volume entities (6 flows each)
group_c = [
{
':ips': f'10.0.0.{i+26}',
'n_flows': 6,
'8ack_mu': 500, '8ack_sigma': 100,
'4dur_mu': 5.0, '4dur_sigma': 1.0,
'dst_range': (200, 250),
'0ver': 'TLSv1.2',
'cipher_suite': 'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256',
}
for i in range(25)
]
entities = group_a + group_b + group_c
# Total flows: 10×50 + 15×20 + 25×6 = 500 + 300 + 150 = 950
# ── Generate flow rows ────────────────────────────────────────────
rows: list[dict] = []
dst_ports = [80, 443, 8080, 8443]
protos = ['TCP', 'UDP']
proto_weights = [0.9, 0.1]
for ent in entities:
lo, hi = ent['dst_range']
for _ in range(ent['n_flows']):
dst_idx = int(rng.integers(lo, hi))
row = {
':ips': ent[':ips'],
':ipd': f"10.0.{dst_idx >> 8}.{dst_idx & 0xff}",
':prd': int(rng.choice(dst_ports)),
'8ack': int(max(0, rng.normal(ent['8ack_mu'],
ent['8ack_sigma']))),
'4dur': round(max(0.1, abs(rng.normal(ent['4dur_mu'],
ent['4dur_sigma']))), 2),
'1ipp': str(rng.choice(protos, p=proto_weights)),
'0ver': ent['0ver'],
'cipher_suite': ent['cipher_suite'],
'snam': f"host{rng.integers(1, 200)}.example.com",
'timestamp': (
f"2024-06-15 "
f"{rng.integers(0, 23):02d}:"
f"{rng.integers(0, 59):02d}:"
f"{rng.integers(0, 59):02d}"
),
}
rows.append(row)
return pl.DataFrame(rows)
# ═══════════════════════════════════════════════════════════════════════
# Pipeline tests
# ═══════════════════════════════════════════════════════════════════════
class TestFullPipeline:
"""End-to-end: synthetic CSV → load → entity detect → aggregate → cluster."""
def _write_csv(self, df: pl.DataFrame, tmpdir: str) -> str:
"""Write a Polars DataFrame to a temp CSV; return the file path."""
path = Path(tmpdir) / 'synthetic_flows.csv'
df.write_csv(path)
return str(path)
def test_full_pipeline(self):
"""Smoke-test the full pipeline — every step produces valid output."""
with tempfile.TemporaryDirectory() as tmpdir:
# ── Step 1: Create synthetic data ─────────────────────────────
df_flows = create_synthetic_flows(seed=42)
assert len(df_flows) > 0, 'Synthetic data should not be empty'
csv_path = self._write_csv(df_flows, tmpdir)
# ── Step 2: Load data ─────────────────────────────────────────
lf, schema, row_count, file_count, memory_mb = load_csv_directory(
csv_path, encoding='utf-8',
)
assert isinstance(lf, pl.LazyFrame), 'Expected LazyFrame'
assert file_count == 1
assert row_count > 0
assert memory_mb >= 0
assert ':ips' in schema
# ── Step 3: Store in session ──────────────────────────────────
store = SessionStore()
store.store_dataset(
'e2e_ds', lf, schema=schema,
metadata={'row_count': row_count, 'csv_glob': csv_path},
)
# ── Step 4: Entity detection ──────────────────────────────────
det_result = detect_entity_column('e2e_ds')
assert 'error' not in det_result, f'detect_entity_column failed: {det_result.get("error")}'
# :ips is the catch-all entity column (strings with good unique ratio)
assert det_result['recommended'] in (':ips', ':ipd', 'snam'), (
f'Expected :ips, :ipd or snam, got {det_result["recommended"]}'
)
assert len(det_result['candidates']) > 0
# Verify recommended_multi exists
assert 'recommended_multi' in det_result, 'recommended_multi field missing'
assert len(det_result['recommended_multi']) > 0, 'recommended_multi is empty'
# ── Step 5: Entity aggregation ────────────────────────────────
agg_lf, feature_cols = aggregate_by_entity(lf, ':ips', schema)
df_agg = agg_lf.collect()
# Should be exactly 50 entities (10 + 15 + 25)
entity_count = len(df_agg)
assert entity_count == 50, f'Expected 50 entities, got {entity_count}'
# Verify per-entity flow counts (sort by flow_count desc so groups
# are contiguous: A=50, B=20, C=6)
df_sorted = df_agg.sort('flow_count', descending=True)
# Group A: 10 entities × 50 flows
for i in range(10):
assert df_sorted['flow_count'][i] == 50, (
f'Row {i}: expected 50, got {df_sorted["flow_count"][i]}'
)
# Group B: 15 entities × 20 flows
for i in range(10, 25):
assert df_sorted['flow_count'][i] == 20, (
f'Row {i}: expected 20, got {df_sorted["flow_count"][i]}'
)
# Group C: 25 entities × 6 flows
for i in range(25, 50):
assert df_sorted['flow_count'][i] == 6, (
f'Row {i}: expected 6, got {df_sorted["flow_count"][i]}'
)
# Core feature columns are present
for col in ('flow_count', 'total_bytes_sent',
'avg_duration', 'unique_dst_ips', 'unique_dst_ports',
'unique_tls_versions', 'unique_protocols',
'first_seen', 'last_seen'):
assert col in feature_cols, f'Missing feature column: {col}'
# ── Step 6: Clustering ────────────────────────────────────────
numeric_cols = [
'flow_count', 'total_bytes_sent',
'avg_duration', 'unique_dst_ips', 'unique_dst_ports',
]
available_cols = [c for c in numeric_cols if c in df_agg.columns]
data = df_agg.select(available_cols).to_numpy()
assert data.shape[0] == 50, 'All 50 entities should have features'
# Standard-scale + HDBSCAN
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import HDBSCAN
data_scaled = StandardScaler().fit_transform(data)
clusterer = HDBSCAN(min_cluster_size=5, metric='euclidean')
labels = clusterer.fit_predict(data_scaled)
n_clusters = len(set(labels) - {-1})
assert n_clusters > 0, (
f'HDBSCAN should find ≥1 cluster, found {n_clusters} '
f'(labels={set(labels)})'
)
noise_count = int((labels == -1).sum())
# At most 40% noise allowed (we have well-separated groups)
noise_ratio = noise_count / len(labels)
assert noise_ratio < 0.40, (
f'Noise ratio too high: {noise_ratio:.2%} '
f'({noise_count}/{len(labels)})'
)
# ── Step 7: Feature extraction ────────────────────────────────
unique_labels = sorted(set(l for l in labels if l >= 0))
assert len(unique_labels) > 0, 'Need at least one valid cluster'
# Compute per-cluster distinguishing features using z-score
features = []
for label in unique_labels:
mask = labels == label
cluster_data = data[mask]
for col_idx, col_name in enumerate(available_cols):
global_mean = float(data[:, col_idx].mean())
global_std = float(data[:, col_idx].std()) or 1.0
cluster_mean = float(cluster_data[:, col_idx].mean())
z = (cluster_mean - global_mean) / global_std
features.append({
'cluster': int(label),
'feature': col_name,
'z_score': round(z, 4),
})
assert len(features) > 0, 'Expected at least one extracted feature'
# Each cluster should have features for all numeric columns
assert len(features) >= len(unique_labels) * len(available_cols)
# ── Step 8: Subnet aggregation function ────────────────────────
from analysis.entity_aggregator import ip_to_subnet
assert callable(ip_to_subnet)
assert ip_to_subnet('10.0.1.15', 24) == '10.0.1.0/24'
# Note: SessionStore is cleaned up by the ``clean_store`` fixture
def test_synthetic_data_shape(self):
"""Verify the synthetic data generator produces expected shapes."""
df = create_synthetic_flows(seed=42)
assert len(df.columns) == 10, f'Expected 10 columns, got {len(df.columns)}'
# Total: 10×50 + 15×20 + 25×6 = 950
assert len(df) == 950, f'Expected 950 rows, got {len(df)}'
# Verify unique entity count
n_entities = df[':ips'].n_unique()
assert n_entities == 50, f'Expected 50 unique :ips, got {n_entities}'
# Group A should have TLSv1.3
group_a_ips = [f'10.0.0.{i+1}' for i in range(10)]
group_a_tls = df.filter(
pl.col(':ips').is_in(group_a_ips)
)['0ver'].unique().to_list()
assert group_a_tls == ['TLSv1.3']
# Group C should have CHACHA20 cipher
group_c_ips = [f'10.0.0.{i+26}' for i in range(25)]
group_c_cipher = df.filter(
pl.col(':ips').is_in(group_c_ips)
)['cipher_suite'].unique().to_list()
assert any('CHACHA20' in c for c in group_c_cipher)
class TestPipelineEdgeCases:
"""Edge cases for the analysis pipeline."""
def test_empty_dataset_rejected(self):
"""Empty dataset stored → detect_entity_column returns error."""
store = SessionStore()
empty = pl.DataFrame({':ips': pl.Series([], dtype=pl.Utf8)})
schema = {}
store.store_dataset(
'empty_ds', empty.lazy(), schema=schema,
metadata={'row_count': 0},
)
result = detect_entity_column('empty_ds')
# May fail or return empty candidates; either is acceptable
# but it should not crash with an unhandled exception.
assert 'error' in result or result['recommended'] == ''
def test_single_entity_clustering(self):
"""Single entity → aggregation works but clustering may not split."""
data = pl.DataFrame({
':ips': ['10.0.0.1'] * 20,
':ipd': ['10.0.0.2'] * 20,
'8ack': [100] * 20,
'4dur': [1.0] * 20,
'1ipp': ['TCP'] * 20,
'0ver': ['TLSv1.2'] * 20,
'cipher_suite': ['AES'] * 20,
'snam': ['host1.example.com'] * 20,
'timestamp': ['2024-01-01 00:00:00'] * 20,
':prd': [443] * 20,
})
schema = {name: str(dtype) for name, dtype in zip(data.columns, data.dtypes)}
lf = data.lazy()
agg_lf, _ = aggregate_by_entity(lf, ':ips', schema)
df_agg = agg_lf.collect()
assert len(df_agg) == 1
assert df_agg['flow_count'][0] == 20
def test_aggregate_preserves_schema_types(self):
"""Aggregated columns should have expected Polars dtypes."""
data = pl.DataFrame({
':ips': ['10.0.0.1'] * 10,
'8ack': [100] * 10,
'4dur': [1.5] * 10,
})
schema = {name: str(dtype) for name, dtype in zip(data.columns, data.dtypes)}
lf = data.lazy()
agg_lf, _ = aggregate_by_entity(lf, ':ips', schema)
df = agg_lf.collect()
assert df['flow_count'].dtype == pl.UInt32 or df['flow_count'].dtype == pl.Int64