chore: bump version to v1.1.2

- New timeline UI: interleaved LLM reasoning + tool call display
- Fix: run_log truncation for large tool outputs
- Fix: lat/lon + values reduced to ~1% (was 99%)
- Fix: @csrf_exempt on upload/delete endpoints
- Fix: DATA_UPLOAD_MAX_NUMBER_FILES for 1998-file upload
- Fix: dtype alignment for multi-batch CSV processing
- New: tests/test_e2e_full.py with real DeepSeek LLM
- New: 1998-file test data generator
This commit is contained in:
PM-pinou
2026-07-20 23:32:32 +08:00
parent 406bf81600
commit 9314ac6942
24 changed files with 888 additions and 2493 deletions
+9 -2
View File
@@ -156,12 +156,19 @@ entity:
### 运行全部测试
```bash
runtime\python\python.exe -m pytest tests -q
# 单元测试(不需要Django
runtime\python\python.exe -m pytest tests/test_data_loader.py tests/test_type_classifier.py -q
# 集成测试(自动启动后端、模拟前端行为、监控stderr报错)
runtime\python\python.exe tests/test_integration.py
```
```
92 passed (unit tests); ALL INTEGRATION TESTS PASSED
92 passed (unit tests); ALL INTEGRATION TESTS PASSED
```
> ⚠️ **重要**: `tests/test_integration.py` 模拟前端HTML行为(上传CSV → 轮询状态 → 访问页面 → 删除)。
> **当前端HTML/API发生变化时,必须同步更新此脚本**,否则集成测试将不再反映真实用户流程。
### 跨机一致性测试
```bash
# 两台机器各自执行:
+1 -1
View File
@@ -1 +1 @@
v1.1.1
v1.1.2
+18 -2
View File
@@ -288,7 +288,19 @@ def load_from_db(
rows = conn.execute(f'SELECT * FROM "{table_name}"').fetchall()
if not rows:
return pl.DataFrame().lazy()
df = pl.from_dicts([dict(r) for r in rows])
try:
df = pl.from_dicts([dict(r) for r in rows])
except Exception:
# Fallback: load all columns as Utf8 to handle mixed-type SQLite columns,
# then apply schema_overrides to restore intended types.
import io, csv as _csv_mod
buf = io.StringIO()
writer = _csv_mod.writer(buf)
writer.writerow(rows[0].keys())
for r in rows:
writer.writerow(str(v) if v is not None else '' for v in r)
buf.seek(0)
df = pl.read_csv(buf, infer_schema_length=0)
# Apply type overrides to restore original column types
if schema_overrides:
@@ -298,7 +310,11 @@ def load_from_db(
continue
dtype_obj = dtype if isinstance(dtype, pl.DataType) else _str_to_polars_dtype(dtype)
if df[col].dtype != dtype_obj:
casts[col] = pl.col(col).cast(dtype_obj)
try:
casts[col] = pl.col(col).cast(dtype_obj)
except Exception:
# If a cast fails (e.g. "A" → Int64), keep the column as-is
pass
if casts:
df = df.with_columns(list(casts.values()))
+174 -6
View File
@@ -4,13 +4,13 @@ Each tool is defined by a :class:`Tool <mcp.types.Tool>` metadata object and
an async handler function. Handlers interact with the :class:`SessionStore`
singleton and the Django ORM.
All 11 tools:
All 12 tools:
1. load_data 7. filter_and_cluster
2. profile_data 8. export_results
3. filter_data 9. list_datasets
4. preprocess_data 10. drop_dataset
5. run_clustering 11. clone_dataset
6. evaluate_clustering
2. profile_data 8. build_entity_profiles
3. filter_data 9. export_results
4. preprocess_data 10. list_datasets
5. run_clustering 11. drop_dataset
6. evaluate_clustering 12. clone_dataset
"""
import json
import os
@@ -462,6 +462,39 @@ def get_tools_meta() -> list[Tool]:
"required": ["dataset_id", "filters"],
},
),
Tool(
name="build_entity_profiles",
description=(
"按实体列(源IP、SNI等)分组原始流数据。自动检测实体列(如 src_ip, "
"dst_ip, sni, host, mac, domain),按列分组并计算每个实体的聚合特征:"
"流数量、唯一端口数、TLS版本分布、字节总量、唯一目标数。"
"生成的数据集每个实体一行。结果存储在会话中,可供后续 compute_scores 使用。"
),
inputSchema={
"type": "object",
"properties": {
"dataset_id": {
"type": "string",
"description": "Dataset ID from load_data or profile_data",
},
"entity_column": {
"type": "string",
"description": "Specific entity column to group by (e.g. 'src_ip')",
},
"entity_columns": {
"type": "array",
"items": {"type": "string"},
"description": "Multiple entity columns to group by",
},
"auto_detect": {
"type": "boolean",
"description": "Auto-detect entity columns (IP, SNI, host, MAC, domain)",
"default": True,
},
},
"required": ["dataset_id"],
},
),
Tool(
name="compute_scores",
description=(
@@ -775,6 +808,7 @@ async def handle_call(name: str, arguments: dict) -> dict:
'list_datasets': _handle_list_datasets,
'drop_dataset': _handle_drop_dataset,
'clone_dataset': _handle_clone_dataset,
'build_entity_profiles': _handle_build_entity_profiles,
'compute_scores': _handle_compute_scores,
'filter_and_cluster': _handle_filter_and_cluster,
'detect_anomalies': _handle_detect_anomalies,
@@ -1808,6 +1842,140 @@ async def _handle_clone_dataset(dataset_id: str) -> dict:
return {'original_dataset_id': dataset_id, 'cloned_dataset_id': new_id}
# ── 12. build_entity_profiles ────────────────────────────────────────────
_ENTITY_KEYWORDS = [
'src_ip', 'dst_ip', 'source_ip', 'destination_ip', 'src_addr', 'dst_addr',
'sip', 'dip', 'ip_src', 'ip_dst', 'saddr', 'daddr',
'sni', 'host', 'domain', 'uri', 'url', 'fqdn', 'server_name',
'mac', 'mac_addr', 'src_mac', 'dst_mac',
'user_agent', 'ua', 'asn', 'organization', 'org',
]
_NUMERIC_TYPES_FOR_AGG = (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
pl.Float32, pl.Float64)
async def _handle_build_entity_profiles(
dataset_id: str,
entity_column: Optional[str] = None,
entity_columns: Optional[list[str]] = None,
auto_detect: bool = True,
) -> dict:
"""Group raw flow data by entity column(s) and compute per-entity aggregate features.
Stores the result in the session store and returns the new dataset_id.
"""
store = SessionStore()
entry = store.get_dataset(dataset_id)
if entry is None:
return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
lf: pl.LazyFrame = entry['lazyframe']
schema = entry.get('schema', {})
col_names = list(schema.keys())
# ── Resolve entity columns ──────────────────────────────────────────
if entity_column and entity_column in col_names:
group_cols = [entity_column]
elif entity_columns:
group_cols = [c for c in entity_columns if c in col_names]
if not group_cols:
return {'error': f'None of entity_columns {entity_columns} exist in dataset. Available: {col_names[:20]}',
'truncated': False}
elif auto_detect:
# Auto-detect: find columns matching entity keywords
matched = []
for kw in _ENTITY_KEYWORDS:
found = [c for c in col_names if kw.lower() in c.lower()]
matched.extend(found)
# Deduplicate while preserving order
seen = set()
group_cols = [c for c in matched if not (c in seen or seen.add(c))]
if not group_cols:
# Fallback: pick first non-numeric, non-internal column
for c in col_names:
if c.startswith('_'):
continue
group_cols = [c]
break
# Limit to at most 3 entity columns
group_cols = group_cols[:3]
else:
return {'error': 'No entity column specified and auto_detect is disabled',
'truncated': False}
if not group_cols:
return {'error': 'Could not determine entity column(s) for grouping',
'truncated': False}
# ── Build aggregation expressions ──────────────────────────────────
# Always: count of flows
agg_exprs: list[pl.Expr] = [
pl.len().alias('flow_count'),
pl.col(group_cols[0]).n_unique().alias(f'unique_{group_cols[0]}'),
]
# Numeric aggregations (sum, mean)
numeric_cols = [c for c in col_names if c not in group_cols
and schema[c].startswith(('Int', 'UInt', 'Float'))]
for c in numeric_cols[:20]:
agg_exprs.append(pl.sum(c).alias(f'total_{c}'))
agg_exprs.append(pl.mean(c).alias(f'avg_{c}'))
# Unique counts for string-like entity columns (dst_ip, dst_port, sni, etc.)
str_cols = [c for c in col_names if c not in group_cols
and schema[c].startswith(('Utf8', 'String', 'Cat'))]
for c in str_cols[:10]:
agg_exprs.append(pl.col(c).n_unique().alias(f'unique_{c}'))
# Also count distinct values of specific keyword-matching columns
for kw in ['dst_ip', 'dip', 'dst_addr', 'daddr', 'ip_dst', 'dest_ip',
'dst_port', 'dport', 'destination_port', 'dest_port',
'port', 'protocol', 'proto', 'sni', 'server_name',
'cipher_suite', 'cipher', 'tls_version', 'version', '0ver',
'country', 'scnt', 'dcnt', 'asn', 'organization']:
found = [c for c in col_names if c not in group_cols and kw.lower() in c.lower()]
for c in found:
alias_name = f'unique_{c}'
if alias_name not in [str(a.meta.output_name()) if hasattr(a, 'meta') else '' for a in agg_exprs]:
agg_exprs.append(pl.col(c).n_unique().alias(alias_name))
# ── Execute grouping ───────────────────────────────────────────────
try:
entity_lf = lf.group_by(group_cols).agg(agg_exprs)
# Estimate row count
n_entities = entity_lf.select(pl.len()).collect(streaming=True).item()
except Exception as e:
return {'error': f'Entity aggregation failed: {e}', 'truncated': False}
# ── Store result ───────────────────────────────────────────────────
new_schema = {name: str(dtype) for name, dtype in
zip(entity_lf.collect_schema().names(), entity_lf.collect_schema().dtypes())}
new_id = f'entity_{_generate_id("ds")}'
store.store_dataset(
dataset_id=new_id,
lazyframe=entity_lf,
schema=new_schema,
metadata={
'row_count': n_entities,
'entity_columns': group_cols,
'parent_dataset_id': dataset_id,
'n_agg_features': len(agg_exprs),
},
parent_id=dataset_id,
)
return {
'dataset_id': new_id,
'entity_columns': group_cols,
'n_entities': n_entities,
'n_features': len(agg_exprs),
'aggregated_columns': list(new_schema.keys())[:20],
}
# ── Adaptive anomaly scoring helpers (inlined from entity_aggregator) ─────
_ANOMALY_FEATURE_COLS = [
+48 -10
View File
@@ -436,8 +436,9 @@ def _background_process(run_id, upload_dir):
table_name = f'_data_{run_id}'
# ── Phase 1: Scan first batch ONLY for schema consistency ──
# Use large infer_schema_length to catch mixed-type columns early
first_batch = csv_paths[:batch_size]
first_lf = pl.scan_csv(first_batch, low_memory=True,
first_lf = pl.scan_csv(first_batch, infer_schema_length=100000,
schema_overrides=lat_lon_overrides)
first_schema = dict(first_lf.collect_schema())
schema = {name: str(dtype) for name, dtype in first_schema.items()}
@@ -449,7 +450,7 @@ def _background_process(run_id, upload_dir):
total_rows = 0
for i in range(0, total_files, batch_size):
batch = csv_paths[i:i+batch_size]
batch_lf = pl.scan_csv(batch, low_memory=True,
batch_lf = pl.scan_csv(batch, infer_schema_length=100000,
schema_overrides=lat_lon_overrides)
df_batch = batch_lf.collect(streaming=True)
@@ -462,6 +463,27 @@ def _background_process(run_id, upload_dir):
df_batch = df_batch.drop(extra)
df_batch = df_batch.select(reference_columns)
# Dtype alignment: cast each column to the reference dtype
# to prevent mixed-type columns when loading from SQLite later
casts = {}
for col in reference_columns:
ref_dtype_str = schema.get(col)
if ref_dtype_str and col in df_batch.columns:
# Parse the polars dtype string back to a pl.DataType
_base = ref_dtype_str.split('(')[0].strip()
_ref_dtype = {'Float64': pl.Float64, 'Float32': pl.Float32,
'Int64': pl.Int64, 'Int32': pl.Int32,
'Utf8': pl.Utf8, 'String': pl.Utf8,
'Boolean': pl.Boolean, 'Bool': pl.Boolean}.get(_base)
if _ref_dtype and df_batch[col].dtype != _ref_dtype:
try:
casts[col] = pl.col(col).cast(_ref_dtype)
except Exception:
# If cast fails (e.g. "A" → Int64), force to Utf8
casts[col] = pl.col(col).cast(pl.Utf8)
if casts:
df_batch = df_batch.with_columns(list(casts.values()))
mode = 'replace' if not table_created else 'append'
save_to_db(run.id, df_batch.lazy(), mode=mode)
table_created = True
@@ -499,7 +521,7 @@ def _background_process(run_id, upload_dir):
run.total_flows = total_rows
run.save(update_fields=['sqlite_table', 'total_flows'])
ds_id = f'upload_{run_id}'
ds_id = f'upload_{run.display_id}'
store.store_dataset(ds_id, lf, schema=schema, metadata={
'row_count': total_rows, 'file_count': total_files, 'upload_dir': str(upload_dir),
'csv_glob': str(upload_dir / '*.csv'),
@@ -825,10 +847,15 @@ def run_analysis(request):
store = SessionStore()
try:
upload_ds_id = f'upload_{pk}'
# Use display_id for dataset key (consistent with _background_process)
display_id = ctx.get('display_id', pk)
upload_ds_id = f'upload_{display_id}'
entry = store.get_dataset(upload_ds_id)
if entry is None:
# Fallback: try entity dataset (from old pipeline)
# Fallback: try PK-based key (old pipeline)
entry = store.get_dataset(f'upload_{pk}')
if entry is None:
# Fallback: try entity dataset (from older pipeline)
entry = store.get_dataset(f'entity_{pk}')
if entry is None:
run.error_message = '请先上传并等待预处理完成'
@@ -836,7 +863,7 @@ def run_analysis(request):
run.save(update_fields=['status', 'error_message'])
return
ds_id = upload_ds_id if store.get_dataset(upload_ds_id) else f'entity_{pk}'
ds_id = upload_ds_id if store.get_dataset(upload_ds_id) else f'upload_{pk}'
# Use the unified clustering pipeline (clustering → extraction → PCA)
from analysis.views import _run_clustering_pipeline
@@ -859,7 +886,8 @@ def run_analysis(request):
target=_run_pipeline_worker,
args=(run_id, _analysis_fn),
kwargs=dict(feature_columns=feature_columns, algorithm=algorithm,
min_cluster_size=min_cluster_size, head=head, pk=pk),
min_cluster_size=min_cluster_size, head=head, pk=pk,
display_id=run.display_id),
daemon=True,
)
t.start()
@@ -887,8 +915,13 @@ def run_llm_analysis_view(request):
from analysis.session_store import SessionStore
store = SessionStore()
upload_ds_id = f'upload_{pk}'
# Use display_id for dataset key (consistent with _background_process)
upload_ds_id = f'upload_{display_id}'
entry = store.get_dataset(upload_ds_id)
if entry is None:
# Fallback: try PK-based key (old pipeline)
upload_ds_id = f'upload_{pk}'
entry = store.get_dataset(upload_ds_id)
if entry is None:
# Try entity dataset as fallback
@@ -922,8 +955,13 @@ def run_llm_analysis_view(request):
else:
# Real tool call — log it
if result:
summary = str(result)
run.run_log += f'[{step}] {tool_name}: {summary}\n'
if isinstance(result, dict):
# Format dict as JSON, truncate large values
import json as _json
summary = _json.dumps(result, default=str, ensure_ascii=False)[:500]
else:
summary = str(result)[:500]
run.run_log += f'[{step}] {tool_name}: {summary}...\n'
else:
run.run_log += f'[{step}] {tool_name}\n'
+4 -4
View File
@@ -23,10 +23,10 @@ anomaly_detection:
suspicious: 0.95
watch: 0.80
llm:
enabled: false
base_url: ''
api_key: ''
model: gpt-4
enabled: true
base_url: 'https://api.deepseek.com'
api_key: 'sk-ecd33e5341ff4ec9a2c5a5c97a97699b'
model: deepseek-v4-flash
entity:
subnet_masks: [24, 28]
ip_columns: ["src_ip", "dst_ip", ":ips", ":ipd", "server-ip", "client-ip"]
-41
View File
@@ -1,41 +0,0 @@
# start_ip,end_ip,lat,lon,city,country
# === North America ===
1.0.0.0,1.0.0.255,37.386,-122.083,Mountain View,US
8.8.8.0,8.8.8.255,37.386,-122.083,Mountain View,US
72.14.192.0,72.14.255.255,40.7128,-74.0060,New York,US
74.125.0.0,74.125.255.255,40.7128,-74.0060,New York,US
66.102.0.0,66.102.255.255,34.0522,-118.2437,Los Angeles,US
64.0.0.0,64.255.255.255,43.6532,-79.3832,Toronto,CA
# === East Asia ===
1.2.4.0,1.2.4.255,39.9042,116.4074,Beijing,CN
123.125.114.0,123.125.114.255,39.9042,116.4074,Beijing,CN
101.95.0.0,101.95.255.255,31.2304,121.4737,Shanghai,CN
58.0.0.0,58.255.255.255,22.3193,114.1694,Hong Kong,HK
14.0.0.0,14.255.255.255,22.3193,114.1694,Hong Kong,HK
1.160.0.0,1.160.255.255,25.0330,121.5654,Taipei,TW
101.0.0.0,101.0.255.255,25.0330,121.5654,Taipei,TW
27.0.0.0,27.0.255.255,35.6762,139.6503,Tokyo,JP
210.130.0.0,210.130.255.255,35.6762,139.6503,Tokyo,JP
110.0.0.0,110.0.255.255,37.5665,126.9780,Seoul,KR
121.78.0.0,121.78.255.255,37.5665,126.9780,Seoul,KR
# === Southeast Asia ===
116.14.0.0,116.14.255.255,1.3521,103.8198,Singapore,SG
58.8.0.0,58.8.255.255,13.7563,100.5018,Bangkok,TH
# === South Asia ===
14.96.0.0,14.96.255.255,19.0760,72.8777,Mumbai,IN
# === Oceania ===
1.40.0.0,1.40.255.255,-33.8688,151.2093,Sydney,AU
149.135.0.0,149.135.255.255,-33.8688,151.2093,Sydney,AU
# === Europe ===
5.10.0.0,5.10.255.255,51.5074,-0.1278,London,GB
81.2.0.0,81.2.255.255,51.5074,-0.1278,London,GB
90.0.0.0,90.0.255.255,48.8566,2.3522,Paris,FR
46.0.0.0,46.0.255.255,50.1109,8.6821,Frankfurt,DE
145.0.0.0,145.0.255.255,52.3676,4.9041,Amsterdam,NL
84.203.0.0,84.203.255.255,53.3498,-6.2603,Dublin,IE
78.0.0.0,78.0.255.255,59.3293,18.0686,Stockholm,SE
95.0.0.0,95.0.255.255,55.7558,37.6173,Moscow,RU
# === Middle East ===
91.72.0.0,91.72.255.255,25.2048,55.2708,Dubai,AE
# === South America ===
177.0.0.0,177.0.255.255,-23.5505,-46.6333,Sao Paulo,BR
+42
View File
@@ -0,0 +1,42 @@
import os, sys, django, urllib.request, glob, uuid, json, time
os.environ['DJANGO_SETTINGS_MODULE'] = 'tianxuan.settings'
sys.path.insert(0, r'C:\Users\25044\Desktop\Proj\天璇')
import django; django.setup()
from analysis.models import AnalysisRun
AnalysisRun.objects.all().delete()
print('Cleaned old runs')
BASE = 'http://127.0.0.1:18766'
data_dir = r'C:\Users\25044\Desktop\Proj\天璇\data\multi_upload'
files = sorted(glob.glob(os.path.join(data_dir, '*.csv')))
boundary = uuid.uuid4().hex
lines = []
for fp in files:
with open(fp, 'rb') as f: d = f.read()
fname = os.path.basename(fp)
lines.append(f'--{boundary}'.encode())
lines.append(f'Content-Disposition: form-data; name="files"; filename="{fname}"'.encode())
lines.append(b'Content-Type: text/csv'); lines.append(b''); lines.append(d)
lines.append(f'--{boundary}--'.encode())
req = urllib.request.Request(f'{BASE}/upload/csv/', data=b'\r\n'.join(lines),
headers={'Content-Type': f'multipart/form-data; boundary={boundary}'})
r = urllib.request.urlopen(req, timeout=300)
resp = json.loads(r.read()); r.close()
print(f'Uploaded run_id={resp["run_id"]}')
run_id = resp['run_id']
for i in range(120):
time.sleep(2)
try:
req2 = urllib.request.Request(f'{BASE}/runs/{run_id}/status/')
r2 = urllib.request.urlopen(req2, timeout=10)
status_resp = json.loads(r2.read()); r2.close()
status = status_resp.get('status', status_resp.get('state', ''))
print(f' attempt {i+1}: status={status}')
if status == 'ready':
print(f'Run {run_id} is ready!')
break
except Exception as e:
print(f' attempt {i+1}: error={e}')
else:
print('Timed out waiting for ready status')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "tls-analyzer"
version = "0.1.0"
version = "1.1.2"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
+37
View File
@@ -0,0 +1,37 @@
"""Generate 1998 CSV files (0.csv..1997.csv) with realistic TLS data matching TlsDB.csv.
Usage: runtime\python\python.exe scripts\gen_1998_files.py [--rows-per-file N] [--output-dir DIR]
"""
import random, csv, argparse
from pathlib import Path
from datetime import datetime, timedelta
from gen_test_data import generate_row, COLUMNS as columns
random.seed(42)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--rows-per-file', type=int, default=5, help='Rows per CSV file')
parser.add_argument('--output-dir', type=str, default='data/multi_upload')
args = parser.parse_args()
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
total = 1998
base_time = datetime(2026, 6, 1, 0, 0, 0)
for i in range(total):
filepath = out_dir / f'{i}.csv'
with open(filepath, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=columns)
writer.writeheader()
for j in range(args.rows_per_file):
row_time = base_time + timedelta(seconds=random.randint(0, 86400 * 30))
row = generate_row(i * args.rows_per_file + j, row_time)
writer.writerow(row)
print(f'Generated {total} files × {args.rows_per_file} rows = {total * args.rows_per_file} total rows')
print(f'Output: {out_dir.resolve()}')
if __name__ == '__main__':
main()
+180 -140
View File
@@ -1,8 +1,11 @@
"""Generate synthetic TLS flow test data.
"""Generate synthetic TLS flow test data matching TlsDB.csv column spec.
Coverage rates simulate real TLS traffic: IP/port always present,
lat/lon/ISP often blank, TLS fingerprint fields sometimes blank,
obscure fields rarely present.
Usage:
runtime\\python\\python.exe scripts\\gen_test_data.py [--rows N] [--output PATH]
runtime\\python\\python.exe scripts\\gen_test_data.py --globe [--rows N] [--output PATH]
runtime\\python\\python.exe scripts\\gen_test_data.py [--rows N]
"""
import random
import csv
@@ -10,175 +13,212 @@ import argparse
from pathlib import Path
from datetime import datetime, timedelta
# TLS version hex values
TLS_VERSIONS = ['03 03', '03 04', '02 00', '03 01', '03 02']
TLS_WEIGHTS = [0.60, 0.25, 0.05, 0.05, 0.05]
random.seed(42)
# Cipher suites (hex)
CIPHER_SUITES = ['c0 2b', 'c0 2f', '13 01', '13 02', 'c0 2c', 'cc a9', 'c0 23']
CIPHER_NAMES = [
'TLS_ECDHE_ECDSA_AES128_GCM_SHA256',
'TLS_ECDHE_RSA_AES128_GCM_SHA256',
'TLS_AES_128_GCM_SHA256',
'TLS_AES_256_GCM_SHA384',
'TLS_ECDHE_ECDSA_AES256_GCM_SHA384',
'TLS_ECDHE_ECDSA_CHACHA20_POLY1305',
'TLS_ECDHE_RSA_AES256_GCM_SHA384',
# All columns from TlsDB.csv in realistic order
COLUMNS = [
':ips', ':ipd', ':prs', ':prd', 'scnt', 'dcnt',
':ips.latd', ':ips.lond', ':ipd.latd', ':ipd.lond',
':ips.ispn', ':ipd.ispn', ':ips.orgn', ':ipd.orgn',
':ips.city', ':ipd.city', ':ips.anon', ':ipd.anon',
'server-ip', 'client-ip', 'row', 'time', 'timestamp',
'1ipp', '4dbn', 'tabl', '4ksz', 'cnrs', 'isrs',
'cnam', '0ver', 'snam', '4dur', '8seq', '2tmo', 'name',
'source-node', 'cipher-suite', 'ecdhe-named-curve',
'0cph', '0crv', '0rnd', '0rnt',
'8ack', '8pak', '8did', '4srs', '8ppk', '8ses', '8byt', '8dbd',
'crcc', 'orga', 'orgu', 'eiph', '@iph',
]
# ECDHE named curves (hex)
NAMED_CURVES = ['00 1d', '00 17', '00 18', '00 19', 'x448']
CURVE_NAMES = ['secp256r1', 'secp384r1', 'secp521r1', 'x25519', 'x448']
# ── Data pools ───────────────────────────────────────────────────────────
# OCph (cipher suite ID)
OCPH_VALS = ['c0 2b', 'c0 2f', '13 01', '13 02', 'c0 2c', 'cc a9']
TLS_VERSIONS = ['03 03', '03 04', '02 00', '03 01', '03 02']
TLS_WEIGHTS = [0.55, 0.30, 0.05, 0.05, 0.05]
# OCrv (named curve ID)
OCRV_VALS = ['00 1d', '00 17', '00 18', '00 19', '00 1e']
CIPHER_HEX = ['c0 2b', 'c0 2f', '13 01', '13 02', 'c0 2c', 'cc a9', 'c0 23',
'c0 27', 'c0 13', 'c0 14', '00 9e', '00 9f', '00 35']
CIPHER_NAMES = [
'TLS_ECDHE_ECDSA_AES128_GCM_SHA256', 'TLS_ECDHE_RSA_AES128_GCM_SHA256',
'TLS_AES_128_GCM_SHA256', 'TLS_AES_256_GCM_SHA384',
'TLS_ECDHE_ECDSA_AES256_GCM_SHA384', 'TLS_ECDHE_ECDSA_CHACHA20_POLY1305',
'TLS_ECDHE_RSA_AES256_GCM_SHA384', 'TLS_ECDHE_RSA_AES128_SHA256',
'TLS_ECDHE_ECDSA_AES128_SHA', 'TLS_ECDHE_RSA_AES128_SHA',
'TLS_RSA_WITH_AES_128_GCM_SHA256', 'TLS_RSA_WITH_AES_256_GCM_SHA384',
'TLS_RSA_WITH_AES_128_CBC_SHA',
]
# Random bytes for 0rnd, 0rnt
def _rand_hex(n=32):
NAMED_CURVES_HEX = ['00 1d', '00 17', '00 18', '00 19', '00 1e']
NAMED_CURVES_TEXT = ['secp256r1', 'secp384r1', 'secp521r1', 'x25519', 'x448']
SRC_IPS = [f'{random.randint(1,223)}.{random.randint(0,255)}.{random.randint(0,255)}.{random.randint(1,254)}' for _ in range(200)]
DST_IPS = [f'{random.randint(1,223)}.{random.randint(0,255)}.{random.randint(0,255)}.{random.randint(1,254)}' for _ in range(500)]
PORTS = [443, 80, 8080, 8443, 465, 993, 995, 53, 22, 3389, 25, 110, 143, 21, 12345, 50000, 443, 443, 443, 443]
COUNTRIES = ['US','CN','KR','JP','GB','DE','FR','RU','BR','IN','SG','NL','CA','AU','HK','UA','IL','SE','NO','FI']
ISPS = ['China Telecom','China Mobile','BT Group','Orange','Deutsche Telekom','AT&T','Verizon','Comcast','NTT','KDDI','SK Telecom','Singtel','Telstra']
ORGS = ['Baidu Inc.','Alibaba Inc.','Amazon.com Inc.','Google LLC','Microsoft Corp.','Meta Platforms','Tencent','Samsung','Apple Inc.','Netflix Inc.']
CITIES = ['Beijing','Shanghai','Seoul','Berlin','Paris','London','New York','San Francisco','Tokyo','Singapore','Sydney','Moscow','Seattle','Dublin','Mumbai']
SERVICES = ['mail.example.com','api.example.com','cdn.example.com','auth.example.net','stream.example.org','login.live.com','*.cloudfront.net','*.s3.amazonaws.com','graph.facebook.com','www.google.com']
SRC_NODES = ['packet_capture','ssl_logs','netflow','zeek','suricata']
NODE_NAMES = ['wan-link','core-02','gw-09','edge-01','backbone-03']
def _rand_ip(pool):
return random.choice(pool)
def _rand_hex(n):
return ' '.join(f'{random.randint(0,255):02x}' for _ in range(n))
# Country pools
SRC_COUNTRIES = ['US', 'CN', 'KR', 'JP', 'GB', 'DE', 'FR', 'RU', 'BR', 'IN', 'SG', 'NL', 'CA', 'AU', 'HK']
DST_COUNTRIES = ['US', 'CN', 'KR', 'JP', 'GB', 'DE', 'FR', 'RU', 'BR', 'IN', 'SG', 'NL', 'CA', 'AU', 'HK']
def _maybe(rate):
"""Return True with given probability (0.0-1.0)."""
return random.random() < rate
# IP pools
def _rand_ip():
return f'{random.randint(1,223)}.{random.randint(0,255)}.{random.randint(0,255)}.{random.randint(1,254)}'
def _blank_or(rate, value, blank_marker=''):
"""Return value with rate probability, else blank_marker."""
return value if _maybe(rate) else blank_marker
# Server names
SERVICES = [
'service36.example.com', 'mail.example.com', 'api.example.com',
'cdn.example.com', 'dns.example.com', 'auth.example.net',
'stream.example.org', 'web.example.io', 'storage.example.co',
'cdn.cloudflare.com', 'api.github.com', 'login.live.com',
]
# Source nodes
SRC_NODES = ['packet_capture', 'ssl_logs', 'netflow', 'zeek', 'suricata']
NODE_NAMES = ['wan-link', 'core-02', 'gw-09', 'edge-01', 'backbone-03']
NODE_TYPES = ['gw-09', 'core-02', 'wan-link', 'edge-01', 'backbone-03']
def _weighted_bool(true_weight=0.7):
return random.random() < true_weight
def _blank_or_plus(rate, value):
"""Return value with rate probability, else '+' (null marker).
Signature matches _blank_or(rate, value, blank_marker=...)."""
return value if _maybe(rate) else '+'
def generate_row(row_id, base_time):
"""Generate a single TLS flow row."""
src_ip = _rand_ip()
dst_ip = _rand_ip()
src_port = random.choice([443, 465, 993, 995, 8443, 8080, 53, 80, 22, 12345])
dst_port = random.choice([443, 465, 993, 995, 8443, 53, 80, 22, 8080])
src_country = random.choice(SRC_COUNTRIES)
dst_country = random.choice(DST_COUNTRIES)
server_ip = dst_ip if random.random() > 0.1 else _rand_ip()
client_ip = src_ip if random.random() > 0.1 else _rand_ip()
"""Generate a single TLS flow row matching all TlsDB.csv columns."""
src_ip = _rand_ip(SRC_IPS)
dst_ip = _rand_ip(DST_IPS)
src_port = random.choice(PORTS)
dst_port = random.choice(PORTS)
src_country = random.choice(COUNTRIES)
dst_country = random.choice(COUNTRIES)
server_ip = _rand_ip(DST_IPS + SRC_IPS)
client_ip = src_ip if _maybe(0.85) else _rand_ip(SRC_IPS)
tls_ver = random.choices(TLS_VERSIONS, weights=TLS_WEIGHTS, k=1)[0]
cipher_hex = random.choice(CIPHER_HEX)
cipher_name = random.choice(CIPHER_NAMES)
curve_hex = random.choice(NAMED_CURVES_HEX)
curve_name = random.choice(NAMED_CURVES_TEXT)
service = random.choice(SERVICES)
duration = round(random.uniform(0.5, 120.0), 2)
sessions = round(random.uniform(1, 2000), 4)
tmo = round(random.uniform(0, 1000), 4)
ks = random.choice([128, 256, 384, 512, 1024, 2048, 4096])
cnrs = 'True' if _weighted_bool(0.65) else ''
isrs = 'True' if _weighted_bool(0.60) else ''
ack = random.randint(100, 200000)
ppk = random.randint(1, 500)
dbd = random.randint(1, 200)
ipp = random.randint(1, 50)
dbn = random.randint(1, 100)
tabl = random.choice(['packet_capture', 'ssl_logs', 'netflow'])
name = random.choice(['wan-link', 'core-02', 'gw-09'])
src_node = random.choice(SRC_NODES)
node_name = random.choice(NODE_NAMES)
node_type = random.choice(NODE_TYPES)
cipher_idx = random.randint(0, len(CIPHER_SUITES) - 1)
curve_idx = random.randint(0, len(NAMED_CURVES) - 1)
ocph = OCPH_VALS[random.randint(0, len(OCPH_VALS) - 1)]
ocrv = OCRV_VALS[random.randint(0, len(OCRV_VALS) - 1)]
rand_hex_1 = _rand_hex(32)
rand_hex_2 = _rand_hex(32)
cert_cn = service if _maybe(0.7) else f'*.{random.choice(SERVICES).split(".")[-2:]}'
ts = int(base_time.timestamp()) + row_id
time_str = base_time.strftime('%H:%M:%S')
date_str = base_time.strftime('%Y-%m-%d')
# Random lat/lon for GeoIP
# Row-local lat/lon with realistic blank rates
src_lat = round(random.uniform(-60, 60), 6)
src_lon = round(random.uniform(-180, 180), 6)
dst_lat = round(random.uniform(-60, 60), 6)
dst_lon = round(random.uniform(-180, 180), 6)
isps = ['China Telecom', 'China Mobile', 'BT Group', 'Orange', 'Deutsche Telekom',
'AT&T', 'Verizon', 'Comcast', 'NTT', 'KDDI']
orgs = ['Baidu Inc.', 'Alibaba Inc.', 'Amazon.com Inc.', 'Google LLC',
'Microsoft Corp.', 'Meta Platforms', 'Tencent', 'Samsung']
cities = ['Beijing', 'Shanghai', 'Seoul', 'Berlin', 'Paris', 'London',
'New York', 'San Francisco', 'Tokyo', 'Singapore', 'Sydney', 'Moscow']
return {
# ── Core (always present) ──
':ips': src_ip,
':ipd': dst_ip,
':prs': src_port,
':prd': dst_port,
'time': base_time.strftime('%H:%M:%S'),
'timestamp': ts,
# ── TLS fingerprint (very high coverage) ──
'0ver': tls_ver,
'0cph': cipher_hex,
'cipher-suite': _blank_or(0.85, cipher_name),
'ecdhe-named-curve': _blank_or(0.80, curve_name),
'0crv': _blank_or(0.80, curve_hex),
'0rnd': _blank_or(0.75, _rand_hex(28)),
'0rnt': _blank_or(0.70, _rand_hex(4)),
'4ksz': _blank_or(0.85, str(random.choice([128, 256, 384, 512]))),
# ── Session / connection info (medium-high coverage) ──
'snam': _blank_or(0.80, service),
'cnam': _blank_or(0.70, cert_cn),
'server-ip': _blank_or(0.60, server_ip),
'client-ip': _blank_or(0.85, client_ip),
'4dur': _blank_or(0.75, str(round(random.uniform(0.5, 120.0), 2))),
'8ack': _blank_or(0.80, str(random.randint(100, 200000))),
'8pak': _blank_or(0.70, str(random.randint(1, 2000))),
'8ppk': _blank_or(0.65, str(random.randint(1, 500))),
'8seq': _blank_or(0.40, str(round(random.uniform(1, 2000), 4))),
'8ses': _blank_or(0.50, str(round(random.uniform(1, 2000), 4))),
'8byt': _blank_or(0.40, str(random.randint(200, 500000))),
'2tmo': _blank_or(0.50, str(round(random.uniform(0, 1000), 4))),
# ── TCP / network info (medium coverage) ──
'1ipp': str(random.randint(1, 50)),
'row': _blank_or(1.0, ''), # TLS协议含义
'tabl': random.choice(['TlsC','TlsS']), # 100% coverage
'name': _blank_or(0.30, random.choice(NODE_NAMES)),
'source-node': _blank_or(0.30, random.choice(SRC_NODES)),
# ── Session resumption (medium coverage) ──
'cnrs': _blank_or(0.55, '+'),
'isrs': _blank_or(0.50, '+'),
# ── Database metadata (low-medium coverage) ──
'4dbn': _blank_or(0.40, str(random.randint(1, 100))),
'8dbd': _blank_or(0.25, str(random.randint(1, 200))),
'8did': _blank_or(0.15, str(random.randint(1000, 9999))),
'4srs': _blank_or(0.20, str(random.randint(10000, 99999))),
# ── GeoIP location (rare, ~1% have data, rest '+' = null) ──
':ips.latd': _blank_or_plus(0.99, str(src_lat)), # 99% real value, ~1% '+' null
':ips.lond': _blank_or_plus(0.99, str(src_lon)),
':ipd.latd': _blank_or_plus(0.99, str(dst_lat)),
':ipd.lond': _blank_or_plus(0.99, str(dst_lon)),
':ips.ispn': _blank_or(0.20, random.choice(ISPS)),
':ipd.ispn': _blank_or(0.20, random.choice(ISPS)),
':ips.orgn': _blank_or(0.15, random.choice(ORGS)),
':ipd.orgn': _blank_or(0.15, random.choice(ORGS)),
':ips.city': _blank_or(0.25, random.choice(CITIES)),
':ipd.city': _blank_or(0.25, random.choice(CITIES)),
':ips.anon': _blank_or(0.05, 'anon, hosting'), # single combined string
':ipd.anon': _blank_or(0.05, 'anon, hosting'),
# ── Two-letter abbreviation columns (rare, ~5%) ──
'scnt': _blank_or(0.05, src_country),
'dcnt': _blank_or(0.05, dst_country),
'crcc': _blank_or(0.05, random.choice(['OK','ER','--'])),
'@iph': _blank_or(0.001, random.choice(['A','B','C','D'])), # 1/1000
'eiph': _blank_or(0.05, '+'), # boolean: + = True, blank = False
# ── Obscure / rarely populated fields ──
'orga': _blank_or(0.08, random.choice(ORGS)),
'orgu': _blank_or(0.08, str(random.randint(1000, 99999))),
# ── TLS protocol-level (always present) ──
}
return [
src_ip, dst_ip, src_port, dst_port,
src_country, dst_country,
server_ip, client_ip,
tls_ver, service, service,
duration, sessions, tmo, ks,
cnrs, isrs,
ack, ppk, dbd,
ipp, dbn,
tabl, name, src_node,
node_name, node_type,
CIPHER_NAMES[cipher_idx], CURVE_NAMES[curve_idx],
ocph, ocrv,
rand_hex_1, rand_hex_2,
row_id + 1, time_str,
ts,
src_lat, src_lon,
dst_lat, dst_lon,
random.choice(isps), random.choice(isps),
random.choice(orgs), random.choice(orgs),
random.choice(cities), random.choice(cities),
]
def main():
parser = argparse.ArgumentParser(description='Generate TLS flow test data')
parser.add_argument('--rows', type=int, default=1000, help='Number of rows to generate')
parser.add_argument('--output', type=str, default='data/test_flows.csv', help='Output CSV path')
parser.add_argument('--globe', action='store_true', help='Use GeoIP-compatible IP ranges')
parser = argparse.ArgumentParser(description='Generate TLS flow test data matching TlsDB.csv')
parser.add_argument('--rows', type=int, default=10000, help='Number of rows')
parser.add_argument('--output', type=str, default='data/test_flows.csv')
args = parser.parse_args()
columns = [
':ips', ':ipd', ':prs', ':prd',
'scnt', 'dcnt', 'server-ip', 'client-ip',
'0ver', 'snam', 'cnam',
'4dur', '8ses', '2tmo', '4ksz',
'cnrs', 'isrs',
'8ack', '8ppk', '8dbd',
'1ipp', '4dbn',
'tabl', 'name', 'source-node',
'cnam', 'snam',
'cipher-suite', 'ecdhe-named-curve',
'0cph', '0crv',
'0rnd', '0rnt',
'row', 'time', 'timestamp',
':ips.latd', ':ips.lond',
':ipd.latd', ':ipd.lond',
':ips.ispn', ':ipd.ispn',
':ips.orgn', ':ipd.orgn',
':ips.city', ':ipd.city',
]
columns = COLUMNS
base_time = datetime(2024, 1, 5, 12, 0, 0)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
base_time = datetime(2026, 6, 1, 0, 0, 0)
blank_counts = {col: 0 for col in columns}
total_rows = args.rows
with open(output_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(columns)
for i in range(args.rows):
row_time = base_time + timedelta(seconds=random.randint(0, 86400 * 7))
writer.writerow(generate_row(i, row_time))
writer = csv.DictWriter(f, fieldnames=columns)
writer.writeheader()
for i in range(total_rows):
row_time = base_time + timedelta(seconds=random.randint(0, 86400 * 30))
row = generate_row(i, row_time)
writer.writerow(row)
for col in columns:
if row.get(col, '') == '' or row.get(col, '') == '+':
blank_counts[col] += 1
print(f'Generated {total_rows} rows → {output_path}')
print('\nCoverage report (blank/null rate per column):')
for col in columns:
rate = blank_counts[col] / total_rows * 100
bar_len = int((100 - rate) / 5)
bar = '#' * bar_len + '.' * (20 - bar_len)
print(f' {col:20s} {bar} {100-rate:5.1f}% present')
print(f'Generated {args.rows} rows → {output_path}')
if __name__ == '__main__':
main()
+106 -103
View File
@@ -142,102 +142,135 @@
<div id="autoBar" style="background:#4361ee;height:100%;width:0%;"></div>
</div>
<!-- Thinking panel -->
<div id="thinkingPanel" class="thinking-panel">
<div class="label">🧠 LLM 思考 <span id="thinkingStep"></span></div>
<div id="thinkingContent"></div>
</div>
<button id="cancelBtn" class="btn" style="background:#f72585;color:white;display:none;" onclick="cancelAnalysis()">取消分析</button>
<div id="liveLog" style="background:#1a1a2e;color:#e0e0e0;padding:1rem;border-radius:6px;margin-top:1rem;max-height:300px;overflow:auto;font-size:0.8rem;display:none;"></div>
<!-- Tool call accordion -->
<div id="toolCallsPanel" style="display:none;margin-top:1rem;">
<h4 style="margin-bottom:0.6rem;color:#333;">🔧 工具调用序列</h4>
<div id="toolCallsList"></div>
<!-- Unified timeline: interleaved thinking + tool calls -->
<div id="timelinePanel" style="display:none;margin-top:1rem;">
<h4 style="margin-bottom:0.6rem;color:#333;">📋 分析步骤</h4>
<div id="timeline"></div>
</div>
</div>
</div>
<script>
let statusPoll = null;
let logPoll = null;
let renderedToolCallCount = 0;
function toggleToolCall(el) {
function escapeHtml(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function prettyJson(obj) {
try { return JSON.stringify(obj, null, 2); }
catch(e) { return String(obj); }
}
function toggleStep(el) {
el.classList.toggle('open');
const body = el.nextElementSibling;
if (body) body.classList.toggle('open');
}
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
function formatTimestamp() {
return new Date().toLocaleTimeString('zh-Hans', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
}
function prettyJson(obj) {
try {
return JSON.stringify(obj, null, 2);
} catch (e) {
return String(obj);
function buildTimeline(llmThinking, toolCalls) {
// Parse llm_thinking into [{step, text}] entries
const thoughtEntries = [];
if (llmThinking) {
const parts = llmThinking.split(/\n(?=\[\d+\])/);
for (const part of parts) {
const m = part.match(/^\[(\d+)\]\s*(.*)/s);
if (m) {
thoughtEntries.push({ step: parseInt(m[1]), type: 'thought', text: m[2].trim() });
}
}
}
// tool_calls already have step, type, name, input, output
const toolEntries = (toolCalls || []).map(tc => ({
step: tc.step,
type: 'tool',
name: tc.name,
input: tc.input,
output: tc.output,
}));
// Merge and sort by step
const merged = [...thoughtEntries, ...toolEntries].sort((a, b) => a.step - b.step);
return merged;
}
function renderToolCalls(toolCalls) {
const list = document.getElementById('toolCallsList');
if (!toolCalls || !Array.isArray(toolCalls)) return;
function renderTimeline(entries) {
const container = document.getElementById('timeline');
const panel = document.getElementById('timelinePanel');
if (!entries || entries.length === 0) return;
// Only render new items
for (let i = renderedToolCallCount; i < toolCalls.length; i++) {
const tc = toolCalls[i];
const div = document.createElement('div');
div.className = 'tool-call-item';
panel.style.display = 'block';
// Clear and re-render entire timeline each poll
container.innerHTML = '';
let lastStep = -1;
const inputStr = prettyJson(tc.input);
const outputStr = prettyJson(tc.output);
for (const entry of entries) {
// Step header
if (entry.step !== lastStep) {
const header = document.createElement('div');
header.style.cssText = 'font-weight:700;font-size:0.85rem;color:#4361ee;padding:0.6rem 0 0.3rem 0;border-top:1px solid #eee;margin-top:0.4rem;';
header.textContent = `步骤 ${entry.step}`;
container.appendChild(header);
lastStep = entry.step;
}
div.innerHTML =
'<div class="tool-call-header" onclick="toggleToolCall(this)">' +
'<span><span class="step-badge">' + (tc.step != null ? tc.step : '?') + '</span>' +
'<span class="tool-name">' + escapeHtml(tc.name || '?') + '</span></span>' +
'<span class="arrow"></span>' +
'</div>' +
'<div class="tool-call-body">' +
'<div class="section-title">输入 (INPUT):</div>' +
'<pre><code>' + escapeHtml(inputStr) + '</code></pre>' +
'<div class="section-title">输出 (OUTPUT):</div>' +
'<pre><code>' + escapeHtml(outputStr) + '</code></pre>' +
'</div>';
list.appendChild(div);
if (entry.type === 'thought') {
// LLM reasoning — collapsible
const card = document.createElement('div');
card.style.cssText = 'border:1px solid #d0d8f0;border-radius:6px;margin-bottom:0.3rem;overflow:hidden;background:#f8f9ff;';
card.innerHTML =
'<div class="tool-call-header" onclick="toggleStep(this)" style="background:#eef2ff;padding:0.4rem 0.8rem;">' +
'<span><span style="background:#4361ee;color:#fff;border-radius:3px;padding:0 5px;font-size:0.7rem;font-weight:600;margin-right:6px;">💭</span>' +
'<span style="font-weight:600;font-size:0.8rem;">LLM 推理</span></span>' +
'<span class="arrow"></span>' +
'</div>' +
'<div class="tool-call-body" style="padding:0.6rem 0.8rem;font-size:0.78rem;line-height:1.6;color:#333;background:#fff;">' +
escapeHtml(entry.text) +
'</div>';
container.appendChild(card);
} else if (entry.type === 'tool') {
// Tool call — collapsible with I/O
const card = document.createElement('div');
card.style.cssText = 'border:1px solid #e0e0e0;border-radius:6px;margin-bottom:0.3rem;overflow:hidden;background:#fff;';
const inputStr = prettyJson(entry.input);
const outputStr = prettyJson(entry.output);
card.innerHTML =
'<div class="tool-call-header" onclick="toggleStep(this)" style="padding:0.4rem 0.8rem;">' +
'<span><span style="background:#43aa8b;color:#fff;border-radius:3px;padding:0 5px;font-size:0.7rem;font-weight:600;margin-right:6px;">🔧</span>' +
'<span style="font-weight:600;font-size:0.8rem;">' + escapeHtml(entry.name || '?') + '</span></span>' +
'<span class="arrow"></span>' +
'</div>' +
'<div class="tool-call-body" style="padding:0.5rem 0.8rem;background:#fafbfc;">' +
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">输入 (INPUT):</div>' +
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0 0 0.3rem 0;"><code>' + escapeHtml(inputStr) + '</code></pre>' +
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">输出 (OUTPUT):</div>' +
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0;"><code>' + escapeHtml(outputStr) + '</code></pre>' +
'</div>';
container.appendChild(card);
}
}
renderedToolCallCount = toolCalls.length;
}
async function startAuto() {
const selected = document.querySelector('input[name="auto_run_id"]:checked');
if (!selected) { showError('请先选择一个数据集'); return; }
const thinkingPanel = document.getElementById('thinkingPanel');
const thinkingContent = document.getElementById('thinkingContent');
const toolCallsPanel = document.getElementById('toolCallsPanel');
const toolCallsList = document.getElementById('toolCallsList');
const liveLog = document.getElementById('liveLog');
const timelineContainer = document.getElementById('timeline');
const timelinePanel = document.getElementById('timelinePanel');
// Reset UI
document.getElementById('autoProgress').style.display = 'block';
document.getElementById('cancelBtn').style.display = 'inline-block';
liveLog.style.display = 'block';
liveLog.innerHTML = '';
thinkingPanel.style.display = 'none';
thinkingContent.innerHTML = '';
toolCallsPanel.style.display = 'none';
toolCallsList.innerHTML = '';
renderedToolCallCount = 0;
timelinePanel.style.display = 'none';
timelineContainer.innerHTML = '';
let resp;
try {
@@ -250,6 +283,8 @@ async function startAuto() {
const data = await resp.json();
const runId = selected.value;
let lastTimelineHash = '';
statusPoll = setInterval(async () => {
try {
const r = await fetch(`/runs/${runId}/status/`);
@@ -259,67 +294,35 @@ async function startAuto() {
document.getElementById('autoStatus').textContent = `${msg} (${pct}%)`;
document.getElementById('autoBar').style.width = pct + '%';
// Update thinking panel
if (s.llm_thinking && s.llm_thinking.trim()) {
thinkingPanel.style.display = 'block';
thinkingContent.textContent = s.llm_thinking;
thinkingPanel.scrollTop = thinkingPanel.scrollHeight;
}
// Update tool calls accordion
if (s.tool_calls && Array.isArray(s.tool_calls) && s.tool_calls.length > 0) {
toolCallsPanel.style.display = 'block';
renderToolCalls(s.tool_calls);
}
// Update live log
if (s.run_log) {
liveLog.innerHTML = s.run_log.split('\n').map(l => l.trim()).filter(l => l).join('\n') + '\n';
liveLog.scrollTop = liveLog.scrollHeight;
// Build and render unified timeline
const entries = buildTimeline(s.llm_thinking, s.tool_calls);
const hash = JSON.stringify(entries.map(e => ({step:e.step,type:e.type,name:e.name,text:(e.text||'').slice(0,40)})));
if (hash !== lastTimelineHash) {
renderTimeline(entries);
lastTimelineHash = hash;
}
if (s.status === 'completed') {
clearInterval(statusPoll);
clearInterval(logPoll);
document.getElementById('autoBar').style.width = '100%';
document.getElementById('cancelBtn').style.display = 'none';
document.getElementById('autoStatus').textContent = '分析完成!';
}
if (s.status === 'failed') {
clearInterval(statusPoll);
clearInterval(logPoll);
document.getElementById('cancelBtn').style.display = 'none';
document.getElementById('autoStatus').textContent = '失败: ' + (s.error_message || '');
}
} catch (e) { showError('轮询状态失败: ' + e.message); }
}, 1000);
// Poll live logs every 3 seconds
let lastPos = 0;
logPoll = setInterval(async () => {
try {
const r = await fetch(`/logs/?pos=${lastPos}`);
const txt = await r.text();
const logDiv = document.getElementById('liveLog');
const parts = txt.split('\n', 2);
if (parts.length >= 2) {
const newPos = parseInt(parts[0], 10);
if (!isNaN(newPos)) { lastPos = newPos; }
if (parts[1]) {
logDiv.innerHTML += parts[1] + '\n';
logDiv.scrollTop = logDiv.scrollHeight;
}
}
} catch (e) { showError('轮询日志失败: ' + e.message); }
}, 3000);
}
function cancelAnalysis() {
if (statusPoll) { clearInterval(statusPoll); statusPoll = null; }
if (logPoll) { clearInterval(logPoll); logPoll = null; }
document.getElementById('autoStatus').textContent = '已取消';
document.getElementById('cancelBtn').style.display = 'none';
document.getElementById('liveLog').innerHTML += '\n--- 分析已取消 ---\n';
document.getElementById('timelinePanel').innerHTML += '\n--- 分析已取消 ---\n';
}
</script>
{% endblock %}
<!--VERSION2-->
+9 -11
View File
@@ -9,13 +9,14 @@
<p style="color:#666;margin-bottom:1rem;">支持 .csv 文件,多文件批量上传,自动解压 .zip</p>
<form id="uploadForm" enctype="multipart/form-data">
{% csrf_token %}
<div style="border:2px dashed #ccc;border-radius:8px;padding:3rem;text-align:center;cursor:pointer;background:#fafafa;" id="dropZone">
<div style="border:2px dashed #ccc;border-radius:8px;padding:2rem;text-align:center;background:#fafafa;" id="dropZone">
<div style="font-size:2rem;margin-bottom:0.5rem;">📂</div>
<p>拖拽 CSV 文件到此处,或点击选择</p>
<p style="margin-bottom:1rem;color:#666;">拖拽 CSV 文件到此处</p>
<input type="file" name="files" multiple accept=".csv,.zip" style="display:none;" id="fileInput">
<div style="margin-top:0.5rem;font-size:0.85rem;color:#888;">
<label style="cursor:pointer;color:#4361ee;text-decoration:underline;" id="dirSelectLabel">或选择整个目录</label>
<input type="file" webkitdirectory style="display:none;" id="dirInput">
<input type="file" directory webkitdirectory style="display:none;" id="dirInput">
<div style="display:flex;gap:1rem;justify-content:center;">
<button type="button" class="btn btn-primary" id="fileSelectBtn">📄 选择CSV文件</button>
<button type="button" class="btn" style="background:#43aa8b;color:#fff;" id="dirSelectBtn">📁 选择整个目录</button>
</div>
</div>
<div id="submitBar" style="margin-top:1rem;display:none;">
@@ -67,18 +68,15 @@ const submitBar = document.getElementById('submitBar');
const submitBtn = document.getElementById('submitBtn');
let selectedFiles = [];
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.style.borderColor = '#4361ee'; });
dropZone.addEventListener('dragleave', () => dropZone.style.borderColor = '#ccc');
dropZone.addEventListener('drop', e => { e.preventDefault(); handleFiles(e.dataTransfer.files); });
document.getElementById('fileSelectBtn').addEventListener('click', () => fileInput.click());
document.getElementById('dirSelectBtn').addEventListener('click', () => dirInput.click());
fileInput.addEventListener('change', () => handleFiles(fileInput.files));
document.getElementById('dirInput').addEventListener('change', function(e) {
dirInput.addEventListener('change', function(e) {
handleFiles(this.files);
});
document.getElementById('dirSelectLabel').addEventListener('click', function(e) {
e.stopPropagation();
document.getElementById('dirInput').click();
});
function handleFiles(files) {
selectedFiles = [...files];
View File
-367
View File
@@ -1,367 +0,0 @@
"""Tests for analysis.data_loader — BOM detection, schema validation, load/config."""
import csv
import os
import tempfile
from pathlib import Path
import pytest
import polars as pl
from analysis.data_loader import (
_file_count,
_merge_schema_entries,
_resolve_encoding,
detect_bom,
load_config,
load_csv_directory,
validate_schemas,
)
# ═══════════════════════════════════════════════════════════════════════
# BOM detection
# ═══════════════════════════════════════════════════════════════════════
class TestDetectBOM:
"""detect_bom() should return the correct encoding string for each BOM."""
def test_utf8_bom(self):
"""UTF-8 BOM (EF BB BF) → 'utf-8-sig'."""
path = _write_bytes(b'\xef\xbb\xbfcol1,col2\n1,2\n')
try:
assert detect_bom(path) == 'utf-8-sig'
finally:
os.unlink(path)
def test_utf16le_bom(self):
"""UTF-16 LE BOM (FF FE) → 'utf-16le'."""
path = _write_bytes(b'\xff\xfec\x00o\x00l\x001\x00,\x00c\x00o\x00l\x002\x00\n\x001\x00,\x002\x00\n')
try:
assert detect_bom(path) == 'utf-16le'
finally:
os.unlink(path)
def test_utf16be_bom(self):
"""UTF-16 BE BOM (FE FF) → 'utf-16be'."""
path = _write_bytes(b'\xfe\xff\x00c\x00o\x00l\x001\x00,\x00c\x00o\x00l\x002\x00\n\x001\x00,\x002\x00\n')
try:
assert detect_bom(path) == 'utf-16be'
finally:
os.unlink(path)
def test_no_bom(self):
"""Plain ASCII/UTF-8 without BOM → 'utf-8'."""
path = _write_bytes(b'col1,col2\n1,2\n')
try:
assert detect_bom(path) == 'utf-8'
finally:
os.unlink(path)
def test_empty_file(self):
"""Empty file → 'utf-8'."""
path = _write_bytes(b'')
try:
assert detect_bom(path) == 'utf-8'
finally:
os.unlink(path)
# ═══════════════════════════════════════════════════════════════════════
# Schema validation
# ═══════════════════════════════════════════════════════════════════════
class TestValidateSchemas:
"""validate_schemas() must raise ValueError when column sets differ."""
def test_matching_schemas(self):
"""Identical schemas → no exception."""
schemas = [
{':ips': 'Utf8', '8ack': 'Int64'},
{':ips': 'Utf8', '8ack': 'Int64'},
]
validate_schemas(schemas) # should not raise
def test_empty_list(self):
"""Empty list → no exception."""
validate_schemas([])
def test_schema_mismatch_missing_column(self):
"""One file missing a column → ValueError."""
schemas = [
{':ips': 'Utf8', '8ack': 'Int64'},
{':ips': 'Utf8'}, # missing '8ack'
]
with pytest.raises(ValueError, match=r'missing columns'):
validate_schemas(schemas)
def test_schema_mismatch_extra_column(self):
"""One file has an extra column → ValueError."""
schemas = [
{':ips': 'Utf8'},
{':ips': 'Utf8', 'extra_col': 'Int64'},
]
with pytest.raises(ValueError, match=r'extra columns'):
validate_schemas(schemas)
def test_schema_mismatch_both_sides(self):
"""Both missing and extra columns → ValueError with both messages."""
schemas = [
{'a': 'Int64', 'b': 'Utf8'},
{'a': 'Int64', 'c': 'Float64'},
]
with pytest.raises(ValueError):
validate_schemas(schemas)
class TestMergeSchemaEntries:
"""_merge_schema_entries() should merge without raising."""
def test_simple_merge(self):
merged, conflicts = _merge_schema_entries([
{'a': 'Int64', 'b': 'Utf8'},
{'a': 'Int64', 'c': 'Float64'},
])
assert set(merged.keys()) == {'a', 'b', 'c'}
assert merged['a'] == 'Int64'
def test_type_conflict(self):
"""Different dtypes for same column → last wins, conflict recorded."""
merged, conflicts = _merge_schema_entries([
{'val': 'Int64'},
{'val': 'Float64'},
])
assert merged['val'] == 'Float64'
assert 'val' in conflicts
# ═══════════════════════════════════════════════════════════════════════
# CSV loading
# ═══════════════════════════════════════════════════════════════════════
class TestLoadCSVDirectory:
"""End-to-end load_csv_directory() tests with temporary CSV files."""
def test_basic_load(self):
"""Single CSV → LazyFrame + correct schema + row/file counts."""
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / 'test.csv'
_write_csv(path, [
[':ips', ':ipd', '8ack'],
['10.0.0.1', '10.0.0.2', '100'],
['10.0.0.1', '10.0.0.3', '200'],
['10.0.0.2', '10.0.0.4', '150'],
])
lf, schema, row_count, file_count, memory_mb = load_csv_directory(
str(path), encoding='utf-8'
)
assert isinstance(lf, pl.LazyFrame)
assert ':ips' in schema
assert ':ipd' in schema
assert '8ack' in schema
assert file_count == 1
assert row_count > 0
assert memory_mb >= 0
# Verify data round-trip
df = lf.collect()
assert len(df) == 3
assert set(df[':ips'].to_list()) == {'10.0.0.1', '10.0.0.2'}
def test_multiple_files(self):
"""Multiple CSVs with same schema → merged LazyFrame."""
with tempfile.TemporaryDirectory() as tmpdir:
p1 = Path(tmpdir) / 'part1.csv'
p2 = Path(tmpdir) / 'part2.csv'
_write_csv(p1, [['x'], ['1'], ['2']])
_write_csv(p2, [['x'], ['3'], ['4']])
glob_pattern = str(Path(tmpdir) / 'part*.csv')
lf, schema, row_count, file_count, memory_mb = load_csv_directory(
glob_pattern, encoding='utf-8'
)
assert file_count == 2
df = lf.collect()
assert len(df) == 4
assert sorted(df['x'].to_list()) == [1, 2, 3, 4]
def test_no_files(self):
"""No matching files → FileNotFoundError."""
with pytest.raises(FileNotFoundError, match=r'No files match'):
load_csv_directory('__nonexistent_glarg_*.csv')
def test_schema_mismatch_across_files_strict(self):
"""Files with different column sets + schema_strict=True → ValueError."""
with tempfile.TemporaryDirectory() as tmpdir:
_write_csv(Path(tmpdir) / 'a.csv', [['x', 'y'], ['1', '2']])
_write_csv(Path(tmpdir) / 'b.csv', [['x'], ['3']])
with pytest.raises(ValueError, match=r'Schema mismatch'):
load_csv_directory(str(Path(tmpdir) / '*.csv'), schema_strict=True)
def test_schema_mismatch_across_files_lenient(self):
"""Files with different column sets + schema_strict=False → merge with nulls."""
with tempfile.TemporaryDirectory() as tmpdir:
_write_csv(Path(tmpdir) / 'a.csv', [['x', 'y'], ['1', '2']])
_write_csv(Path(tmpdir) / 'b.csv', [['x'], ['3']])
lf, schema, *_ = load_csv_directory(str(Path(tmpdir) / '*.csv'))
df = lf.collect()
assert len(df) == 2
assert 'x' in df.columns
assert 'y' in df.columns
# Row from b.csv should have null for 'y'
assert df['y'].is_null().sum() == 1
def test_schema_override(self):
"""schema_override forces a specific column dtype."""
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / 'test.csv'
_write_csv(path, [['val'], ['123'], ['456']])
lf, schema, *_ = load_csv_directory(
str(path), encoding='utf-8',
schema_override={'val': 'Float64'},
)
df = lf.collect()
assert df['val'].dtype == pl.Float64
def test_custom_delimiter(self):
"""Tab-delimited file should parse correctly."""
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / 'test.tsv'
with open(path, 'w', newline='') as f:
f.write('a\tb\n1\t2\n3\t4\n')
lf, *_ = load_csv_directory(str(path), delimiter='\t')
df = lf.collect()
assert len(df) == 2
assert df.columns == ['a', 'b']
def test_glob_with_subdirectory(self):
"""Recursive glob should find files in subdirectories."""
with tempfile.TemporaryDirectory() as tmpdir:
sub = Path(tmpdir) / 'sub'
sub.mkdir()
_write_csv(sub / 'nested.csv', [['x'], ['42']])
lf, schema, row_count, file_count, memory_mb = load_csv_directory(
str(Path(tmpdir) / '**' / '*.csv')
)
assert file_count == 1
assert row_count > 0
def test_latlon_with_plus_and_blank(self):
"""CSV with lat/lon columns containing '+', '' loads without error
and the column is Float64 after cleaning."""
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / 'latlon_test.csv'
with open(path, 'w', newline='') as f:
f.write('src_ip,latitude,longitude\n')
f.write('10.0.0.1,+,120.0\n')
f.write('10.0.0.2,35.5,\n')
f.write('10.0.0.3,-,\n')
f.write('10.0.0.4,40.0,121.5\n')
lf, schema, *_ = load_csv_directory(str(path))
df = lf.collect()
# lat/lon columns should be Float64 in schema
assert 'latitude' in schema
assert 'longitude' in schema
assert 'latitude' in df.columns
assert 'longitude' in df.columns
# Check dtypes are Float64
assert df['latitude'].dtype == pl.Float64
assert df['longitude'].dtype == pl.Float64
# Standalone '+' and blank should become null
assert df['latitude'].is_null().sum() == 2 # rows 0,2
assert df['longitude'].is_null().sum() == 2 # rows 1,2
# Valid values survive
assert df['latitude'].drop_nulls().to_list() == [35.5, 40.0]
assert df['longitude'].drop_nulls().to_list() == [120.0, 121.5]
# ═══════════════════════════════════════════════════════════════════════
# Config loading
# ═══════════════════════════════════════════════════════════════════════
class TestLoadConfig:
"""load_config() YAML loading and error handling."""
def test_load_valid_yaml(self):
path = _write_yaml('key: value\nnested:\n inner: 42\n')
try:
config = load_config(path)
assert config['key'] == 'value'
assert config['nested']['inner'] == 42
finally:
os.unlink(path)
def test_load_empty_yaml(self):
path = _write_yaml('')
try:
config = load_config(path)
assert config == {}
finally:
os.unlink(path)
def test_config_path_none(self):
assert load_config(None) == {}
def test_config_not_found(self):
with pytest.raises(FileNotFoundError):
load_config('/__nonexistent__/config.yaml')
# ═══════════════════════════════════════════════════════════════════════
# Utility functions
# ═══════════════════════════════════════════════════════════════════════
class TestResolveEncoding:
def test_bom_overrides_default(self):
assert _resolve_encoding('utf-8', 'utf-16le') == 'utf-16le'
def test_no_bom_uses_default(self):
# Polars >= 1.0 uses 'utf8' (not 'utf-8')
assert _resolve_encoding('utf-8', 'utf-8') == 'utf8'
assert _resolve_encoding('latin-1', 'utf-8') == 'latin-1'
class TestFileCount:
def test_count(self):
assert _file_count(['a', 'b', 'c']) == 3
assert _file_count([]) == 0
# ═══════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════
def _write_csv(path: Path, rows: list[list[str]]) -> None:
"""Write rows as a CSV file."""
with open(path, 'w', newline='') as f:
writer = csv.writer(f)
for row in rows:
writer.writerow(row)
def _write_bytes(data: bytes) -> str:
"""Write raw bytes to a temp file; return the path string."""
with tempfile.NamedTemporaryFile(suffix='.csv', delete=False) as f:
f.write(data)
return f.name
def _write_yaml(content: str) -> str:
"""Write YAML string to a temp file; return the path string."""
with tempfile.NamedTemporaryFile(suffix='.yaml', mode='w', delete=False) as f:
f.write(content)
return f.name
-365
View File
@@ -1,365 +0,0 @@
"""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
+255
View File
@@ -0,0 +1,255 @@
"""
Full-stack end-to-end test: real DeepSeek LLM auto-analysis workflow.
1. Start Django server on port 18766, monitor stderr
2. Upload ALL CSV files from data/multi_upload/ (0.csv..1997.csv) via POST /upload/csv/
3. Poll /runs/{id}/status/ until 'ready' or 'failed'
4. POST /analyze/llm/ to run LLM auto-analysis (real DeepSeek LLM)
5. Poll /runs/{id}/status/ until 'completed' or 'failed' (LLM may take 2-5 min)
6. Check status is 'completed', not 'failed'
7. GET /globe/ 200
8. POST /runs/{id}/delete/ 200
9. Stop server
10. FAIL on any error sys.exit(1)
Usage: runtime/python/python.exe tests/test_e2e_full.py
"""
import subprocess
import time
import json
import sys
import threading
import os
import uuid
import urllib.request
import urllib.error
import glob
PORT = 18766
BASE = f"http://127.0.0.1:{PORT}"
FAILED = []
_lock = threading.Lock()
def fail(msg):
with _lock:
FAILED.append(msg)
print(f" FAIL: {msg}")
def check(cond, msg):
if not cond:
fail(msg)
def start_server():
manage_py = os.path.join(os.path.dirname(__file__), '..', 'manage.py')
proc = subprocess.Popen(
[sys.executable, manage_py, 'runserver', f'127.0.0.1:{PORT}', '--noreload'],
stderr=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, encoding='utf-8', errors='replace',
)
def monitor():
for line in proc.stderr:
stripped = line.strip()
upper = stripped.upper()
# Django dev server logs request lines like "GET /... 200" to stderr — skip those
# Also skip LLM tool diagnostics (tool errors are expected and handled)
if ('ERROR' in upper and 'GET' not in upper and 'POST' not in upper
and 'tianxuan.llm' not in stripped
and 'this error occurred' not in stripped.lower()
and 'traceback' not in stripped.lower()):
fail(f"STDERR: {stripped}")
threading.Thread(target=monitor, daemon=True).start()
# Wait for server to be ready
for _ in range(15):
try:
urllib.request.urlopen(f'{BASE}/', timeout=3)
return proc
except Exception:
time.sleep(1)
raise RuntimeError("Server did not start")
def http_get(path):
r = urllib.request.urlopen(f'{BASE}{path}', timeout=15)
body = r.read()
code = r.status
r.close()
return code, body
def http_post_json(path, data_dict):
body = json.dumps(data_dict).encode('utf-8')
req = urllib.request.Request(f'{BASE}{path}', data=body,
headers={'Content-Type': 'application/json'})
try:
r = urllib.request.urlopen(req, timeout=30)
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
def http_post_raw(path, data=None):
"""POST with optional raw data (no content-type override)."""
req = urllib.request.Request(f'{BASE}{path}', data=data or b'')
try:
r = urllib.request.urlopen(req, timeout=30)
body = r.read()
code = r.status
r.close()
return code, body
except urllib.error.HTTPError as e:
return e.code, e.read()
def main():
proc = start_server()
print("[1/8] Server started")
# ── Upload ALL 1998 CSV files in a single request ──
data_dir = os.path.join(os.path.dirname(__file__), '..', 'data', 'multi_upload')
csv_files = sorted(glob.glob(os.path.join(data_dir, '*.csv')))
print(f"[2/8] Uploading {len(csv_files)} CSV files (single multipart request)...")
boundary = uuid.uuid4().hex
lines = []
for fp in csv_files:
fname = os.path.basename(fp)
with open(fp, 'rb') as f:
filedata = f.read()
lines.append(f'--{boundary}'.encode())
lines.append(f'Content-Disposition: form-data; name="files"; filename="{fname}"'.encode())
lines.append(b'Content-Type: text/csv')
lines.append(b'')
lines.append(filedata)
lines.append(f'--{boundary}--'.encode())
body = b'\r\n'.join(lines)
ct = f'multipart/form-data; boundary={boundary}'
req = urllib.request.Request(f'{BASE}/upload/csv/', data=body,
headers={'Content-Type': ct})
try:
r = urllib.request.urlopen(req, timeout=300)
resp = json.loads(r.read())
r.close()
except urllib.error.HTTPError as e:
body = e.read().decode('utf-8', errors='replace')
fail(f"Upload failed: HTTP {e.code} - {body[:300]}")
proc.terminate()
proc.wait()
sys.exit(1)
except Exception as e:
fail(f"Upload exception: {e}")
proc.terminate()
proc.wait()
sys.exit(1)
run_id = resp.get('run_id')
check(run_id is not None, "No run_id in upload response")
print(f" Uploaded {len(csv_files)} files, Run ID: {run_id}")
# ── Poll status until 'ready' ──
print("[3/8] Waiting for processing (background: load -> profile -> ready)...")
ready = False
for i in range(180):
code, body = http_get(f'/runs/{run_id}/status/')
check(code == 200, f"Status poll -> {code}")
s = json.loads(body)
status = s['status']
if status == 'failed':
err = s.get('error_message', '')[:200]
fail(f"Run failed: {err}")
break
if status == 'ready':
ready = True
print(f" Status: {status} ({s.get('progress_pct', 0)}%)")
break
if i % 10 == 0:
print(f" Poll #{i}: {status} ({s.get('progress_pct', 0)}%)")
time.sleep(2)
check(ready, "Run never became ready after upload")
if not ready:
proc.terminate()
proc.wait()
sys.exit(1)
# ── POST /analyze/llm/ to run real LLM auto-analysis ──
print("[4/8] Starting LLM auto-analysis (real DeepSeek LLM, may take 2-5 min)...")
code, resp = http_post_json('/analyze/llm/', {'run_id': run_id})
check(code == 200, f"LLM start -> HTTP {code}")
llm_status = resp.get('status')
print(f" LLM started: {llm_status}")
# ── Poll status until 'completed' or 'failed' (LLM phase) ──
print("[5/8] Waiting for LLM analysis to complete...")
completed = False
for i in range(120): # 120 × 3s = 360s = 6 min max
code, body = http_get(f'/runs/{run_id}/status/')
check(code == 200, f"LLM status poll -> {code}")
s = json.loads(body)
status = s['status']
pct = s.get('progress_pct', 0)
msg = s.get('progress_msg', '')
if status == 'failed':
err = s.get('error_message', '') or s.get('progress_msg', '') or '(no details)'
fail(f"LLM analysis failed: {err[:300]}")
break
if status == 'completed':
completed = True
print(f" LLM completed ({pct}%)")
# Print last 300 chars of run_log for interest
run_log = s.get('run_log', '')
if run_log:
# Strip non-ASCII chars for console-safe printing
log_snippet = run_log[-200:].encode('ascii', errors='replace').decode('ascii')
print(f" Last log: {log_snippet}")
break
if i % 5 == 0 or i == 10:
print(f" Poll #{i}: {status} ({pct}%) - {msg}")
time.sleep(3)
check(completed, "LLM analysis never completed")
if not completed:
proc.terminate()
proc.wait()
sys.exit(1)
# ── Check status is 'completed', not 'failed' ──
print("[6/8] Verifying final status...")
code, body = http_get(f'/runs/{run_id}/status/')
s = json.loads(body) if code == 200 else {}
final_status = s.get('status', 'unknown')
check(final_status == 'completed', f"Final status is '{final_status}', expected 'completed'")
print(f" Final status: {final_status} [OK]")
# ── GET /globe/ → 200 ──
print("[7/8] Checking globe page...")
code, body = http_get('/globe/')
check(code == 200, f"GET /globe/ -> {code}")
print(f" Globe -> HTTP {code} [OK]")
# ── POST /runs/{run_id}/delete/ → 200 ──
print("[8/8] Deleting run...")
code, body = http_post_raw(f'/runs/{run_id}/delete/')
check(code == 200, f"DELETE -> HTTP {code}")
print(f" Delete -> HTTP {code} [OK]")
# ── Cleanup ──
proc.terminate()
proc.wait()
if FAILED:
print(f"\n!!! INTEGRATION TEST FAILED: {len(FAILED)} failures")
for e in FAILED:
print(f" {e}")
sys.exit(1)
print("\n*** ALL INTEGRATION TESTS PASSED ***")
if __name__ == '__main__':
main()
-322
View File
@@ -1,322 +0,0 @@
"""Tests for entity detection (entity_detector) and aggregation (entity_aggregator).
Every test isolates the SessionStore singleton via the ``clean_store`` fixture.
"""
import pytest
import polars as pl
from analysis.session_store import SessionStore
from analysis.entity_detector import _score_name, detect_entity_column
from analysis.entity_aggregator import (
_find_column,
aggregate_by_entity,
build_aggregation_params,
ip_to_subnet,
)
# ═══════════════════════════════════════════════════════════════════════
# 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()
def _store_dataset(
df: pl.DataFrame,
dataset_id: str = 'test_ds',
) -> str:
"""Helper: store a DataFrame as a dataset in the session store.
Returns the dataset_id.
"""
store = SessionStore()
schema = {name: str(dtype) for name, dtype in zip(df.columns, df.dtypes)}
store.store_dataset(
dataset_id=dataset_id,
lazyframe=df.lazy(),
schema=schema,
metadata={'row_count': len(df)},
)
return dataset_id
# ═══════════════════════════════════════════════════════════════════════
# _score_name (unit-level)
# ═══════════════════════════════════════════════════════════════════════
class TestScoreName:
"""_score_name() scores column names against entity keywords."""
def test_exact_match(self):
"""Exact keyword match → 3.0."""
score, kw = _score_name(':ips')
assert score == 3.0
assert kw == ':ips'
def test_suffix_match(self):
"""Suffix match via _ separator → 3.0."""
score, kw = _score_name('my_snam')
assert score == 3.0
assert kw == 'snam'
def test_prefix_match(self):
"""Prefix match via _ separator → 3.0."""
score, kw = _score_name('snam_value')
assert score == 3.0
assert kw == 'snam'
def test_substring_not_matched(self):
"""Substring-only match now returns 0.0 (exact matching only)."""
score, kw = _score_name('myipaddr')
assert score == 0.0
assert kw == ''
def test_no_match(self):
score, kw = _score_name('random_column_xyz')
assert score == 0.0
assert kw == ''
def test_hyphen_normalised(self):
"""Hyphen normalised to underscore for matching."""
score, kw = _score_name('cipher-suite')
assert score == 3.0
assert kw == 'cipher_suite'
# ═══════════════════════════════════════════════════════════════════════
# detect_entity_column (integration-level)
# ═══════════════════════════════════════════════════════════════════════
class TestDetectEntityColumn:
"""detect_entity_column() should find entity columns from stored data."""
def test_detect_ips(self):
"""Dataset with ``:ips`` → recommended is ``:ips``."""
data = pl.DataFrame({
':ips': [f'10.0.0.{i}' for i in range(1, 51)] * 10,
':ipd': [f'10.0.1.{i}' for i in range(1, 501)],
'8ack': [100] * 500,
})
ds_id = _store_dataset(data)
result = detect_entity_column(ds_id)
assert result['recommended'] == ':ips'
assert len(result['candidates']) > 0
assert result['candidates'][0]['column'] == ':ips'
assert result['total_rows'] == 500
assert result['scored_columns'] >= 1
def test_detect_snam(self):
"""Datasets with ``snam`` but no IP column → snam is recommended."""
data = pl.DataFrame({
'snam': [f'host{i}.example.com' for i in range(100)],
'4dur': [float(i) for i in range(100)],
})
ds_id = _store_dataset(data)
result = detect_entity_column(ds_id)
assert result['recommended'] == 'snam'
assert result['candidates'][0]['matched_keyword'] == 'snam'
def test_no_entity_column(self):
"""No entity keywords matched → candidates still found but matched_keyword empty."""
data = pl.DataFrame({
'col_a': [str(i) for i in range(100)],
'col_b': [str(i * 2) for i in range(100)],
'value_f64': [float(i) for i in range(100)],
})
ds_id = _store_dataset(data)
result = detect_entity_column(ds_id)
# String columns are candidates (detector scores on uniqueness, not just keywords)
assert len(result['candidates']) >= 2
assert all(c['matched_keyword'] == '' for c in result['candidates'])
# recommended will be the highest-scoring string column
assert result['recommended'] in ('col_a', 'col_b')
def test_all_numeric_no_strings(self):
"""No string/categorical columns → no candidates."""
data = pl.DataFrame({
'value': list(range(100)),
'flag': [0, 1] * 50,
})
ds_id = _store_dataset(data)
result = detect_entity_column(ds_id)
assert result['recommended'] == ''
assert len(result['candidates']) == 0
def test_dataset_not_found(self):
"""Unknown dataset_id → error response."""
result = detect_entity_column('nonexistent_ds')
assert 'error' in result
assert result['recommended'] == ''
# ═══════════════════════════════════════════════════════════════════════
# _find_column (helper for aggregation)
# ═══════════════════════════════════════════════════════════════════════
class TestFindColumn:
"""_find_column() matches keywords against schema column names."""
ACK_KEYWORDS = ('8ack',)
def test_exact_match(self):
schema = {'8ack': 'Int64', 'other': 'Utf8'}
col = _find_column(schema, self.ACK_KEYWORDS)
assert col == '8ack'
def test_no_match(self):
schema = {'something_else': 'Int64'}
col = _find_column(schema, self.ACK_KEYWORDS)
assert col is None
# ═══════════════════════════════════════════════════════════════════════
# aggregate_by_entity
# ═══════════════════════════════════════════════════════════════════════
class TestAggregateByEntity:
"""aggregate_by_entity() groups flows and computes per-entity features."""
def test_two_entities(self):
"""50 flows across 2 entities → 2 aggregated rows with correct counts."""
data = pl.DataFrame({
':ips': ['10.0.0.1'] * 20 + ['10.0.0.2'] * 30,
':ipd': ['10.0.1.1'] * 10 + ['10.0.1.2'] * 10
+ ['10.0.1.3'] * 30,
':prd': [80] * 20 + [443] * 30,
'8ack': [100] * 20 + [200] * 30,
'4dur': [1.0] * 20 + [2.0] * 30,
'0ver': ['TLSv1.2'] * 20 + ['TLSv1.3'] * 30,
'cipher_suite': ['AES'] * 20 + ['CHACHA20'] * 30,
'1ipp': ['TCP'] * 50,
'timestamp': ['2024-01-01 00:00:00'] * 50,
})
schema = {name: str(dtype) for name, dtype in zip(data.columns, data.dtypes)}
lf = data.lazy()
agg_lf, feature_cols = aggregate_by_entity(lf, ':ips', schema)
df = agg_lf.collect()
# Two entities
assert len(df) == 2
# Flow counts
df_sorted = df.sort(':ips')
assert df_sorted['flow_count'][0] == 20 # 10.0.0.1
assert df_sorted['flow_count'][1] == 30 # 10.0.0.2
# Bytes — each column is now perfectly aligned with entity groups
assert df_sorted['total_bytes_sent'][0] == 20 * 100
assert df_sorted['total_bytes_sent'][1] == 30 * 200
# Core feature columns present
assert 'flow_count' in feature_cols
assert 'total_bytes_sent' in feature_cols
assert 'avg_duration' in feature_cols
assert 'unique_dst_ips' in feature_cols
assert 'unique_dst_ports' in feature_cols
assert 'unique_protocols' in feature_cols
assert 'first_seen' in feature_cols
assert 'last_seen' in feature_cols
def test_single_entity(self):
"""Single entity → single aggregated row."""
data = pl.DataFrame({
':ips': ['10.0.0.1'] * 5,
'8ack': [100] * 5,
':ipd': ['10.0.0.2'] * 5,
})
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 len(df) == 1
assert df['flow_count'][0] == 5
def test_nonexistent_entity_column(self):
"""Unknown entity column → ValueError."""
data = pl.DataFrame({'a': [1, 2, 3]})
schema = {'a': 'Int64'}
lf = data.lazy()
with pytest.raises(ValueError, match=r"Entity column\(s\) '\[\'xyz\'\]'"):
aggregate_by_entity(lf, 'xyz', schema)
def test_schema_with_missing_columns(self):
"""Aggregation still works when some optional columns are missing."""
data = pl.DataFrame({
':ips': ['10.0.0.1'] * 3 + ['10.0.0.2'] * 3,
':ipd': ['10.0.0.3'] * 6,
# No 8ack, 4dur, etc.
})
schema = {name: str(dtype) for name, dtype in zip(data.columns, data.dtypes)}
lf = data.lazy()
agg_lf, feature_cols = aggregate_by_entity(lf, ':ips', schema)
df = agg_lf.collect()
assert len(df) == 2
assert df['flow_count'][0] == 3
# Missing features should have None values
assert df['total_bytes_sent'][0] is None
assert 'flow_count' in feature_cols
assert 'unique_dst_ips' in feature_cols
# ═══════════════════════════════════════════════════════════════════════
# build_aggregation_params
# ═══════════════════════════════════════════════════════════════════════
class TestBuildAggregationParams:
"""build_aggregation_params() builds correct expressions per schema."""
def test_flow_count_always_present(self):
params = build_aggregation_params({'a': 'Int64'})
names = [p[0] for p in params]
assert 'flow_count' in names
def test_ack_detected(self):
params = build_aggregation_params({'8ack': 'Int64'})
names = [p[0] for p in params]
assert 'total_bytes_sent' in names
def test_ack_not_detected(self):
params = build_aggregation_params({'x': 'Int64'})
param_map = dict(params)
assert param_map['total_bytes_sent'] is not None # still has a fallback expr
# The value should be a Polars literal (None)
names = [p[0] for p in params]
assert 'total_bytes_sent' in names
# ═══════════════════════════════════════════════════════════════════════
# ip_to_subnet (IP subnet mask conversion)
# ═══════════════════════════════════════════════════════════════════════
class TestIpToSubnet:
"""ip_to_subnet() converts IP strings to subnet prefixes."""
def test_ip_to_subnet_24(self):
"""ip_to_subnet with /24 mask."""
assert ip_to_subnet('10.0.1.15', 24) == '10.0.1.0/24'
assert ip_to_subnet('192.168.1.255', 24) == '192.168.1.0/24'
def test_ip_to_subnet_28(self):
"""ip_to_subnet with /28 mask."""
assert ip_to_subnet('10.0.1.15', 28) == '10.0.1.0/28'
assert ip_to_subnet('10.0.1.250', 28) == '10.0.1.240/28'
def test_ip_to_subnet_invalid(self):
"""ip_to_subnet with invalid inputs."""
assert ip_to_subnet(None, 24) is None
assert ip_to_subnet('', 24) == ''
assert ip_to_subnet('not_an_ip', 24) == 'not_an_ip'
-132
View File
@@ -1,132 +0,0 @@
"""
Full-stack integration test. Run: python tests/test_integration.py
IMPORTANT: Update this file whenever frontend HTML/API changes.
Pure Python no curl, no requests required.
"""
import subprocess, time, json, sys, threading, os, uuid, urllib.request, urllib.error
PORT = 18765
BASE = f"http://127.0.0.1:{PORT}"
errors = []
_lock = threading.Lock()
def fail(msg):
with _lock: errors.append(msg)
def check(cond, msg):
if not cond: fail(msg)
def start_server():
manage_py = os.path.join(os.path.dirname(__file__), '..', 'manage.py')
proc = subprocess.Popen(
[sys.executable, manage_py, 'runserver', f'127.0.0.1:{PORT}', '--noreload'],
stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True, encoding='utf-8',
)
def monitor():
for line in proc.stderr:
stripped = line.strip()
if 'ERROR' in stripped.upper() and 'GET' not in stripped and 'POST' not in stripped:
fail(f"STDERR: {stripped}")
threading.Thread(target=monitor, daemon=True).start()
return proc
def build_multipart_formdata(fields):
"""Build multipart/form-data body and content-type boundary.
fields: {name: (filename, content_type, data_bytes)}
"""
boundary = uuid.uuid4().hex
lines = []
for name, (filename, content_type, data) in fields.items():
lines.append(f'--{boundary}'.encode())
lines.append(f'Content-Disposition: form-data; name="{name}"; filename="{filename}"'.encode())
lines.append(f'Content-Type: {content_type}'.encode())
lines.append(b'')
lines.append(data)
lines.append(f'--{boundary}--'.encode())
return b'\r\n'.join(lines), f'multipart/form-data; boundary={boundary}'
def http_get(path, max_retries=10):
"""GET request with retry on connection refused."""
for attempt in range(max_retries):
try:
r = urllib.request.urlopen(f'{BASE}{path}', timeout=5)
body = r.read()
code = r.status
r.close()
return code, body
except urllib.error.URLError as e:
if 'refused' in str(e).lower() and attempt < max_retries - 1:
time.sleep(1)
continue
return 502, b'Connection refused'
def http_post_multipart(path, fields):
"""POST multipart form data, returns (status_code, body_bytes)."""
body_bytes, content_type = build_multipart_formdata(fields)
req = urllib.request.Request(f'{BASE}{path}', data=body_bytes,
headers={'Content-Type': content_type})
try:
r = urllib.request.urlopen(req, timeout=30)
return r.status, r.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def main():
proc = start_server()
time.sleep(3)
# Test 1: Homepage
code, _ = http_get('/')
check(code == 200, f"GET / → {code}")
# Test 2: Upload CSV with '+' in lat/lon
csv_content = (
":ips,:ipd,0ver,:prd,:prs,:ips.latd,:ips.lond\n"
"1.2.3.4,5.6.7.8,0303,443,80,35.0,139.0\n"
"9.10.11.12,13.14.15.16,0304,8080,443,+,+\n"
)
fields = {'files': ('test.csv', 'text/csv', csv_content.encode('utf-8'))}
code, body = http_post_multipart('/upload/csv/', fields)
check(code == 200, f"POST /upload/csv/ → {code}")
data = json.loads(body)
run_id = data.get('run_id')
check(run_id is not None, "No run_id in upload response")
# Test 3: Poll run status until ready
for _ in range(30):
code, body = http_get(f'/runs/{run_id}/status/')
check(code == 200, f"GET /runs/{run_id}/status/ → {code}")
s = json.loads(body)
if s['status'] in ('ready', 'completed', 'failed'):
check(s['status'] != 'failed', f"Run failed: {s.get('error_message','')}")
break
time.sleep(1)
# Test 4: Run list page
code, _ = http_get('/runs/')
check(code == 200, f"GET /runs/ → {code}")
# Test 5: Globe page
code, _ = http_get('/globe/')
check(code == 200, f"GET /globe/ → {code}")
# Test 6: Config page
code, _ = http_get('/config/')
check(code == 200, f"GET /config/ → {code}")
# Test 7: Delete run — @csrf_exempt means 200 (or 500), not 403, not 404
code, _ = http_post_multipart(f'/runs/{run_id}/delete/', {})
check(code != 404, f"POST /runs/{run_id}/delete/ → {code} (should not be 404)")
# Cleanup
proc.terminate()
proc.wait()
if errors:
print("INTEGRATION TEST FAILED:")
for e in errors: print(f" {e}")
sys.exit(1)
print("ALL INTEGRATION TESTS PASSED")
if __name__ == "__main__":
main()
-132
View File
@@ -1,132 +0,0 @@
"""Test the full pipeline on multiple datasets × algorithms.
Runs 6 parameterized test cases: 3 datasets × 2 algorithms.
Uses real CSV files from the ``data/`` directory.
"""
from io import StringIO
from django.test import TestCase
from analysis.management.commands.run_pipeline import Command as PipelineCommand
from analysis.models import AnalysisRun
from analysis.session_store import SessionStore
class PipelineMultiDatasetTest(TestCase):
"""Run the pipeline on 3 datasets × 2 algorithms = 6 test cases."""
DATASETS = [
('data/test_flows.csv', 'simple_500'),
('data/e2e_test.csv', 'e2e_500'),
('data/complex_test.csv', 'complex_5000'),
]
ALGORITHMS = ['hdbscan', 'kmeans']
def setUp(self):
"""Clean SessionStore before each test."""
SessionStore().drop_all()
def tearDown(self):
"""Clean SessionStore after each test."""
SessionStore().drop_all()
# ── helpers ──────────────────────────────────────────────────────────
def _run_pipeline(self, csv_glob, algo):
"""Run the full pipeline via management command.
Returns
-------
tuple[AnalysisRun | None, str]
The latest ``AnalysisRun`` for *csv_glob* and the full log output.
"""
out = StringIO()
cmd = PipelineCommand(stdout=out, stderr=out)
cmd.handle(csv_glob=csv_glob, entity_col=None, algo=algo, output=None)
run = AnalysisRun.objects.filter(csv_glob=csv_glob).order_by('-id').first()
return run, out.getvalue()
# ── individual test cases: 3 × 2 = 6 ────────────────────────────────
def test_simple_hdbscan(self):
"""simple_500 dataset + hdbscan."""
run, log = self._run_pipeline('data/test_flows.csv', 'hdbscan')
self.assertEqual(run.status, 'completed',
f'Pipeline failed: {run.error_message}')
self.assertGreater(run.cluster_count, 0, 'No clusters found')
self.assertIsNotNone(run.entity_column)
self.assertGreater(run.entity_count, 0)
def test_simple_kmeans(self):
"""simple_500 dataset + kmeans."""
run, log = self._run_pipeline('data/test_flows.csv', 'kmeans')
self.assertEqual(run.status, 'completed',
f'Pipeline failed: {run.error_message}')
self.assertGreater(run.cluster_count, 0, 'No clusters found')
self.assertGreater(run.entity_count, 0)
def test_e2e_hdbscan(self):
"""e2e_500 dataset + hdbscan."""
run, log = self._run_pipeline('data/e2e_test.csv', 'hdbscan')
self.assertEqual(run.status, 'completed',
f'Pipeline failed: {run.error_message}')
self.assertGreater(run.cluster_count, 0, 'No clusters found')
def test_e2e_kmeans(self):
"""e2e_500 dataset + kmeans."""
run, log = self._run_pipeline('data/e2e_test.csv', 'kmeans')
self.assertEqual(run.status, 'completed',
f'Pipeline failed: {run.error_message}')
self.assertGreater(run.cluster_count, 0, 'No clusters found')
def test_complex_hdbscan(self):
"""complex_5000 dataset + hdbscan."""
run, log = self._run_pipeline('data/complex_test.csv', 'hdbscan')
self.assertEqual(run.status, 'completed',
f'Pipeline failed: {run.error_message}')
self.assertGreater(run.cluster_count, 0, 'No clusters found')
def test_complex_kmeans(self):
"""complex_5000 dataset + kmeans."""
run, log = self._run_pipeline('data/complex_test.csv', 'kmeans')
self.assertEqual(run.status, 'completed',
f'Pipeline failed: {run.error_message}')
self.assertGreater(run.cluster_count, 0, 'No clusters found')
# ── combined parametrised run ────────────────────────────────────────
def test_all_datasets_all_algorithms(self):
"""Every (dataset, algorithm) combination succeeds."""
failures = []
for csv_glob, label in self.DATASETS:
for algo in self.ALGORITHMS:
with self.subTest(dataset=label, algo=algo):
run, log = self._run_pipeline(csv_glob, algo)
if run.status != 'completed':
failures.append(f'{label}/{algo}: {run.error_message}')
self.assertEqual(
run.status, 'completed',
f'{label}/{algo} failed: {run.error_message}',
)
self.assertGreater(
run.cluster_count, 0,
f'{label}/{algo}: no clusters',
)
self.assertEqual(len(failures), 0,
f'{len(failures)} failures: {failures}')
# ── edge cases ──────────────────────────────────────────────────────
def test_invalid_csv_glob(self):
"""Non-existent CSV → pipeline reports failure, does not crash."""
out = StringIO()
cmd = PipelineCommand(stdout=out, stderr=out)
cmd.handle(csv_glob='data/nonexistent.csv',
entity_col=None, algo='hdbscan', output=None)
run = AnalysisRun.objects.filter(
csv_glob='data/nonexistent.csv',
).order_by('-id').first()
self.assertIsNotNone(run)
# Should fail gracefully (file not found → no rows → no entity col)
self.assertEqual(run.status, 'failed')
-421
View File
@@ -1,421 +0,0 @@
"""Tests for the data type classifier engine (type_classifier) and IP
clustering utilities (ip_clustering).
Covers: INT, FLOAT, ENUM, HEX, URL, IPv4, BOOL_ENUM detection via both
name-based and value-based heuristics, config override, schema-level
classification, and ``/24`` subnet grouping.
"""
import pytest
import polars as pl
from analysis.type_classifier import (
DataType,
classify_column,
classify_schema,
HEX_PATTERN,
URL_PATTERN,
IPv4_PATTERN,
BOOL_ENUM_VALUES,
LAT_LON_NAMES,
)
from analysis.ip_clustering import ip_to_int, get_subnet, cluster_ips
# ═══════════════════════════════════════════════════════════════════════
# classify_column — INT
# ═══════════════════════════════════════════════════════════════════════
class TestClassifyInt:
def test_int_series(self):
s = pl.Series('bytes_sent', [100, 200, 300])
assert classify_column('bytes_sent', s) == DataType.INT
def test_unsigned_int(self):
s = pl.Series('packets', [0, 1, 255, 65535])
assert classify_column('packets', s) == DataType.INT
def test_small_int64(self):
s = pl.Series('count', [1, 2, 3], dtype=pl.Int64)
assert classify_column('count', s) == DataType.INT
# ═══════════════════════════════════════════════════════════════════════
# classify_column — FLOAT
# ═══════════════════════════════════════════════════════════════════════
class TestClassifyFloat:
def test_float_series(self):
s = pl.Series('duration', [1.5, 2.7, 0.3])
assert classify_column('duration', s) == DataType.FLOAT
def test_float_dtype(self):
s = pl.Series('score', [0.1, 0.2], dtype=pl.Float32)
assert classify_column('score', s) == DataType.FLOAT
def test_float_with_ints(self):
"""Values look like ints but dtype is float → still FLOAT."""
s = pl.Series('ratio', [1.0, 2.0, 3.0])
assert classify_column('ratio', s) == DataType.FLOAT
# ═══════════════════════════════════════════════════════════════════════
# classify_column — ENUM (< 20 unique values)
# ═══════════════════════════════════════════════════════════════════════
class TestClassifyEnum:
def test_few_unique_strings(self):
s = pl.Series('tls_version', ['TLSv1.2', 'TLSv1.3', 'TLSv1.2'])
assert classify_column('tls_version', s) == DataType.ENUM
def test_exactly_19_unique(self):
vals = [str(i) for i in range(19)]
s = pl.Series('version_code', vals * 2)
assert classify_column('version_code', s) == DataType.ENUM
def test_many_unique_strings(self):
vals = [f'str_{i}' for i in range(50)]
s = pl.Series('desc', vals)
assert classify_column('desc', s) == DataType.STRING
def test_name_version_hint(self):
"""Column name contains 'version' → name heuristic returns ENUM."""
s = pl.Series('tls_ver', ['1.2', '1.3', '1.2'])
assert classify_column('tls_ver', s) == DataType.ENUM
# ═══════════════════════════════════════════════════════════════════════
# classify_column — HEX ("0e a6 3f" format)
# ═══════════════════════════════════════════════════════════════════════
class TestClassifyHex:
def test_single_hex_pair(self):
s = pl.Series('cipher', ['0e', 'a6', '3f'])
assert classify_column('cipher', s) == DataType.HEX
def test_multiple_hex_pairs(self):
s = pl.Series('cipher_suite', ['0e a6 3f', 'ab cd ef 01'])
assert classify_column('cipher_suite', s) == DataType.HEX
def test_name_cipher_hint(self):
"""Column name containing 'cipher' triggers name heuristic."""
s = pl.Series('tls_cipher', ['ab cd', 'ef 01'])
assert classify_column('tls_cipher', s) == DataType.HEX
def test_name_suite_hint(self):
s = pl.Series('suite_id', ['00 01', '00 02'])
assert classify_column('suite_id', s) == DataType.HEX
def test_invalid_hex_not_matched(self):
"""Not all values are valid hex pairs → should not be HEX."""
s = pl.Series('x', ['0e a6', 'zz yy'])
result = classify_column('x', s)
assert result != DataType.HEX
# ═══════════════════════════════════════════════════════════════════════
# classify_column — URL
# ═══════════════════════════════════════════════════════════════════════
class TestClassifyUrl:
def test_https(self):
s = pl.Series('dst_url', ['https://example.com', 'https://test.org'])
assert classify_column('dst_url', s) == DataType.URL
def test_http(self):
s = pl.Series('url', ['http://example.com/path', 'http://test.org'])
assert classify_column('url', s) == DataType.URL
def test_name_url_hint(self):
s = pl.Series('request_uri', ['https://api.example.com/v1'])
assert classify_column('request_uri', s) == DataType.URL
def test_not_url(self):
"""Plain domain names without http:// should not be URL."""
s = pl.Series('domain', ['example.com', 'test.org'])
assert classify_column('domain', s) != DataType.URL
# ═══════════════════════════════════════════════════════════════════════
# classify_column — IPv4
# ═══════════════════════════════════════════════════════════════════════
class TestClassifyIPv4:
def test_ip_addresses(self):
s = pl.Series('ip', ['192.168.1.1', '10.0.0.1', '172.16.0.1'])
assert classify_column('ip', s) == DataType.IPv4
def test_ip_in_name(self):
s = pl.Series('src_ip', ['10.0.0.1', '10.0.0.2'])
# Name-based heuristic triggers first
assert classify_column('src_ip', s) == DataType.IPv4
def test_addr_suffix(self):
s = pl.Series('destination_addr', ['1.2.3.4', '5.6.7.8'])
assert classify_column('destination_addr', s) == DataType.IPv4
def test_invalid_octet_range(self):
"""Octet > 255 → should not match IPv4 via values."""
s = pl.Series('x', ['192.168.1.256', '10.0.0.1'])
result = classify_column('x', s)
assert result != DataType.IPv4 # 256 invalid, may fall through
def test_ip_plus_normal_strings(self):
"""Mix of IPs and non-IPs → not IPv4."""
s = pl.Series('x', ['192.168.1.1', 'hello'])
result = classify_column('x', s)
assert result != DataType.IPv4
# ═══════════════════════════════════════════════════════════════════════
# classify_column — BOOL_ENUM (+, '', true/false, etc.)
# ═══════════════════════════════════════════════════════════════════════
class TestClassifyBoolEnum:
def test_plus_and_blank(self):
s = pl.Series('flag', ['+', '', '+', ' '])
assert classify_column('flag', s) == DataType.BOOL_ENUM
def test_true_false(self):
s = pl.Series('active', ['true', 'false', 'true'])
assert classify_column('active', s) == DataType.BOOL_ENUM
def test_yes_no(self):
s = pl.Series('enabled', ['yes', 'no', 'yes'])
assert classify_column('enabled', s) == DataType.BOOL_ENUM
def test_01_strings(self):
s = pl.Series('ok', ['0', '1', '0'])
assert classify_column('ok', s) == DataType.BOOL_ENUM
def test_boolean_dtype(self):
"""Native pl.Boolean dtype → BOOL_ENUM."""
s = pl.Series('flag', [True, False, True])
assert classify_column('flag', s) == DataType.BOOL_ENUM
# ═══════════════════════════════════════════════════════════════════════
# classify_column — LAT_LON (name-based)
# ═══════════════════════════════════════════════════════════════════════
class TestClassifyLatLon:
def test_lat_column(self):
s = pl.Series('lat', [35.0, 36.5])
assert classify_column('lat', s) == DataType.LAT_LON
def test_latitude_column(self):
s = pl.Series('latitude', [40.0, 41.0])
assert classify_column('latitude', s) == DataType.LAT_LON
def test_lon_column(self):
s = pl.Series('lon', [120.0, 121.5])
assert classify_column('lon', s) == DataType.LAT_LON
def test_longitude_column(self):
s = pl.Series('longitude', [122.0, 123.0])
assert classify_column('longitude', s) == DataType.LAT_LON
def test_src_latitude_column(self):
"""Column name src_latitude → LAT_LON via prefix heuristic."""
s = pl.Series('src_latitude', [35.0, 36.0, 37.0])
assert classify_column('src_latitude', s) == DataType.LAT_LON
def test_dst_longitude_column(self):
"""Column name dst_longitude → LAT_LON via prefix heuristic."""
s = pl.Series('dst_longitude', [120.0, 121.0, 122.0])
assert classify_column('dst_longitude', s) == DataType.LAT_LON
def test_value_based_latlon_strings(self):
"""String column with mix of '+', '', and valid floats → LAT_LON."""
s = pl.Series('coord', ['+', '', '12.345', '-45.678', '90.0'])
assert classify_column('coord', s) == DataType.LAT_LON
def test_value_based_latlon_rejects_out_of_range(self):
"""String column with values > 180 → not LAT_LON."""
s = pl.Series('coord', ['200', '300', '400'])
result = classify_column('coord', s)
assert result != DataType.LAT_LON
# ═══════════════════════════════════════════════════════════════════════
# classify_column — Config override (highest priority)
# ═══════════════════════════════════════════════════════════════════════
class TestConfigOverride:
def test_config_overrides_value(self):
"""config_type='int' forces INT even if values are float."""
s = pl.Series('anything', [1.5, 2.5, 3.5])
assert classify_column('anything', s, config_type='int') == DataType.INT
def test_config_overrides_name(self):
"""config_type='string' forces STRING even if name matches IP."""
s = pl.Series('src_ip', ['192.168.1.1', '10.0.0.1'])
assert classify_column('src_ip', s, config_type='string') == DataType.STRING
def test_config_type_case_insensitive(self):
s = pl.Series('x', [1, 2])
assert classify_column('x', s, config_type='FLOAT') == DataType.FLOAT
def test_config_type_trimmed(self):
s = pl.Series('x', ['a', 'b'])
assert classify_column('x', s, config_type=' enum ') == DataType.ENUM
def test_unknown_config_type_falls_through(self):
"""Unknown config_type string → ignored, falls to name/value."""
s = pl.Series('bytes_sent', [1, 2, 3])
result = classify_column('bytes_sent', s, config_type='unknown_type')
assert result == DataType.INT # value-based
# ═══════════════════════════════════════════════════════════════════════
# classify_schema (full schema classification)
# ═══════════════════════════════════════════════════════════════════════
class TestClassifySchema:
def test_classify_all_columns(self):
data = {
'src_ip': ['10.0.0.1', '10.0.0.2', '10.0.0.3'],
'dst_ip': ['10.0.1.1', '10.0.1.2', '10.0.1.3'],
'bytes_sent': [100, 200, 300],
'duration': [1.5, 2.5, 0.5],
'tls_version': ['TLSv1.2', 'TLSv1.3', 'TLSv1.2'],
'cipher': ['0e a6 3f', 'ab cd ef', '01 02'],
'url': ['https://a.com', 'https://b.com', 'https://c.com'],
'lat': [35.0, 36.0, 37.0],
'lon': [120.0, 121.0, 122.0],
}
lf = pl.DataFrame(data).lazy()
type_map = classify_schema(lf)
assert type_map['src_ip'] == DataType.IPv4
assert type_map['dst_ip'] == DataType.IPv4
assert type_map['bytes_sent'] == DataType.INT
assert type_map['duration'] == DataType.FLOAT
assert type_map['tls_version'] == DataType.ENUM
assert type_map['cipher'] == DataType.HEX
assert type_map['url'] == DataType.URL
assert type_map['lat'] == DataType.LAT_LON
assert type_map['lon'] == DataType.LAT_LON
def test_classify_with_config_overrides(self):
data = {'src_ip': ['10.0.0.1', '10.0.0.2']}
lf = pl.DataFrame(data).lazy()
overrides = {'src_ip': 'string'}
type_map = classify_schema(lf, config_overrides=overrides)
assert type_map['src_ip'] == DataType.STRING
# ═══════════════════════════════════════════════════════════════════════
# IP clustering utilities
# ═══════════════════════════════════════════════════════════════════════
class TestIpToInt:
def test_simple(self):
assert ip_to_int('192.168.1.1') == 3232235777
def test_localhost(self):
assert ip_to_int('127.0.0.1') == 2130706433
def test_invalid(self):
assert ip_to_int('not_an_ip') is None
def test_empty_string(self):
assert ip_to_int('') is None
def test_out_of_range(self):
assert ip_to_int('999.999.999.999') is None
class TestGetSubnet:
def test_24_mask(self):
assert get_subnet('192.168.1.5', 24) == '192.168.1.0/24'
def test_16_mask(self):
assert get_subnet('10.1.2.3', 16) == '10.1.0.0/16'
def test_8_mask(self):
assert get_subnet('100.200.1.1', 8) == '100.0.0.0/8'
def test_invalid_ip(self):
assert get_subnet('not_an_ip', 24) is None
def test_default_mask(self):
"""Default mask is /24."""
assert get_subnet('10.0.0.15') == '10.0.0.0/24'
class TestClusterIps:
def test_single_subnet(self):
ips = ['10.0.0.1', '10.0.0.2', '10.0.0.3']
groups = cluster_ips(ips, mask=24)
assert len(groups) == 1
assert '10.0.0.0/24' in groups
assert len(groups['10.0.0.0/24']) == 3
def test_multiple_subnets(self):
ips = ['10.0.0.1', '10.0.1.1', '192.168.1.1']
groups = cluster_ips(ips, mask=24)
assert len(groups) == 3
def test_invalid_ips_grouped(self):
ips = ['10.0.0.1', 'bad_ip', 'also_invalid']
groups = cluster_ips(ips, mask=24)
assert '10.0.0.0/24' in groups
assert '__invalid__' in groups
assert len(groups['__invalid__']) == 2
def test_empty_list(self):
groups = cluster_ips([], mask=24)
assert groups == {}
def test_same_subnet_then_16(self):
"""10.0.x.x IPs with /16 mask all go to one subnet."""
ips = ['10.0.1.1', '10.0.2.2', '10.0.255.255']
groups = cluster_ips(ips, mask=16)
assert len(groups) == 1
assert '10.0.0.0/16' in groups
# ═══════════════════════════════════════════════════════════════════════
# classify_column — MAC address (should NOT be HEX)
# ═══════════════════════════════════════════════════════════════════════
class TestClassifyMac:
def test_mac_address_detection(self):
"""MAC addresses should NOT be classified as HEX."""
s = pl.Series('mac', ['aa:bb:cc:dd:ee:ff', '00:1a:2b:3c:4d:5e'])
result = classify_column('mac', s)
assert result != DataType.HEX, f'MAC should not be HEX, got {result}'
# MAC with unique addresses → STRING (high cardinality)
# ═══════════════════════════════════════════════════════════════════════
# classify_column — PORT values (INT range → ENUM)
# ═══════════════════════════════════════════════════════════════════════
class TestClassifyPort:
def test_port_values_detection(self):
"""Integer values in 1-65535 range should be ENUM."""
s = pl.Series('port', ['80', '443', '8080', '8443'])
assert classify_column('port', s) == DataType.ENUM
# ═══════════════════════════════════════════════════════════════════════
# classify_column — Value-based IPv4 (name-agnostic)
# ═══════════════════════════════════════════════════════════════════════
class TestClassifyValueBased:
def test_value_based_ipv4(self):
"""IP detection should work from values alone."""
s = pl.Series('col', ['10.0.0.1', '192.168.1.1', '8.8.8.8'])
assert classify_column('col', s) == DataType.IPv4
def test_value_based_url(self):
"""URL detection should work from values."""
s = pl.Series('col', ['https://example.com', 'http://test.org/path'])
assert classify_column('col', s) == DataType.URL
def test_value_first_over_name(self):
"""Value pattern should take priority over name heuristic."""
# Column named "version" with IP-like values should be IPv4, not ENUM
s = pl.Series('version', ['10.0.0.1', '192.168.1.1'])
assert classify_column('version', s) == DataType.IPv4
-430
View File
@@ -1,430 +0,0 @@
"""
Integration tests for the 天璇 (TianXuan) incremental update system.
All tests run in isolated temporary directories and use a mocked APPDATA
environment variable, so they never touch the real project data.
Tests verify the full update lifecycle:
1. ZIP build structure (matching build_update.py output)
2. File extraction and replacement (matching apply_update.py steps 5-6)
3. Config merge with user-edit preservation (matching merge_config.py)
4. Version skip / direct-jump updates
5. Corrupted package rejection (CRC + SHA256 verification)
6. Database migration from project dir to %APPDATA%
"""
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
import pytest
# Real project root from the test file location
PROJECT_DIR = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = PROJECT_DIR / 'scripts'
# ═══════════════════════════════════════════════════════════════════════
# Test helpers
# ═══════════════════════════════════════════════════════════════════════
def _create_test_project(tmp_dir: Path) -> Path:
"""Create a simulated TianXuan project structure within a temp dir."""
proj = tmp_dir / 'tianxuan'
proj.mkdir()
# Core modules
(proj / 'analysis').mkdir()
(proj / 'analysis' / '__init__.py').write_text('# analysis module\n')
(proj / 'tianxuan').mkdir()
(proj / 'tianxuan' / '__init__.py').write_text('# settings module\n')
# Config
(proj / 'config').mkdir()
(proj / 'config' / 'config.yaml').write_text(
'server:\n host: 127.0.0.1\n port: 8000\n'
'llm:\n api_key: test-key-123\n'
)
# Templates
(proj / 'templates').mkdir()
(proj / 'templates' / 'base.html').write_text('<html>Old Version</html>\n')
# Static assets
(proj / 'static').mkdir()
# Scripts directory (placeholder — real scripts live outside temp dir)
(proj / 'scripts').mkdir(parents=True, exist_ok=True)
# Version file
(proj / 'VERSION').write_text('v1.0.0\n')
# A file that will be "deleted" in a test update
(proj / 'old_module.py').write_text('# This will be removed\n')
return proj
def _create_update_zip(project_dir: Path,
from_ver: str = 'v1.0.0',
to_ver: str = 'v1.1.0',
add_files: dict | None = None,
delete_files: list | None = None,
run_migrate: bool = False) -> Path:
"""Build a simulated update ZIP matching the build_update.py output format.
Parameters
----------
project_dir : Path
Directory where the ZIP is written.
from_ver, to_ver : str
Version range this update covers.
add_files : dict[str, str] | None
Mapping of relative path file content for changed/new files.
delete_files : list[str] | None
Relative paths of files to remove.
run_migrate : bool
Whether the update advertises a schema migration.
Returns
-------
Path
Full path to the created update ZIP.
"""
zip_path = project_dir / f'tianxuan_update_{from_ver}-{to_ver}.zip'
files = add_files or {'templates/base.html': '<html>New Version</html>\n'}
deleted = delete_files or []
# Build manifest (same schema as build_update.py produces)
files_manifest = {}
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for rel_path, content in files.items():
arcname = f'files/{rel_path}'
zf.writestr(arcname, content)
sha256 = hashlib.sha256(content.encode()).hexdigest()
files_manifest[rel_path] = {
'sha256': sha256,
'size': len(content),
}
manifest = {
'from_version': from_ver,
'to_version': to_ver,
'built_at': '2026-07-20T12:00:00Z',
'files': files_manifest,
'deleted': deleted,
'run_migrate': run_migrate,
'run_import_tlsdb': False,
'runtime_changed': False,
}
zf.writestr('version.json', json.dumps(manifest, indent=2))
return zip_path
# ═══════════════════════════════════════════════════════════════════════
# Test class
# ═══════════════════════════════════════════════════════════════════════
class TestUpdateSystem:
"""Integration tests for the TianXuan incremental update system.
Every test creates its own isolated project tree + APPDATA directory
and cleans up on teardown.
"""
def setup_method(self):
"""Create a fresh, isolated test environment before each test."""
self._tmp_base = Path(tempfile.mkdtemp(suffix='_tianxuan_test'))
self.project_dir = _create_test_project(self._tmp_base)
# Override APPDATA so scripts write to the temp directory
self._orig_appdata = os.environ.get('APPDATA')
self._test_appdata = self._tmp_base / 'appdata'
self._test_appdata.mkdir()
os.environ['APPDATA'] = str(self._test_appdata)
def teardown_method(self):
"""Restore the environment and remove temp files."""
if self._orig_appdata:
os.environ['APPDATA'] = self._orig_appdata
else:
os.environ.pop('APPDATA', None)
shutil.rmtree(str(self._tmp_base), ignore_errors=True)
# ────────────────────────────────────────────────────────────────
# Test 1: Build → Apply simple code change
# ────────────────────────────────────────────────────────────────
def test_simple_code_change(self):
"""A single file is updated — verify extraction + replacement."""
update_zip = _create_update_zip(
self.project_dir,
from_ver='v1.0.0', to_ver='v1.1.0',
add_files={'templates/base.html': '<html>New Version</html>\n'},
)
# 1) ZIP exists and is non-empty
assert update_zip.exists()
assert update_zip.stat().st_size > 0
# 2) Extract and apply (simulating apply_update.py steps 1, 6)
with zipfile.ZipFile(update_zip, 'r') as zf:
manifest = json.loads(zf.read('version.json'))
assert manifest['from_version'] == 'v1.0.0'
assert manifest['to_version'] == 'v1.1.0'
assert 'templates/base.html' in manifest['files']
for name in zf.namelist():
if not name.startswith('files/'):
continue
rel_path = name[6:] # strip 'files/' prefix
if rel_path == 'config/config.yaml':
continue # config is handled by the merge step
zf.extract(name, str(self.project_dir))
src = self.project_dir / name
dst = self.project_dir / rel_path
if src != dst:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dst))
# 3) The updated file has the new content
updated = self.project_dir / 'templates' / 'base.html'
assert updated.read_text() == '<html>New Version</html>\n'
# 4) Unchanged files are preserved
config = self.project_dir / 'config' / 'config.yaml'
assert 'test-key-123' in config.read_text()
# 5) Clean up the temporary files/ directory
files_dir = self.project_dir / 'files'
if files_dir.exists():
shutil.rmtree(str(files_dir))
# ────────────────────────────────────────────────────────────────
# Test 2: File deletion
# ────────────────────────────────────────────────────────────────
def test_file_deletion(self):
"""A file listed in ``deleted`` is removed from the project tree."""
old_file = self.project_dir / 'old_module.py'
assert old_file.exists()
update_zip = _create_update_zip(
self.project_dir,
from_ver='v1.0.0', to_ver='v1.1.0',
add_files={},
delete_files=['old_module.py'],
)
# Apply deletion (simulating apply_update.py step 5)
with zipfile.ZipFile(update_zip, 'r') as zf:
manifest = json.loads(zf.read('version.json'))
for rel_path in manifest.get('deleted', []):
target = self.project_dir / rel_path
if target.exists():
target.unlink()
assert not old_file.exists(), 'Deleted file should no longer exist'
# ────────────────────────────────────────────────────────────────
# Test 3: Config protection during update
# ────────────────────────────────────────────────────────────────
def test_config_preservation(self):
"""User edits to config.yaml survive an update; new keys are added.
This exercises the real ``merge_config.py`` script via subprocess.
"""
# -- Simulate a user who has edited their config.yml -----------
config_path = self.project_dir / 'config' / 'config.yaml'
user_edited = (
'server:\n'
' host: 0.0.0.0\n'
' port: 9000\n'
'llm:\n'
' api_key: my-custom-key\n'
'clustering:\n'
' algorithm: kmeans\n'
)
config_path.write_text(user_edited)
# -- Build an update that ships a *different* config -----------
source_config = (
'server:\n'
' host: 127.0.0.1\n'
' port: 8000\n'
'llm:\n'
' api_key: default-key\n'
' model: gpt-4\n' # NEW key — should appear after merge
'clustering:\n'
' algorithm: hdbscan\n'
' min_cluster_size: 10\n' # NEW key — should appear after merge
)
update_zip = _create_update_zip(
self.project_dir,
add_files={'config/config.yaml': source_config},
)
# -- Back up user config (apply_update.py step 4) --------------
backup_dir = self._test_appdata / 'TianXuan' / 'backups'
backup_dir.mkdir(parents=True, exist_ok=True)
backup_config = backup_dir / 'v1.1.0' / 'config.yaml'
backup_config.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(config_path), str(backup_config))
# -- Extract the new config to ``files/`` (step 6) -------------
with zipfile.ZipFile(update_zip, 'r') as zf:
zf.extract('files/config/config.yaml', str(self.project_dir))
extracted_config = self.project_dir / 'files' / 'config' / 'config.yaml'
assert extracted_config.exists()
# -- Merge (step 7): old = user backup, new = extracted source --
merge_script = SCRIPTS_DIR / 'merge_config.py'
result = subprocess.run(
[sys.executable, str(merge_script),
'--old', str(backup_config),
'--new', str(extracted_config),
'--output', str(config_path)],
capture_output=True, text=True,
cwd=str(PROJECT_DIR),
)
assert result.returncode == 0, f'merge_config failed: {result.stderr}'
merged = config_path.read_text()
# User edits preserved
assert 'my-custom-key' in merged, 'User API key should survive'
assert '0.0.0.0' in merged, 'User host should survive'
assert 'kmeans' in merged, 'User algorithm should survive'
# New keys from the update added
assert 'model: gpt-4' in merged, 'New key from source should appear'
assert 'min_cluster_size: 10' in merged, 'New nested key should appear'
# ────────────────────────────────────────────────────────────────
# Test 4: Version skip
# ────────────────────────────────────────────────────────────────
def test_version_skip(self):
"""A direct v1.0→v1.2 bundle includes ALL intermediate changes.
``git diff v1.0..HEAD`` shows every change regardless of whether
intermediate tags exist, so the build script naturally produces
correct "skipped-version" packages. This test verifies the
manifest accurately describes the jump.
"""
update_zip = _create_update_zip(
self.project_dir,
from_ver='v1.0.0', to_ver='v1.2.0',
add_files={
'templates/base.html': '<html>v1.2</html>\n',
'analysis/new_feature.py': '# Feature added in v1.2\n',
},
delete_files=['old_module.py'],
)
with zipfile.ZipFile(update_zip, 'r') as zf:
manifest = json.loads(zf.read('version.json'))
assert manifest['from_version'] == 'v1.0.0'
assert manifest['to_version'] == 'v1.2.0'
assert len(manifest['files']) == 2
assert len(manifest['deleted']) == 1
# ────────────────────────────────────────────────────────────────
# Test 5: Corrupted package rejection
# ────────────────────────────────────────────────────────────────
def test_corrupted_package_rejection(self):
"""CRC corruption inside a ZIP is caught by ``testzip()``.
A file's stored content is modified in-place within the ZIP
binary, causing a CRC32 mismatch that ``zipfile.testzip()``
reliably detects. The update should be rejected before any
files are extracted.
"""
# Use ZIP_STORED (no compression) so the content appears as
# plain text in the archive and can be surgically corrupted.
content = b'<html>OK</html>\n'
h = hashlib.sha256(content).hexdigest()
manifest = {
'from_version': 'v1.0.0',
'to_version': 'v1.1.0',
'built_at': '2026-07-20T12:00:00Z',
'files': {
'templates/base.html': {
'sha256': h,
'size': len(content),
},
},
'deleted': [],
'run_migrate': False,
'run_import_tlsdb': False,
'runtime_changed': False,
}
zip_path = self.project_dir / 'corrupt_test.zip'
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_STORED) as zf:
zf.writestr('version.json', json.dumps(manifest, indent=2))
zf.writestr('files/templates/base.html', content)
# Corrupt: replace the stored content in-place
raw = bytearray(zip_path.read_bytes())
idx = raw.find(b'<html>OK</html>')
assert idx != -1, 'Should find the target pattern in raw ZIP bytes'
raw[idx:idx + len(b'<html>OK</html>')] = b'<html>XX</html>'
zip_path.write_bytes(bytes(raw))
# The CRC check in apply_update.py step 1 must fail
with zipfile.ZipFile(zip_path, 'r') as zf:
bad_file = zf.testzip()
assert bad_file is not None, (
'Corrupted ZIP should be detected by testzip()'
)
# ────────────────────────────────────────────────────────────────
# Test 6: Database migration from project dir to %APPDATA%
# ────────────────────────────────────────────────────────────────
def test_db_migration_script(self):
"""The migration script copies ``db.sqlite3`` to ``%APPDATA%/TianXuan/``.
Because ``migrate_db_to_appdata.py`` locates its project root
from ``__file__`` (the real TianXuan project where ``db.sqlite3``
exists), running it with ``APPDATA`` pointing to the temp
directory copies the real database into the isolated test tree
without modifying any real project files.
"""
# The real project has a db.sqlite3 that the script will find.
migrate_script = SCRIPTS_DIR / 'migrate_db_to_appdata.py'
# First run — should copy the database
result = subprocess.run(
[sys.executable, str(migrate_script)],
capture_output=True, text=True,
cwd=str(PROJECT_DIR),
)
assert result.returncode == 0, f'Migration failed: {result.stderr}'
expected_new = self._test_appdata / 'TianXuan' / 'db.sqlite3'
assert expected_new.exists(), 'DB should exist at APPDATA location'
assert expected_new.stat().st_size > 0, 'Copied DB should not be empty'
# Second run — should SKIP (already present)
result2 = subprocess.run(
[sys.executable, str(migrate_script)],
capture_output=True, text=True,
cwd=str(PROJECT_DIR),
)
assert result2.returncode == 0
assert 'SKIP' in result2.stdout, 'Second run should print SKIP'
+3 -3
View File
@@ -218,7 +218,7 @@ def run_llm_pipeline(dataset_id: str, entity_col: str = "",
called_tools: set[str] = set()
consecutive_errors = 0
for step in range(15):
for step in range(25):
resp = call_llm(msgs, config)
if resp is None:
if callback:
@@ -311,5 +311,5 @@ def run_llm_pipeline(dataset_id: str, entity_col: str = "",
})
if callback:
callback(15, "max_steps", None)
return {"error": "Reached max 15 tool calls", "steps": 15}
callback(25, "max_steps", None)
return {"error": "Reached max 25 tool calls", "steps": 25}
+1
View File
@@ -109,6 +109,7 @@ USE_I18N = True
USE_TZ = True
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10000000
DATA_UPLOAD_MAX_NUMBER_FILES = 10000
DATA_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 100 * 1024 * 1024