873 lines
34 KiB
Python
873 lines
34 KiB
Python
"""Generate synthetic TLS flow test data with 3 real-world traffic profiles.
|
|
|
|
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]
|
|
"""
|
|
import sys
|
|
import polars as pl
|
|
import numpy as np
|
|
from pathlib import Path
|
|
from datetime import datetime, timedelta
|
|
|
|
# ===================================================================
|
|
# TLS Constants
|
|
# ===================================================================
|
|
|
|
CIPHER_HEX_MAP: dict[str, str] = {
|
|
'TLS_AES_128_GCM_SHA256': '13 01',
|
|
'TLS_AES_256_GCM_SHA384': '13 02',
|
|
'TLS_ECDHE_ECDSA_AES128_GCM_SHA256': 'c0 2b',
|
|
'TLS_ECDHE_RSA_AES128_GCM_SHA256': 'c0 2f',
|
|
'TLS_ECDHE_RSA_AES256_GCM_SHA384': 'c0 30',
|
|
'TLS_RSA_AES128_GCM_SHA256': '00 9c',
|
|
'TLS_RSA_AES_128_CBC_SHA': '00 3c',
|
|
'TLS_ECDHE_RSA_AES128_CBC_SHA': 'c0 09',
|
|
'TLS_RSA_WITH_RC4_128_SHA': '00 05',
|
|
'TLS_CHACHA20_POLY1305_SHA256': 'cc a9',
|
|
}
|
|
|
|
CURVE_MAP: dict[str, str] = {
|
|
'00 1d': 'x25519',
|
|
'00 17': 'secp256r1',
|
|
'00 18': 'secp384r1',
|
|
'00 19': 'secp521r1',
|
|
}
|
|
|
|
COUNTRY_CODES: list[str] = [
|
|
'US', 'JP', 'DE', 'GB', 'SG', 'KR', 'FR', 'AU', 'IN', 'CA', 'BR',
|
|
'RU', 'CN', 'NG', 'IR', 'NL', 'SE', 'HK', 'TW', 'ZA',
|
|
]
|
|
|
|
COUNTRY_A: list[str] = ['US', 'JP', 'DE', 'GB', 'SG', 'KR', 'FR', 'AU', 'NL', 'CA', 'SE']
|
|
COUNTRY_B: list[str] = ['US', 'NL', 'DE', 'SG', 'HK', 'JP', 'GB']
|
|
COUNTRY_C: list[str] = ['RU', 'CN', 'BR', 'NG', 'IR', 'KR', 'US', 'HK', 'TW', 'ZA']
|
|
|
|
TABLE_NAMES: list[str] = [
|
|
'tls_flows', 'network_traffic', 'encrypted_streams', 'ssl_logs', 'packet_capture',
|
|
]
|
|
LINK_NAMES: list[str] = [
|
|
'internet-edge', 'mpls-backbone', 'vpn-tunnel', 'wan-link',
|
|
'cloud-connect', 'dc-interconnect', 'peering-link',
|
|
]
|
|
NODE_NAMES: list[str] = (
|
|
[f'gw-{i:02d}' for i in range(1, 11)]
|
|
+ [f'core-{i:02d}' for i in range(1, 7)]
|
|
)
|
|
|
|
ISP_NAMES: list[str] = [
|
|
'China Telecom', 'China Unicom', 'China Mobile', 'AT&T', 'Verizon',
|
|
'Comcast', 'Deutsche Telekom', 'BT Group', 'NTT', 'KDDI',
|
|
'Singtel', 'Telstra', 'Reliance Jio', 'T-Mobile', 'Orange',
|
|
'Rostelecom', 'Vivo', 'MTN Group', 'Etisalat',
|
|
]
|
|
ORG_NAMES: list[str] = [
|
|
'Alibaba Inc.', 'Tencent Holdings', 'Baidu Inc.', 'Google LLC',
|
|
'Amazon.com Inc.', 'Microsoft Corp.', 'Meta Platforms Inc.', 'Apple Inc.',
|
|
'Netflix Inc.', 'Cloudflare Inc.', 'Akamai Technologies',
|
|
'Yandex', 'Kaspersky Lab', 'Mail.ru Group',
|
|
]
|
|
CITY_NAMES: list[str] = [
|
|
'Beijing', 'Shanghai', 'Tokyo', 'Seoul', 'New York', 'London',
|
|
'Paris', 'Berlin', 'Singapore', 'Sydney', 'Mumbai', 'Dubai',
|
|
'Toronto', 'Moscow', 'Sao Paulo', 'San Francisco', 'Los Angeles',
|
|
'Lagos', 'Tehran', 'Hong Kong',
|
|
]
|
|
|
|
# ===================================================================
|
|
# Traffic Profile Definitions
|
|
# ===================================================================
|
|
|
|
# Each profile defines distribution parameters for row generation.
|
|
# We encode TLS version, cipher, curve as weighted-choice lists.
|
|
|
|
TLS_PROFILES: dict[str, list[tuple[str, float]]] = {
|
|
'A': [('03 04', 0.80), ('03 03', 0.18), ('02 00', 0.01), ('03 00', 0.01)],
|
|
'B': [('03 04', 0.50), ('03 03', 0.40), ('03 01', 0.05), ('03 02', 0.05)],
|
|
'C': [('03 01', 0.30), ('03 02', 0.20), ('03 03', 0.20), ('03 04', 0.10),
|
|
('02 00', 0.10), ('03 00', 0.10)],
|
|
}
|
|
|
|
CIPHER_PROFILES: dict[str, list[tuple[str, float]]] = {
|
|
'A': [('13 01', 0.50), ('c0 2b', 0.20), ('c0 2f', 0.15), ('13 02', 0.10), ('cc a9', 0.05)],
|
|
'B': [('c0 2f', 0.30), ('13 01', 0.30), ('c0 30', 0.15), ('00 9c', 0.15), ('13 02', 0.10)],
|
|
'C': [('00 3c', 0.25), ('c0 09', 0.20), ('00 05', 0.10), ('13 01', 0.15),
|
|
('c0 2f', 0.10), ('c0 2b', 0.10), ('00 9c', 0.10)],
|
|
}
|
|
|
|
CURVE_PROFILES: dict[str, list[tuple[str, float]]] = {
|
|
'A': [('00 1d', 0.70), ('00 17', 0.25), ('00 18', 0.05)],
|
|
'B': [('00 17', 0.50), ('00 1d', 0.40), ('00 18', 0.10)],
|
|
'C': [('00 17', 0.25), ('00 1d', 0.20), ('00 18', 0.15),
|
|
('00 19', 0.10), ('', 0.30)], # 30% no curve
|
|
}
|
|
|
|
# Port profiles: (port, weight)
|
|
PORT_PROFILES: dict[str, list[tuple[int, float]]] = {
|
|
'A': [(443, 0.90), (80, 0.05), (8080, 0.02), (8443, 0.01), (993, 0.01), (465, 0.01)],
|
|
'B': [(443, 0.60), (8443, 0.15), (8080, 0.10), (3128, 0.05), (1080, 0.05), (8888, 0.05)],
|
|
'C': [(443, 0.60), (80, 0.05), (8080, 0.05), (8443, 0.05), (0, 0.25)], # 0 = random non-standard
|
|
}
|
|
|
|
# Duration (seconds) profiles: used with exponential + clip
|
|
DUR_PROFILES: dict[str, tuple[float, float, float]] = {
|
|
'A': (2.0, 0.5, 8.0), # (mean exp, min, max)
|
|
'B': (60.0, 10.0, 300.0),
|
|
'C': (1.0, 0.3, 600.0), # bimodal: will mix short & long
|
|
}
|
|
|
|
# ===================================================================
|
|
# IP Pools (deterministic via numpy seed)
|
|
# ===================================================================
|
|
|
|
def _ip_pool(n: int, seed_base: int, prefix: str | None = None) -> list[str]:
|
|
"""Generate *n* reproducible IPs with optional *prefix* (e.g. '10.0.1')."""
|
|
rng = np.random.RandomState(seed_base)
|
|
ips: list[str] = []
|
|
for _ in range(n):
|
|
if prefix:
|
|
parts = prefix.split('.')
|
|
ip = (f'{parts[0]}.{parts[1]}.{parts[2]}.'
|
|
f'{rng.randint(1, 255)}')
|
|
else:
|
|
a = rng.randint(1, 223)
|
|
b = rng.randint(0, 256)
|
|
c = rng.randint(0, 256)
|
|
d = rng.randint(1, 255)
|
|
ip = f'{a}.{b}.{c}.{d}'
|
|
ips.append(ip)
|
|
return ips
|
|
|
|
NORMAL_SRC_IPS: list[str] = _ip_pool(150, 42) # residential-like
|
|
NORMAL_DST_IPS: list[str] = _ip_pool(120, 142) # CDN/cloud-like
|
|
PROXY_SRC_IPS: list[str] = _ip_pool(8, 242) # proxy exit nodes
|
|
PROXY_DST_IPS: list[str] = _ip_pool(15, 342) # limited targets
|
|
MAL_SRC_IPS: list[str] = _ip_pool(180, 442) # botnet-like diverse
|
|
MAL_DST_IPS: list[str] = _ip_pool(60, 542) # malicious targets
|
|
|
|
# ===================================================================
|
|
# SNI / Domain pools
|
|
# ===================================================================
|
|
|
|
LEGIT_DOMAINS: list[str] = [
|
|
'www.google.com', 'api.github.com', 'facebook.com', 'youtube.com',
|
|
'amazon.com', 'netflix.com', 'apple.com', 'microsoft.com',
|
|
'cloudflare.com', 'stackoverflow.com', 'reddit.com', 'twitter.com',
|
|
'linkedin.com', 'instagram.com', 'office.com', 'live.com',
|
|
'bing.com', 'adobe.com', 'salesforce.com', 'zoom.us',
|
|
'maps.google.com', 'mail.google.com', 'drive.google.com',
|
|
'docs.google.com', 'play.google.com', 'support.microsoft.com',
|
|
'portal.office.com', 'login.live.com', 'cdn.cloudflare.com',
|
|
]
|
|
|
|
PROXY_DOMAINS: list[str] = [
|
|
'proxy.example.com', 'vpn-gateway.example.net', 'tunnel.example.org',
|
|
'exit-node.example.com', 'relay.example.net', 'gateway.example.org',
|
|
'', '',
|
|
]
|
|
|
|
DGA_DOMAINS: list[str] = [
|
|
'gkj3hj2k3.sdf2k3j.net', '8sdf7h3k.xyz', 'j2k3h4k5h7.ru',
|
|
'asdf9082k.biz', 'mnbv34k2.info', '2kj3h5k7jh9.com',
|
|
'xcnv98sdf2.top', 'q3kj4h5k7.work', 'vbsd78sdf.xyz',
|
|
'm2k3j5h6k8.ru', '9s8d7f6g5h.net', 'k2j3h5k7j9.org',
|
|
'fasdf2342.biz', 'poiuy234k.info', 'zxcv7890m.top',
|
|
'hk3j5k7h9.net', 'sdf2k3j4h5.ru', 'vb9s8d7f6.xyz',
|
|
'n2k3j5h7k9.com', 'x98s7d6f5.info',
|
|
]
|
|
|
|
FAKE_DOMAINS: list[str] = [
|
|
'secure-login-app.com', 'update-manager.net', 'cdn-cache-service.com',
|
|
'analytics-tracker.net', 'content-delivery-system.org',
|
|
'download-center.net', 'account-verify.com', 'system-update.org',
|
|
'cloud-sync-service.net', 'notification-hub.com',
|
|
]
|
|
|
|
# ===================================================================
|
|
# Helpers
|
|
# ===================================================================
|
|
|
|
def _weighted_choice(options: list[tuple[str, float]]) -> str:
|
|
"""Pick one from [(value, weight), ...] according to weight."""
|
|
items, raw_w = zip(*options)
|
|
total = sum(raw_w)
|
|
return str(np.random.choice(items, p=[w / total for w in raw_w]))
|
|
|
|
|
|
def _weighted_choice_int(options: list[tuple[int, float]]) -> int:
|
|
"""Pick int from [(value, weight), ...]."""
|
|
items, raw_w = zip(*options)
|
|
total = sum(raw_w)
|
|
return int(np.random.choice(items, p=[w / total for w in raw_w]))
|
|
|
|
|
|
def _random_hex_pairs(n_pairs: int = 2) -> str:
|
|
"""Generate space-separated hex pairs, e.g. 'c0 2b'."""
|
|
pairs = [f'{np.random.randint(0, 256):02x}' for _ in range(n_pairs)]
|
|
return ' '.join(pairs)
|
|
|
|
|
|
def _random_32_bytes_hex() -> str:
|
|
"""Generate 32 random hex bytes as a space-separated string."""
|
|
pairs = [f'{np.random.randint(0, 256):02x}' for _ in range(32)]
|
|
return ' '.join(pairs)
|
|
|
|
|
|
def _low_entropy_hex() -> str:
|
|
"""Generate repeating low-entropy TLS random (Profile C: beaconing).
|
|
|
|
Pattern: '00 01 00 01 00 01 ...' (repeating 2-byte cycle).
|
|
"""
|
|
pairs = ['00', '01'] * 16
|
|
return ' '.join(pairs)
|
|
|
|
|
|
def _maybe(rate: float) -> bool:
|
|
"""Return True with probability *rate* (value should be blanked)."""
|
|
return np.random.random() < rate
|
|
|
|
|
|
def _assign_profile() -> str:
|
|
"""Assign profile A (60%), B (20%), or C (20%)."""
|
|
r = np.random.random()
|
|
if r < 0.60:
|
|
return 'A'
|
|
elif r < 0.80:
|
|
return 'B'
|
|
else:
|
|
return 'C'
|
|
|
|
|
|
def _pick_sni(profile: str) -> tuple[str, str]:
|
|
"""Generate (snam, cnam) for the given profile.
|
|
|
|
Returns (sni_name, common_name).
|
|
"""
|
|
if profile == 'A':
|
|
if _maybe(0.05):
|
|
return ('', '')
|
|
domain = str(np.random.choice(LEGIT_DOMAINS))
|
|
return (domain, domain) # always match
|
|
|
|
elif profile == 'B':
|
|
if _maybe(0.20):
|
|
return ('', '')
|
|
domain = str(np.random.choice(PROXY_DOMAINS))
|
|
if domain == '':
|
|
return ('', '')
|
|
return (domain, domain)
|
|
|
|
else: # C
|
|
if _maybe(0.30):
|
|
return ('', '')
|
|
r = np.random.random()
|
|
if r < 0.20:
|
|
domain = str(np.random.choice(DGA_DOMAINS))
|
|
elif r < 0.70:
|
|
domain = str(np.random.choice(FAKE_DOMAINS))
|
|
else:
|
|
domain = str(np.random.choice(LEGIT_DOMAINS))
|
|
|
|
# 30% mismatch snam != cnam
|
|
if _maybe(0.30):
|
|
if _maybe(0.5):
|
|
cnam = str(np.random.choice(FAKE_DOMAINS))
|
|
else:
|
|
cnam = str(np.random.choice(LEGIT_DOMAINS))
|
|
return (domain, cnam)
|
|
return (domain, domain)
|
|
|
|
|
|
def _pick_country(profile: str) -> tuple[str, str]:
|
|
"""Pick (scnt, dcnt) for the given profile."""
|
|
if profile == 'A':
|
|
scnt = str(np.random.choice(COUNTRY_A))
|
|
dcnt = str(np.random.choice(COUNTRY_A))
|
|
elif profile == 'B':
|
|
scnt = str(np.random.choice(COUNTRY_B))
|
|
dcnt = str(np.random.choice(COUNTRY_B))
|
|
else:
|
|
scnt = str(np.random.choice(COUNTRY_C))
|
|
dcnt = str(np.random.choice(COUNTRY_C))
|
|
return scnt, dcnt
|
|
|
|
|
|
# ===================================================================
|
|
# Main generator — 3 profiles, 44 columns
|
|
# ===================================================================
|
|
|
|
def generate_tls_flows(n_rows: int = 1000, seed: int = 42) -> pl.DataFrame:
|
|
"""Generate synthetic TLS flow data with 3 real-world traffic profiles.
|
|
|
|
Profile A (60%): Normal Browsing
|
|
Profile B (20%): Proxy/VPN Traffic
|
|
Profile C (20%): Malicious/Suspicious
|
|
|
|
Returns a Polars DataFrame with all 44 columns matching the user's
|
|
exact column naming convention.
|
|
"""
|
|
np.random.seed(seed)
|
|
base_ts = datetime(2024, 1, 1, 0, 0, 0)
|
|
day_seconds = 86400 * 90 # 90-day window
|
|
|
|
rows: list[dict] = []
|
|
total = n_rows
|
|
next_pct = 10
|
|
|
|
for i in range(total):
|
|
# Progress indicator
|
|
pct = (i + 1) * 100 // total
|
|
if pct >= next_pct:
|
|
sys.stdout.write('+')
|
|
sys.stdout.flush()
|
|
next_pct += 10
|
|
|
|
profile = _assign_profile()
|
|
|
|
# --- IPs ---
|
|
if profile == 'A':
|
|
src_ip = str(np.random.choice(NORMAL_SRC_IPS))
|
|
dst_ip = str(np.random.choice(NORMAL_DST_IPS))
|
|
elif profile == 'B':
|
|
src_ip = str(np.random.choice(PROXY_SRC_IPS))
|
|
dst_ip = str(np.random.choice(PROXY_DST_IPS))
|
|
else:
|
|
src_ip = str(np.random.choice(MAL_SRC_IPS))
|
|
dst_ip = str(np.random.choice(MAL_DST_IPS))
|
|
|
|
# --- Ports ---
|
|
src_port = np.random.randint(1024, 65536)
|
|
dst_port = _weighted_choice_int(PORT_PROFILES[profile])
|
|
if dst_port == 0: # random non-standard
|
|
dst_port = int(np.random.choice([p for p in range(1025, 65536) if p != 443]))
|
|
|
|
# --- scnt, dcnt ---
|
|
scnt, dcnt = _pick_country(profile)
|
|
|
|
# --- TLS version ---
|
|
tls_ver = _weighted_choice(TLS_PROFILES[profile])
|
|
|
|
# --- SNI / CN ---
|
|
snam, cnam = _pick_sni(profile)
|
|
|
|
# --- Duration ---
|
|
mean_dur, min_dur, max_dur = DUR_PROFILES[profile]
|
|
if profile == 'C':
|
|
# Bimodal: beaconing (<1s) or C2 (long, >60s)
|
|
if _maybe(0.50):
|
|
dur = round(np.random.uniform(0.3, 1.0), 2)
|
|
else:
|
|
dur = round(min(max(np.random.exponential(120), 60), 600), 2)
|
|
else:
|
|
dur = round(min(max(np.random.exponential(mean_dur), min_dur), max_dur), 2)
|
|
|
|
# --- Session / timeout / key size ---
|
|
ses = round(np.random.uniform(0, 1000), 4)
|
|
tmo = round(np.random.uniform(0, 300), 4)
|
|
ksz = int(np.random.choice([128, 256, 384, 521]))
|
|
|
|
# --- cnrs / isrs (blank="no", +="yes") ---
|
|
if profile == 'A':
|
|
cnrs_val = '+' if _maybe(0.30) else ''
|
|
isrs_val = '+' if np.random.random() < 0.10 else ''
|
|
elif profile == 'B':
|
|
cnrs_val = '+' if _maybe(0.60) else ''
|
|
isrs_val = '+' if np.random.random() < 0.40 else ''
|
|
else:
|
|
cnrs_val = '+' if _maybe(0.20) else ''
|
|
isrs_val = '+' if np.random.random() < 0.50 else ''
|
|
|
|
# --- Bytes / packets ---
|
|
if profile == 'A':
|
|
ack_val = int(max(1, np.random.exponential(3000)))
|
|
ppk_val = max(1, int(ack_val / np.random.uniform(200, 1500)))
|
|
elif profile == 'B':
|
|
ack_val = int(max(1, np.random.exponential(50000)))
|
|
ppk_val = max(1, int(ack_val / np.random.uniform(100, 1400)))
|
|
else:
|
|
if _maybe(0.50):
|
|
# Beaconing: very small, regular packets
|
|
ack_val = int(np.random.uniform(30, 100)) * np.random.randint(1, 20)
|
|
ppk_val = max(1, ack_val // int(np.random.uniform(30, 100)))
|
|
else:
|
|
# Data exfil: large transfer
|
|
ack_val = int(max(1, np.random.exponential(100000)))
|
|
ppk_val = max(1, int(ack_val / np.random.uniform(100, 1400)))
|
|
|
|
# --- Timestamps ---
|
|
ts_offset = np.random.randint(0, day_seconds)
|
|
ts_full = base_ts + timedelta(seconds=ts_offset)
|
|
ts_str = ts_full.strftime('%Y-%m-%d %H:%M:%S')
|
|
tm_str = ts_full.strftime('%H:%M:%S')
|
|
dbd_str = (base_ts + timedelta(
|
|
seconds=np.random.randint(0, day_seconds)
|
|
)).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
# --- Cipher ---
|
|
cipher_hex = _weighted_choice(CIPHER_PROFILES[profile])
|
|
cipher_name = CIPHER_HEX_MAP.get(cipher_hex, '')
|
|
|
|
# --- Curve ---
|
|
curve_hex = _weighted_choice(CURVE_PROFILES[profile])
|
|
curve_name = CURVE_MAP.get(curve_hex, '')
|
|
|
|
# --- Other ---
|
|
ipp_val = int(np.random.choice([6, 17], p=[0.85, 0.15]))
|
|
dbn_val = np.random.randint(1, 10)
|
|
tabl = str(np.random.choice(TABLE_NAMES))
|
|
link_name = str(np.random.choice(LINK_NAMES))
|
|
node = str(np.random.choice(NODE_NAMES))
|
|
|
|
# --- TLS random (low entropy for Profile C) ---
|
|
if profile == 'C':
|
|
rnd = _low_entropy_hex()
|
|
else:
|
|
rnd = _random_32_bytes_hex()
|
|
rnt_val = int(base_ts.timestamp()) + np.random.randint(0, day_seconds)
|
|
|
|
# --- GeoIP dot-notation columns (non-globe: mostly blank) ---
|
|
def _geo_val(val, blank_rate=0.70):
|
|
return val if not _maybe(blank_rate) else ''
|
|
|
|
lat_src = '' if _maybe(0.70) else round(np.random.uniform(-90, 90), 6)
|
|
lon_src = '' if _maybe(0.70) else round(np.random.uniform(-180, 180), 6)
|
|
lat_dst = '' if _maybe(0.70) else round(np.random.uniform(-90, 90), 6)
|
|
lon_dst = '' if _maybe(0.70) else round(np.random.uniform(-180, 180), 6)
|
|
ispn_src = '' if _maybe(0.70) else str(np.random.choice(ISP_NAMES))
|
|
ispn_dst = '' if _maybe(0.70) else str(np.random.choice(ISP_NAMES))
|
|
orgn_src = '' if _maybe(0.70) else str(np.random.choice(ORG_NAMES))
|
|
orgn_dst = '' if _maybe(0.70) else str(np.random.choice(ORG_NAMES))
|
|
city_src = '' if _maybe(0.70) else str(np.random.choice(CITY_NAMES))
|
|
city_dst = '' if _maybe(0.70) else str(np.random.choice(CITY_NAMES))
|
|
|
|
# --- Assemble row dict ---
|
|
row = {
|
|
':ips': src_ip,
|
|
':ipd': dst_ip,
|
|
':prs': '' if _maybe(0.30) else src_port,
|
|
':prd': '' if _maybe(0.30) else dst_port,
|
|
'scnt': scnt if not _maybe(0.30) else '',
|
|
'dcnt': dcnt if not _maybe(0.30) else '',
|
|
'server-ip': dst_ip,
|
|
'client-ip': src_ip,
|
|
'0ver': tls_ver if not _maybe(0.05) else '',
|
|
'snam': snam,
|
|
'cnam': cnam if not _maybe(0.05) else '',
|
|
'4dur': '' if _maybe(0.30) else dur,
|
|
'8ses': '' if _maybe(0.30) else ses,
|
|
'2tmo': '' if _maybe(0.30) else tmo,
|
|
'4ksz': '' if _maybe(0.30) else ksz,
|
|
'cnrs': cnrs_val,
|
|
'isrs': isrs_val,
|
|
'8ack': ack_val if not _maybe(0.05) else '',
|
|
'8ppk': ppk_val if not _maybe(0.05) else '',
|
|
'8dbd': dbd_str if not _maybe(0.30) else '',
|
|
'1ipp': ipp_val if not _maybe(0.30) else '',
|
|
'4dbn': '' if _maybe(0.30) else dbn_val,
|
|
'tabl': tabl if not _maybe(0.30) else '',
|
|
'name': link_name if not _maybe(0.30) else '',
|
|
'source-node': node if not _maybe(0.30) else '',
|
|
'cipher-suite': cipher_name if not _maybe(0.30) else '',
|
|
'ecdhe-named-curve': curve_name if not _maybe(0.30) else '',
|
|
'0cph': cipher_hex if not _maybe(0.30) else '',
|
|
'0crv': curve_hex if not _maybe(0.30) else '',
|
|
'0rnd': rnd if not _maybe(0.30) else '',
|
|
'0rnt': '' if _maybe(0.30) else rnt_val,
|
|
'row': i + 1,
|
|
'time': tm_str if not _maybe(0.30) else '',
|
|
'timestamp': ts_str if not _maybe(0.10) else '',
|
|
':ips.latd': lat_src,
|
|
':ips.lond': lon_src,
|
|
':ipd.latd': lat_dst,
|
|
':ipd.lond': lon_dst,
|
|
':ips.ispn': ispn_src,
|
|
':ipd.ispn': ispn_dst,
|
|
':ips.orgn': orgn_src,
|
|
':ipd.orgn': orgn_dst,
|
|
':ips.city': city_src,
|
|
':ipd.city': city_dst,
|
|
}
|
|
rows.append(row)
|
|
|
|
sys.stdout.write('\n')
|
|
sys.stdout.flush()
|
|
return pl.DataFrame(rows)
|
|
|
|
|
|
# ===================================================================
|
|
# GeoIP loader (shared by globe mode)
|
|
# ===================================================================
|
|
|
|
def _load_geoip_ranges() -> dict[str, list[dict]]:
|
|
"""Load GeoIP range data from ``data/geoip_data.txt``."""
|
|
data_path = Path(__file__).resolve().parent.parent / 'data' / 'geoip_data.txt'
|
|
cities: dict[str, list[dict]] = {}
|
|
with open(data_path, 'r', encoding='utf-8') as f:
|
|
for raw in f:
|
|
stripped = raw.strip()
|
|
if not stripped or stripped.startswith('#'):
|
|
continue
|
|
parts = [p.strip() for p in stripped.split(',')]
|
|
if len(parts) != 6:
|
|
continue
|
|
start_str, end_str, lat_str, lon_str, city, _country = parts
|
|
start_int = _ip_to_int(start_str)
|
|
end_int = _ip_to_int(end_str)
|
|
if start_int is None or end_int is None:
|
|
continue
|
|
try:
|
|
lat = float(lat_str)
|
|
lon = float(lon_str)
|
|
except ValueError:
|
|
continue
|
|
city_key = city.replace(' ', '')
|
|
if city_key not in cities:
|
|
cities[city_key] = []
|
|
cities[city_key].append({
|
|
'start': start_int,
|
|
'end': end_int,
|
|
'lat': lat,
|
|
'lon': lon,
|
|
})
|
|
return cities
|
|
|
|
|
|
def _ip_to_int(ip_str: str) -> int | None:
|
|
"""Convert dotted IPv4 string to 32-bit integer."""
|
|
parts = ip_str.strip().split('.')
|
|
if len(parts) != 4:
|
|
return None
|
|
try:
|
|
octets = [int(p) for p in parts]
|
|
if any(o < 0 or o > 255 for o in octets):
|
|
return None
|
|
return (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
def _int_to_ip(n: int) -> str:
|
|
"""Convert 32-bit integer to dotted IPv4 string."""
|
|
return f'{n >> 24 & 255}.{n >> 16 & 255}.{n >> 8 & 255}.{n & 255}'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Globe mode city → (ISP, Org) mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_GLOBE_ORG_MAP: dict[str, tuple[str, str]] = {
|
|
'Beijing': ('China Unicom', 'Tencent Holdings'),
|
|
'Shanghai': ('China Telecom', 'Alibaba Inc.'),
|
|
'Taipei': ('Chunghwa Telecom', 'Hon Hai Group'),
|
|
'Tokyo': ('NTT', 'SoftBank Corp.'),
|
|
'Seoul': ('KT Corp.', 'Samsung SDS'),
|
|
'Singapore': ('Singtel', 'Grab Holdings'),
|
|
'Bangkok': ('True Corp.', 'G-Able'),
|
|
'Mumbai': ('Reliance Jio', 'Tata Consultancy'),
|
|
'Sydney': ('Telstra', 'Atlassian'),
|
|
'MountainView': ('Google Fiber', 'Google LLC'),
|
|
'NewYork': ('Verizon', 'Amazon.com Inc.'),
|
|
'LosAngeles': ('AT&T', 'Meta Platforms Inc.'),
|
|
'Toronto': ('Rogers', 'Shopify Inc.'),
|
|
'London': ('BT Group', 'British Telecom'),
|
|
'Paris': ('Orange', 'TotalEnergies'),
|
|
'Frankfurt': ('Deutsche Telekom', 'SAP SE'),
|
|
'Amsterdam': ('KPN', 'ING Group'),
|
|
'Dublin': ('Eir', 'Accenture'),
|
|
'Stockholm': ('Telia', 'Ericsson'),
|
|
'Moscow': ('Rostelecom', 'Yandex'),
|
|
'Dubai': ('Etisalat', 'Emirates Group'),
|
|
'SaoPaulo': ('Vivo', 'Itau Unibanco'),
|
|
'HongKong': ('PCCW', 'HSBC Holdings'),
|
|
'Lagos': ('MTN Group', 'Access Bank'),
|
|
'Tehran': ('TCI', 'Iran Khodro'),
|
|
}
|
|
|
|
|
|
# ===================================================================
|
|
# Globe mode — generates data suitable for 3D globe visualisation
|
|
# ===================================================================
|
|
|
|
def generate_globe_test_data(n_rows: int = 1000, seed: int = 42) -> pl.DataFrame:
|
|
"""Generate test data for 3D globe visualisation using GeoIP-resolvable IPs.
|
|
|
|
Uses the same 3 traffic profiles as the normal mode, but with
|
|
real GeoIP-resolvable source/destination IPs so lat/lon columns
|
|
correspond to actual geographic locations.
|
|
|
|
Data corruption patterns (for testing globe view robustness):
|
|
- ~30% of ``:ips.latd`` values are pure ``+``
|
|
- ~10% of ``:ips.lond`` values are empty ``""``
|
|
"""
|
|
np.random.seed(seed)
|
|
city_ranges = _load_geoip_ranges()
|
|
|
|
# Source cities (Asia / Pacific)
|
|
src_cities = [
|
|
'Beijing', 'Shanghai', 'Taipei', 'Tokyo',
|
|
'Seoul', 'Singapore', 'Bangkok', 'Mumbai', 'Sydney',
|
|
]
|
|
# Destination cities (global rest)
|
|
dst_cities = [
|
|
'MountainView', 'NewYork', 'LosAngeles', 'Toronto',
|
|
'London', 'Paris', 'Frankfurt', 'Amsterdam', 'Dublin',
|
|
'Stockholm', 'Moscow', 'Dubai', 'SaoPaulo',
|
|
]
|
|
|
|
all_cities = set(city_ranges.keys())
|
|
src_cities = [c for c in src_cities if c in all_cities]
|
|
dst_cities = [c for c in dst_cities if c in all_cities]
|
|
if not src_cities or not dst_cities:
|
|
raise RuntimeError('No usable GeoIP city ranges — cannot generate globe data.')
|
|
|
|
def _rand_ip_from_city(city: str) -> tuple[str, float, float]:
|
|
rng_list = city_ranges[city]
|
|
rng_entry = rng_list[np.random.randint(len(rng_list))]
|
|
ip_int = rng_entry['start'] + np.random.randint(0, rng_entry['end'] - rng_entry['start'] + 1)
|
|
return _int_to_ip(ip_int), rng_entry['lat'], rng_entry['lon']
|
|
|
|
base_ts = int(datetime(2023, 11, 1).timestamp())
|
|
thirty_days_sec = 86400 * 60
|
|
|
|
rows: list[dict] = []
|
|
total = n_rows
|
|
next_pct = 10
|
|
|
|
for i in range(total):
|
|
# Progress indicator
|
|
pct = (i + 1) * 100 // total
|
|
if pct >= next_pct:
|
|
sys.stdout.write('+')
|
|
sys.stdout.flush()
|
|
next_pct += 10
|
|
|
|
profile = _assign_profile()
|
|
|
|
# GeoIP-resolvable IPs + lat/lon
|
|
src_city = str(np.random.choice(src_cities))
|
|
src_ip_raw, src_lat_raw, src_lon_raw = _rand_ip_from_city(src_city)
|
|
|
|
dst_city = str(np.random.choice(dst_cities))
|
|
dst_ip_raw, dst_lat, dst_lon = _rand_ip_from_city(dst_city)
|
|
|
|
# Globe-specific corruption patterns
|
|
src_lat_out: str | float = '+' if np.random.random() < 0.30 else round(src_lat_raw, 6)
|
|
src_lon_out: str | float = '' if np.random.random() < 0.10 else round(src_lon_raw, 6)
|
|
|
|
# --- Ports ---
|
|
src_port = np.random.randint(1024, 65536)
|
|
dst_port = _weighted_choice_int(PORT_PROFILES[profile])
|
|
if dst_port == 0:
|
|
dst_port = int(np.random.choice([p for p in range(1025, 65536) if p != 443]))
|
|
|
|
# --- scnt, dcnt ---
|
|
scnt, dcnt = _pick_country(profile)
|
|
|
|
# --- TLS version ---
|
|
tls_ver = _weighted_choice(TLS_PROFILES[profile])
|
|
|
|
# --- SNI ---
|
|
snam, cnam = _pick_sni(profile)
|
|
|
|
# --- Duration ---
|
|
mean_dur, min_dur, max_dur = DUR_PROFILES[profile]
|
|
if profile == 'C':
|
|
if _maybe(0.50):
|
|
dur = round(np.random.uniform(0.3, 1.0), 2)
|
|
else:
|
|
dur = round(min(max(np.random.exponential(120), 60), 600), 2)
|
|
else:
|
|
dur = round(min(max(np.random.exponential(mean_dur), min_dur), max_dur), 2)
|
|
|
|
# --- Session / timeout / key size ---
|
|
ses = round(np.random.uniform(0, 1000), 4)
|
|
tmo = round(np.random.uniform(0, 300), 4)
|
|
ksz = int(np.random.choice([128, 256, 384, 521]))
|
|
|
|
# --- cnrs / isrs ---
|
|
if profile == 'A':
|
|
cnrs_val = '+' if _maybe(0.30) else ''
|
|
isrs_val = '+' if np.random.random() < 0.10 else ''
|
|
elif profile == 'B':
|
|
cnrs_val = '+' if _maybe(0.60) else ''
|
|
isrs_val = '+' if np.random.random() < 0.40 else ''
|
|
else:
|
|
cnrs_val = '+' if _maybe(0.20) else ''
|
|
isrs_val = '+' if np.random.random() < 0.50 else ''
|
|
|
|
# --- Bytes / packets ---
|
|
if profile == 'A':
|
|
ack_val = int(max(1, np.random.exponential(3000)))
|
|
ppk_val = max(1, int(ack_val / np.random.uniform(200, 1500)))
|
|
elif profile == 'B':
|
|
ack_val = int(max(1, np.random.exponential(50000)))
|
|
ppk_val = max(1, int(ack_val / np.random.uniform(100, 1400)))
|
|
else:
|
|
if _maybe(0.50):
|
|
ack_val = int(np.random.uniform(30, 100)) * np.random.randint(1, 20)
|
|
ppk_val = max(1, ack_val // int(np.random.uniform(30, 100)))
|
|
else:
|
|
ack_val = int(max(1, np.random.exponential(100000)))
|
|
ppk_val = max(1, int(ack_val / np.random.uniform(100, 1400)))
|
|
|
|
# --- Timestamps ---
|
|
ts = base_ts + np.random.randint(0, thirty_days_sec)
|
|
ts_str = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')
|
|
tm_str = datetime.fromtimestamp(ts).strftime('%H:%M:%S')
|
|
dbd_str = datetime.fromtimestamp(
|
|
base_ts + np.random.randint(0, thirty_days_sec)
|
|
).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
# --- Cipher ---
|
|
cipher_hex = _weighted_choice(CIPHER_PROFILES[profile])
|
|
cipher_name = CIPHER_HEX_MAP.get(cipher_hex, '')
|
|
|
|
# --- Curve ---
|
|
curve_hex = _weighted_choice(CURVE_PROFILES[profile])
|
|
curve_name = CURVE_MAP.get(curve_hex, '')
|
|
|
|
# --- Other ---
|
|
ipp_val = int(np.random.choice([6, 17], p=[0.85, 0.15]))
|
|
dbn_val = np.random.randint(1, 10)
|
|
tabl = str(np.random.choice(TABLE_NAMES))
|
|
link_name = str(np.random.choice(LINK_NAMES))
|
|
node = str(np.random.choice(NODE_NAMES))
|
|
|
|
# --- TLS random ---
|
|
if profile == 'C':
|
|
rnd = _low_entropy_hex()
|
|
else:
|
|
rnd = _random_32_bytes_hex()
|
|
rnt_val = base_ts + np.random.randint(0, thirty_days_sec)
|
|
|
|
# --- GeoIP ISP/org from globe map ---
|
|
src_isp, src_org = _GLOBE_ORG_MAP.get(
|
|
src_city, (str(np.random.choice(ISP_NAMES)), str(np.random.choice(ORG_NAMES))),
|
|
)
|
|
dst_isp, dst_org = _GLOBE_ORG_MAP.get(
|
|
dst_city, (str(np.random.choice(ISP_NAMES)), str(np.random.choice(ORG_NAMES))),
|
|
)
|
|
|
|
rows.append({
|
|
':ips': src_ip_raw,
|
|
':ipd': dst_ip_raw,
|
|
':prs': '' if _maybe(0.30) else src_port,
|
|
':prd': '' if _maybe(0.30) else dst_port,
|
|
'scnt': scnt if not _maybe(0.30) else '',
|
|
'dcnt': dcnt if not _maybe(0.30) else '',
|
|
'server-ip': dst_ip_raw,
|
|
'client-ip': src_ip_raw,
|
|
'0ver': tls_ver if not _maybe(0.05) else '',
|
|
'snam': snam,
|
|
'cnam': cnam if not _maybe(0.05) else '',
|
|
'4dur': '' if _maybe(0.30) else dur,
|
|
'8ses': '' if _maybe(0.30) else ses,
|
|
'2tmo': '' if _maybe(0.30) else tmo,
|
|
'4ksz': '' if _maybe(0.30) else ksz,
|
|
'cnrs': cnrs_val,
|
|
'isrs': isrs_val,
|
|
'8ack': ack_val if not _maybe(0.05) else '',
|
|
'8ppk': ppk_val if not _maybe(0.05) else '',
|
|
'8dbd': dbd_str if not _maybe(0.30) else '',
|
|
'1ipp': ipp_val if not _maybe(0.30) else '',
|
|
'4dbn': '' if _maybe(0.30) else dbn_val,
|
|
'tabl': tabl if not _maybe(0.30) else '',
|
|
'name': link_name if not _maybe(0.30) else '',
|
|
'source-node': node if not _maybe(0.30) else '',
|
|
'cipher-suite': cipher_name if not _maybe(0.30) else '',
|
|
'ecdhe-named-curve': curve_name if not _maybe(0.30) else '',
|
|
'0cph': cipher_hex if not _maybe(0.30) else '',
|
|
'0crv': curve_hex if not _maybe(0.30) else '',
|
|
'0rnd': rnd if not _maybe(0.30) else '',
|
|
'0rnt': '' if _maybe(0.30) else rnt_val,
|
|
'row': i + 1,
|
|
'time': tm_str if not _maybe(0.30) else '',
|
|
'timestamp': ts_str if not _maybe(0.10) else '',
|
|
':ips.latd': src_lat_out,
|
|
':ips.lond': src_lon_out,
|
|
':ipd.latd': round(dst_lat, 6),
|
|
':ipd.lond': round(dst_lon, 6),
|
|
':ips.ispn': src_isp,
|
|
':ipd.ispn': dst_isp,
|
|
':ips.orgn': src_org,
|
|
':ipd.orgn': dst_org,
|
|
':ips.city': src_city,
|
|
':ipd.city': dst_city,
|
|
})
|
|
|
|
sys.stdout.write('\n')
|
|
sys.stdout.flush()
|
|
return pl.DataFrame(rows)
|
|
|
|
|
|
# ===================================================================
|
|
# CSV save & CLI
|
|
# ===================================================================
|
|
|
|
EXPECTED_COLUMNS: list[str] = [
|
|
':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',
|
|
'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',
|
|
]
|
|
|
|
|
|
def save_csv(df: pl.DataFrame, path: str):
|
|
"""Write DataFrame to CSV with full 44-column validation."""
|
|
out_path = Path(path)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Validate column count
|
|
actual_cols = df.columns
|
|
if len(actual_cols) != 44:
|
|
print(f'WARNING: Expected 44 columns, got {len(actual_cols)}')
|
|
missing = set(EXPECTED_COLUMNS) - set(actual_cols)
|
|
extra = set(actual_cols) - set(EXPECTED_COLUMNS)
|
|
if missing:
|
|
print(f' Missing: {sorted(missing)}')
|
|
if extra:
|
|
print(f' Extra: {sorted(extra)}')
|
|
|
|
df.write_csv(out_path)
|
|
size_kb = out_path.stat().st_size / 1024
|
|
print(f'Generated: {path} ({len(df)} rows, {size_kb:.0f} KB, {len(actual_cols)} cols)')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import argparse
|
|
parser = argparse.ArgumentParser(description='Generate synthetic TLS flow test data (3 traffic profiles)')
|
|
parser.add_argument('--rows', type=int, default=1000, help='Number of rows (default: 1000)')
|
|
parser.add_argument('--output', type=str, default=None, help='Output CSV path')
|
|
parser.add_argument('--globe', action='store_true',
|
|
help='Generate 3D globe visualisation test data (GeoIP-resolvable IPs)')
|
|
args = parser.parse_args()
|
|
|
|
if args.globe:
|
|
output = args.output or 'data/globe_test.csv'
|
|
print(f'Generating {args.rows} globe rows...')
|
|
print('Progress: ', end='', flush=True)
|
|
df = generate_globe_test_data(args.rows)
|
|
save_csv(df, output)
|
|
|
|
# Globe-specific stats
|
|
plus_cnt = df.filter(pl.col(':ips.latd') == '+').height
|
|
blank_cnt = df.filter(pl.col(':ips.lond') == '').height
|
|
print(f'\nGlobe data stats:')
|
|
print(f' :ips.latd "+": {plus_cnt}/{len(df)} ({plus_cnt/len(df)*100:.1f}%)')
|
|
print(f' :ips.lond "": {blank_cnt}/{len(df)} ({blank_cnt/len(df)*100:.1f}%)')
|
|
else:
|
|
output = args.output or 'data/test_flows.csv'
|
|
print(f'Generating {args.rows} rows (A:60% Normal, B:20% Proxy, C:20% Malicious)...')
|
|
print('Progress: ', end='', flush=True)
|
|
df = generate_tls_flows(args.rows)
|
|
save_csv(df, output)
|
|
|
|
# Profile distribution
|
|
print(f'\nProfile distribution:')
|
|
for prefix, label in [('A', 'A-Normal'), ('B', 'B-Proxy'), ('C', 'C-Malicious')]:
|
|
# Profile isn't stored as a column, so estimate from IP patterns
|
|
pass
|