"""Aggregate flow-level data into entity-level profiles. The main entry point is :func:`aggregate_by_entity`, which groups a Polars :class:`LazyFrame` by one or more entity columns (e.g. ``src_ip``, ``sni``, ``user``) and computes per-entity aggregates across flow, destination, TLS, protocol, and time dimensions. Only features whose source columns exist in the provided schema are computed, so the module works with any CSV schema without assumptions. """ from __future__ import annotations import logging from typing import Optional import polars as pl import numpy as np import logging logger = logging.getLogger(__name__) from .type_classifier import DataType _agg_logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Keyword sets for schema-independent column discovery # --------------------------------------------------------------------------- # Flow stats — user exact column names only _BYTES_SENT_KEYWORDS = ('8ack', '8byt',) _BYTES_REV_KEYWORDS = () # no user column for reverse bytes _DURATION_KEYWORDS = ('4dur',) _PACKETS_KEYWORDS = ('8ppk', '8pak',) _PACKETS_REV_KEYWORDS = () # no user column for reverse packets # Destination diversity — user exact column names only _DST_IP_KEYWORDS = (':ips', ':ipd') _DST_PORT_KEYWORDS = (':prs', ':prd') _DST_NET_KEYWORDS = () # no user column for destination network # TLS features — user exact column names only _CIPHER_KEYWORDS = ('cipher_suite', '0cph') _TLS_VERSION_KEYWORDS = ('0ver',) _SNI_KEYWORDS = ('snam', 'cnam') # Protocol features — user exact column names only _PROTO_KEYWORDS = ('1ipp',) _TCP_FLAGS_KEYWORDS = () # no user column for TCP flags # Geo-location features — user exact column names only _LAT_KEYWORDS = (':ips.latd', ':ipd.latd', 'latd') _LON_KEYWORDS = (':ips.lond', ':ipd.lond', 'lond') # Time patterns — user exact column names only _TIMESTAMP_KEYWORDS = ('time', 'timestamp', '8dbd') # --------------------------------------------------------------------------- # IP subnet helpers for prefix-based aggregation # --------------------------------------------------------------------------- def ip_to_subnet(ip_str: str, mask: int) -> str: """Convert ``10.0.1.15/24`` → ``10.0.1.0/24``, ``10.0.1.250/28`` → ``10.0.1.240/28``. Invalid IPs / ``None`` / empty string are returned unchanged. Supports ``/24`` (0–255 last octet) and ``/28`` (240–255 in steps of 16) masks. """ if not ip_str or not isinstance(ip_str, str): return ip_str try: parts = [int(o) for o in ip_str.strip().split('.')] if len(parts) != 4 or any(p < 0 or p > 255 for p in parts): return ip_str ip_int = (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3] subnet_int = ip_int & (0xFFFFFFFF << (32 - mask)) return ( f"{(subnet_int >> 24) & 255}." f"{(subnet_int >> 16) & 255}." f"{(subnet_int >> 8) & 255}." f"{subnet_int & 255}/{mask}" ) except (ValueError, IndexError, TypeError): return ip_str def apply_subnet_aggregation( lf: pl.LazyFrame, ip_columns: list[str], subnet_masks: list[int], ) -> pl.LazyFrame: """Apply subnet masking to IP columns in the LazyFrame. Each IP column is transformed to its subnet prefix for each mask. If multiple masks exist, the first matching mask is applied (earliest mask in *subnet_masks* wins). """ if not subnet_masks or not ip_columns: return lf schema = lf.collect_schema().names() for mask in subnet_masks: mask_exprs = [] for col in ip_columns: if col in schema: mask_exprs.append( pl.col(col) .map_batches( lambda s, m=mask: s.cast(pl.Utf8).map_batches( lambda x, mm=m: pl.Series( [ip_to_subnet(v, mm) for v in x.to_list()] ), return_dtype=pl.Utf8, ), return_dtype=pl.Utf8, ) .alias(col) ) if mask_exprs: lf = lf.with_columns(mask_exprs) return lf def _find_column(schema: dict[str, str], keywords: tuple[str, ...]) -> Optional[str]: """Return the first column in *schema* whose lowercased name matches any of *keywords* (exact or ``_``-delimited match).""" for col in schema: normalised = col.lower().lstrip(':+').replace('-', '_').replace('.', '_').replace(' ', '_') for kw in sorted(keywords): kw_s = kw.lower().lstrip(':+') if kw_s == normalised or normalised.endswith(f'_{kw_s}') or normalised.startswith(f'{kw_s}_'): return col return None def _timestamp_col(schema: dict[str, str]) -> Optional[str]: """Detect a timestamp column -- user exact column names only.""" for preference in ('timestamp', 'time', '8dbd'): if preference in schema: return preference return _find_column(schema, _TIMESTAMP_KEYWORDS) # --------------------------------------------------------------------------- # Aggregation plan builder # --------------------------------------------------------------------------- def build_aggregation_params( schema: dict[str, str], type_map: dict[str, DataType] | None = None, ) -> list[tuple[str, pl.Expr]]: """Build a list of ``(feature_name, pl.Expr)`` pairs for every aggregate that can be computed from the columns present in *schema*. Each expression is a column-level aggregation suitable for use inside ``pl.LazyFrame.group_by(...).agg(...)``. """ aggs: list[tuple[str, pl.Expr]] = [] # ── Flow stats ─────────────────────────────────────────────────────── # flow_count: always computed via count() aggs.append(('flow_count', pl.len())) bytes_sent_col = _find_column(schema, _BYTES_SENT_KEYWORDS) if bytes_sent_col: aggs.append(('total_bytes_sent', pl.sum(bytes_sent_col).cast(pl.Int64))) # Also store raw column name for downstream use aggs.append(('_bytes_sent_col', pl.lit(bytes_sent_col))) aggs.append(('_bytes_sent_expr', pl.lit(bytes_sent_col))) else: aggs.append(('total_bytes_sent', pl.lit(None, dtype=pl.Int64))) aggs.append(('_bytes_sent_col', pl.lit(''))) aggs.append(('_bytes_sent_expr', pl.lit(''))) _agg_logger.info(f'[FIND_COL] keywords=bytes_sent matched={bytes_sent_col} dtype={schema.get(bytes_sent_col, "N/A") if bytes_sent_col else "None"}') packets_col = _find_column(schema, _PACKETS_KEYWORDS) if packets_col: aggs.append(('total_packets', pl.sum(packets_col).cast(pl.Int64))) else: aggs.append(('total_packets', pl.lit(None, dtype=pl.Int64))) _agg_logger.info(f'[FIND_COL] keywords=packets matched={packets_col} dtype={schema.get(packets_col, "N/A") if packets_col else "None"}') # ── Destination diversity ──────────────────────────────────────────── dst_ip_col = _find_column(schema, _DST_IP_KEYWORDS) if dst_ip_col: aggs.append(('unique_dst_ips', pl.n_unique(dst_ip_col))) aggs.append(('_dst_ip_col', pl.lit(dst_ip_col))) else: aggs.append(('unique_dst_ips', pl.lit(None, dtype=pl.UInt32))) aggs.append(('_dst_ip_col', pl.lit(''))) _agg_logger.info(f'[FIND_COL] keywords=dst_ip matched={dst_ip_col} dtype={schema.get(dst_ip_col, "N/A") if dst_ip_col else "None"}') dst_port_col = _find_column(schema, _DST_PORT_KEYWORDS) if dst_port_col: aggs.append(('unique_dst_ports', pl.n_unique(dst_port_col))) aggs.append(('_dst_port_col', pl.lit(dst_port_col))) else: aggs.append(('unique_dst_ports', pl.lit(None, dtype=pl.UInt32))) aggs.append(('_dst_port_col', pl.lit(''))) _agg_logger.info(f'[FIND_COL] keywords=dst_port matched={dst_port_col} dtype={schema.get(dst_port_col, "N/A") if dst_port_col else "None"}') # ── Protocol features ──────────────────────────────────────────────── proto_col = _find_column(schema, _PROTO_KEYWORDS) if proto_col: aggs.append(('unique_protocols', pl.n_unique(proto_col))) aggs.append(('_proto_col', pl.lit(proto_col))) else: aggs.append(('unique_protocols', pl.lit(None, dtype=pl.UInt32))) aggs.append(('_proto_col', pl.lit(''))) _agg_logger.info(f'[FIND_COL] keywords=proto matched={proto_col} dtype={schema.get(proto_col, "N/A") if proto_col else "None"}') # ── Enhanced TLS analysis features ──────────────────────────────────── # has_sni: count of non-null SNI values sni_col = _find_column(schema, _SNI_KEYWORDS) if sni_col: aggs.append(('has_sni', pl.col(sni_col).is_not_null().sum())) aggs.append(('sni_missing_ratio', pl.col(sni_col).is_null().mean())) aggs.append(('avg_sni_length', pl.col(sni_col).cast(pl.Utf8).str.len_bytes().mean())) aggs.append(('max_sni_length', pl.col(sni_col).cast(pl.Utf8).str.len_bytes().max())) # TLS version risk tls_version_col = _find_column(schema, ('0ver',)) if tls_version_col: aggs.append(('tls_modern_ratio', pl.when(pl.col(tls_version_col).is_in(['tls_1_3'])).then(1) .otherwise(0).cast(pl.Float64).mean())) aggs.append(('tls_old_ratio', pl.when(pl.col(tls_version_col).is_in(['tls_1_0', 'tls_1_1', 'ssl_2_0', 'ssl_3_0'])).then(1) .otherwise(0).cast(pl.Float64).mean())) aggs.append(('unique_tls_versions', pl.n_unique(tls_version_col))) # Modern cipher ratio (AEAD = modern) cipher_col = _find_column(schema, ('0cph', 'cipher-suite')) if cipher_col: aggs.append(('aead_ratio', pl.when(pl.col(cipher_col).str.contains('gcm|chacha20|poly1305')).then(1) .otherwise(0).cast(pl.Float64).mean())) aggs.append(('weak_cipher_ratio', pl.when(pl.col(cipher_col).str.contains('cbc|rc4|null')).then(1) .otherwise(0).cast(pl.Float64).mean())) aggs.append(('ecdhe_ratio', pl.when(pl.col(cipher_col).str.contains('ecdhe')).then(1) .otherwise(0).cast(pl.Float64).mean())) aggs.append(('unique_ciphers', pl.n_unique(cipher_col))) # Non-standard port detection (:prd is often String type) prd_col = _find_column(schema, (':prd',)) if prd_col: prd_str = pl.col(prd_col).cast(pl.Utf8) aggs.append(('non_standard_port_ratio', pl.when(prd_str != '443').then(1) .otherwise(0).cast(pl.Float64).mean())) aggs.append(('port_443_ratio', pl.when(prd_str == '443').then(1) .otherwise(0).cast(pl.Float64).mean())) # Bytes per packet analysis bytes_col = _find_column(schema, ('8ack',)) pkts_col = _find_column(schema, ('8ppk',)) if bytes_col and pkts_col: bpp = pl.col(bytes_col).cast(pl.Float64) / pl.col(pkts_col).cast(pl.Float64).clip(1) aggs.append(('avg_bytes_per_packet', bpp.mean())) aggs.append(('std_bytes_per_packet', bpp.std())) aggs.append(('min_bytes_per_packet', bpp.min())) aggs.append(('max_bytes_per_packet', bpp.max())) # Duration analysis dur_col = _find_column(schema, ('4dur',)) if dur_col: aggs.append(('avg_duration', pl.col(dur_col).cast(pl.Float64).mean())) aggs.append(('std_duration', pl.col(dur_col).cast(pl.Float64).std())) aggs.append(('max_duration', pl.col(dur_col).cast(pl.Float64).max())) # Recoverable/resumed ratio (columns already converted to boolean) cnrs_col = _find_column(schema, ('cnrs',)) if cnrs_col: aggs.append(('recoverable_ratio', pl.col(cnrs_col).cast(pl.Float64).mean())) isrs_col = _find_column(schema, ('isrs',)) if isrs_col: aggs.append(('resumed_ratio', pl.col(isrs_col).cast(pl.Float64).mean())) # Country diversity dcnt_col = _find_column(schema, ('dcnt',)) if dcnt_col: aggs.append(('countries_visited', pl.n_unique(dcnt_col))) # ── Geo-location (latitude / longitude) ──────────────────────────── lat_col = _find_column(schema, _LAT_KEYWORDS) if lat_col: dtype_str = schema.get(lat_col, '').lower() if 'str' in dtype_str or 'utf' in dtype_str: lat_expr = pl.mean(pl.col(lat_col).cast(pl.Float64)) else: lat_expr = pl.mean(lat_col) aggs.append(('avg_latitude', lat_expr.cast(pl.Float64))) else: aggs.append(('avg_latitude', pl.lit(None, dtype=pl.Float64))) _agg_logger.info(f'[FIND_COL] keywords=lat matched={lat_col} dtype={schema.get(lat_col, "N/A") if lat_col else "None"}') lon_col = _find_column(schema, _LON_KEYWORDS) if lon_col: dtype_str = schema.get(lon_col, '').lower() if 'str' in dtype_str or 'utf' in dtype_str: lon_expr = pl.mean(pl.col(lon_col).cast(pl.Float64)) else: lon_expr = pl.mean(lon_col) aggs.append(('avg_longitude', lon_expr.cast(pl.Float64))) else: aggs.append(('avg_longitude', pl.lit(None, dtype=pl.Float64))) _agg_logger.info(f'[FIND_COL] keywords=lon matched={lon_col} dtype={schema.get(lon_col, "N/A") if lon_col else "None"}') # ── Time patterns ──────────────────────────────────────────────────── ts_col = _timestamp_col(schema) if ts_col: # For first/last seen we need min/max of the timestamp column aggs.append(('first_seen', pl.min(ts_col))) aggs.append(('last_seen', pl.max(ts_col))) aggs.append(('_ts_col', pl.lit(ts_col))) # Active hours: unique hour-of-day count # We'll compute this after collecting by extracting hour from timestamps aggs.append(('_has_timestamp', pl.lit(True))) else: aggs.append(('first_seen', pl.lit(None, dtype=pl.Utf8))) aggs.append(('last_seen', pl.lit(None, dtype=pl.Utf8))) aggs.append(('_ts_col', pl.lit(''))) aggs.append(('_has_timestamp', pl.lit(False))) # ── Type-classifier features ────────────────────────────────────── # HEX/URL/IPv4 feature extraction removed — avg_hex_pairs_count, # unique_domains, and unique_24_networks are not useful for TLS analysis. return aggs # --------------------------------------------------------------------------- # Main aggregation entry point # --------------------------------------------------------------------------- # Columns whose ``_``-prefixed names are internal bookkeeping, not user-facing _INTERNAL_COLUMNS = tuple([ '_bytes_sent_col', '_dst_ip_col', '_dst_port_col', '_proto_col', '_ts_col', '_has_timestamp', '_bytes_sent_expr', ]) def aggregate_by_entity( lf: pl.LazyFrame, entity_columns: str | list[str], schema: dict[str, str], type_map: dict[str, DataType] | None = None, ) -> tuple[pl.LazyFrame, list[str]]: """Group flow-level data by *entity_columns* and compute per-entity features. Parameters ---------- lf: A Polars :class:`LazyFrame` containing flow-level data. entity_columns: Column name(s) to group by (e.g. ``'src_ip'``, or ``['src_ip', 'dst_ip']``). schema: Dictionary mapping column names to dtype strings (e.g. from ``SessionStore.get_dataset(...)['schema']``). Returns ------- tuple[pl.LazyFrame, list[str]] ``(aggregated_lazyframe, feature_column_names)`` where the lazyframe has one row per entity and *feature_column_names* lists the user-facing aggregate columns. """ # Normalise to list if isinstance(entity_columns, str): entity_columns = [entity_columns] # ── Boolean indicator column conversion ────────────────────────────── # cnrs/isrs have values "+" (True) or blank (False). Override schema # type and convert to boolean before aggregation. for col in ('cnrs', 'isrs'): if col in schema: schema[col] = 'Boolean' lf = lf.with_columns( pl.when(pl.col(col).cast(pl.Utf8).str.contains(r'\+|True|yes', literal=False)) .then(pl.lit(True)) .otherwise(pl.lit(False)) .alias(col) ) # ── Proxy / Anomaly Detection Scores ────────────────────────────── # 1. Non-standard port ratio (ports other than 443, 80, 53, 8080, 8443) port_col = _find_column(schema, (':prd', 'dst_port', '8prd', 'port')) if port_col: std_ports = [443, 80, 53, 8080, 8443] lf = lf.with_columns( (~pl.col(port_col).cast(pl.Utf8).str.replace(r'\.0$', '').cast(pl.Int64, strict=False) .is_in(std_ports)).cast(pl.Float64) .alias('_non_std_port_ratio') ) # 2. Modern TLS version ratio (TLS 1.2 / 1.3 = 0303 / 0304) tls_col = _find_column(schema, ('0ver', 'tls_version', '_tlsver')) if tls_col: lf = lf.with_columns( pl.col(tls_col).cast(pl.Utf8).str.replace(' ', '') .is_in(['0303', '0304']).cast(pl.Float64) .alias('_modern_tls_ratio') ) # 3. SNI missing ratio (null or empty string) sni_col = _find_column(schema, _SNI_KEYWORDS) if sni_col: lf = lf.with_columns( pl.col(sni_col).cast(pl.Utf8) .map_batches(lambda s: (s == '') | s.is_null(), return_dtype=pl.Boolean) .cast(pl.Float64) .alias('_sni_missing_ratio') ) # Build aggregation expressions based on the schema (and optional type_map) agg_params = build_aggregation_params(schema, type_map=type_map) # Separate feature names and expressions feature_names: list[str] = [] exprs: list[pl.Expr] = [] for name, expr in agg_params: exprs.append(expr.alias(name)) if not name.startswith('_'): feature_names.append(name) # Add mean aggregation for scoring pre-computed columns _lf_cols = lf.collect_schema().names() for col_name, alias_name in [ ('_non_std_port_ratio', 'non_std_port_rate'), ('_modern_tls_ratio', 'modern_tls_rate'), ]: if col_name in _lf_cols: exprs.append(pl.col(col_name).mean().alias(alias_name)) feature_names.append(alias_name) # Check entity columns exist missing = [c for c in entity_columns if c not in schema] if missing: raise ValueError( f"Entity column(s) '{missing}' not found in schema. " f"Available columns: {list(schema.keys())}" ) # Perform aggregation aggregated_lf = lf.group_by(entity_columns).agg(exprs) # ── Adaptive anomaly scoring (data-driven, no hardcoded weights) ── aggregated_lf = _add_adaptive_scores(aggregated_lf, feature_names) _agg_cols = aggregated_lf.collect_schema().names() if 'proxy_score' in _agg_cols: feature_names.append('proxy_score') if 'threat_score' in _agg_cols: feature_names.append('threat_score') if 'risk_level' in _agg_cols: feature_names.append('risk_level') # Post-process: compute active_hours_count if a timestamp column exists # (We compute this as a second pass since it requires extraction logic) any(name == '_has_timestamp' for name, _ in agg_params) # ── Post-aggregation: log number of entities for adaptive sampling info ── try: n_entities = aggregated_lf.select(pl.len()).collect(streaming=True).item() except Exception: n_entities = 0 if n_entities > 50000: logger.info('[SCORE] %s entities — adaptive scoring will use sampled PCA weights', n_entities) # Filter to only user-facing feature columns names (excluding _internal) user_features = [f for f in feature_names if f not in _INTERNAL_COLUMNS] return aggregated_lf, user_features # ── Adaptive anomaly scoring (data-driven, no hardcoded weights) ────── _ANOMALY_FEATURE_COLS = [ 'non_std_port_rate', 'modern_tls_rate', 'sni_missing_ratio', 'recoverable_ratio', 'flow_count', 'total_bytes_sent', 'total_packets', 'unique_dst_ips', 'unique_dst_ports', 'unique_protocols', 'has_sni', 'avg_sni_length', 'countries_visited', ] def _add_adaptive_scores(lf: pl.LazyFrame, feature_names: list[str]) -> pl.LazyFrame: """Add anomaly scores using data-driven weights (PCA + IQR + percentile). Steps: 1. Find which anomaly feature columns exist in the aggregated data 2. Collect a sample (max 10K rows) for PCA weight learning 3. Learn feature weights from PCA first component loadings 4. Compute weighted proxy_score across ALL rows (vectorized, no per-row ops) 5. Add percentile rank calibration 6. Infer risk thresholds from data distribution """ _agg_cols = lf.collect_schema().names() avail = [c for c in _ANOMALY_FEATURE_COLS if c in _agg_cols] if not avail: return lf try: # Step 1-2: Sample up to 10K rows for weight learning n_rows = lf.select(pl.len()).collect(streaming=True).item() sample_lf = lf if n_rows <= 10000 else lf.sample(n=10000, seed=42) df_sample = sample_lf.select(avail).collect(streaming=True) mat = df_sample.to_numpy() mat = np.nan_to_num(mat, nan=0.0) # Step 3: PCA weight learning from sklearn.decomposition import PCA n_comp = min(1, min(mat.shape) - 1) if n_comp < 1 or mat.shape[1] < 2: return lf pca = PCA(n_components=1, random_state=42).fit(mat) raw_weights = np.abs(pca.components_[0]) # Normalise to sum = 1 weights = dict(zip(avail, raw_weights / raw_weights.sum())) # Step 4: Weighted sum expression (all vectorised, no loop) expr_parts = [] for col, w in weights.items(): if col == 'modern_tls_rate': # modern_tls_rate is good — invert for anomaly scoring expr_parts.append((1.0 - pl.col(col).fill_null(0.5)) * w) else: expr_parts.append(pl.col(col).fill_null(0.0) * w) if expr_parts: proxy_expr = expr_parts[0] for p in expr_parts[1:]: proxy_expr = proxy_expr + p else: return lf lf = lf.with_columns(proxy_expr.clip(0, 1).alias('_raw_score')) # Step 5: Percentile rank calibration (0-1, relative position) # Polars doesn't have native rank%, so we approximate with # quantile-based bucketing on the collected sample thresholds sample_scores = df_sample.select( pl.sum_horizontal([ (1.0 - pl.col('modern_tls_rate').fill_null(0.5)) * weights.get('modern_tls_rate', 0) if c == 'modern_tls_rate' else pl.col(c).fill_null(0.0) * weights.get(c, 0) for c in avail ]).clip(0, 1) ).to_series().to_numpy() # Compute IQR thresholds from sample q25, q50, q75 = np.percentile(sample_scores, [25, 50, 75]) iqr = q75 - q25 upper_fence = float(q75 + 1.5 * iqr) if iqr > 0 else 0.9 extreme_fence = float(q75 + 3.0 * iqr) if iqr > 0 else 0.99 # Step 6: Risk level labels (0.0-1.0) lf = lf.with_columns([ pl.col('_raw_score').alias('proxy_score'), pl.when(pl.col('_raw_score') >= extreme_fence) .then(pl.lit('critical')) .when(pl.col('_raw_score') >= upper_fence) .then(pl.lit('suspicious')) .when(pl.col('_raw_score') >= q50) .then(pl.lit('watch')) .otherwise(pl.lit('normal')) .alias('risk_level'), (pl.col('_raw_score') * 0.6 + 0.4 * (pl.col('_raw_score') > q75).cast(pl.Float64)) .alias('threat_score'), ]).drop('_raw_score') valid_counts = {c: int((mat[:, i] != 0).sum()) for i, c in enumerate(avail)} logger.info( '[SCORE] adaptive weights from PCA: %s | thresholds: p50=%.3f upper=%.3f extreme=%.3f | sample=%d/%d', {c: f'{w:.3f}' for c, w in sorted(weights.items(), key=lambda x: -x[1])}, float(q50), upper_fence, extreme_fence, min(n_rows, 10000), n_rows, ) except Exception as exc: logger.warning('[SCORE] adaptive scoring skipped: %s', exc) return lf