Cybersecurity-Projects/PROJECTS/advanced/ai-threat-detection/backend/tests/test_features.py

593 lines
16 KiB
Python

"""
©AngelaMos | 2026
test_features.py
"""
from datetime import datetime, timezone
from app.core.features.extractor import extract_request_features
from app.core.ingestion.parsers import ParsedLogEntry
FEATURE_KEYS = {
"http_method",
"path_depth",
"path_entropy",
"path_length",
"query_string_length",
"query_param_count",
"has_encoded_chars",
"has_double_encoding",
"status_code",
"status_class",
"response_size",
"hour_of_day",
"day_of_week",
"is_weekend",
"ua_length",
"ua_entropy",
"is_known_bot",
"is_known_scanner",
"has_attack_pattern",
"special_char_ratio",
"file_extension",
"country_code",
"is_private_ip",
}
def _make_entry(
ip: str = "93.184.216.34",
timestamp: datetime | None = None,
method: str = "GET",
path: str = "/api/v1/users",
query_string: str = "",
status_code: int = 200,
response_size: int = 1234,
referer: str = "",
user_agent: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
) -> ParsedLogEntry:
"""
Build a ParsedLogEntry with sensible defaults for testing.
"""
if timestamp is None:
timestamp = datetime(2026, 2, 11, 14, 30, 0, tzinfo=timezone.utc)
return ParsedLogEntry(
ip=ip,
timestamp=timestamp,
method=method,
path=path,
query_string=query_string,
status_code=status_code,
response_size=response_size,
referer=referer,
user_agent=user_agent,
raw_line="",
)
def test_extract_returns_all_23_feature_keys() -> None:
"""
Feature dict contains exactly the 23 per-request keys.
"""
features = extract_request_features(_make_entry())
assert set(features.keys()) == FEATURE_KEYS
def test_path_depth() -> None:
"""
Path depth counts non-empty segments between slashes.
"""
assert extract_request_features(_make_entry(path="/"))["path_depth"] == 0
assert extract_request_features(_make_entry(path="/api"))["path_depth"] == 1
assert extract_request_features(
_make_entry(path="/api/v1/users")
)["path_depth"] == 3
def test_path_entropy_high_vs_low() -> None:
"""
Random-character paths have higher entropy than simple paths.
"""
low = extract_request_features(
_make_entry(path="/index.html")
)["path_entropy"]
high = extract_request_features(
_make_entry(path="/x8Kp2mQz7wR4vL1n")
)["path_entropy"]
assert high > low
def test_query_string_features() -> None:
"""
Query param count and length are extracted correctly.
"""
features = extract_request_features(
_make_entry(query_string="page=1&sort=name&limit=50")
)
assert features["query_param_count"] == 3
assert features["query_string_length"] == len("page=1&sort=name&limit=50")
empty = extract_request_features(_make_entry(query_string=""))
assert empty["query_param_count"] == 0
assert empty["query_string_length"] == 0
def test_url_encoding_detection() -> None:
"""
Percent-encoded sequences are detected in path and query.
"""
encoded = extract_request_features(
_make_entry(path="/search", query_string="q=%27OR+1%3D1")
)
assert encoded["has_encoded_chars"] is True
clean = extract_request_features(
_make_entry(path="/index.html", query_string="")
)
assert clean["has_encoded_chars"] is False
def test_double_encoding_detection() -> None:
"""
Double-encoded sequences like %2527 are flagged.
"""
double = extract_request_features(
_make_entry(path="/path%2527trick")
)
assert double["has_double_encoding"] is True
single = extract_request_features(
_make_entry(path="/path%27normal")
)
assert single["has_double_encoding"] is False
def test_status_class() -> None:
"""
Status class groups status codes into Nxx buckets.
"""
assert extract_request_features(
_make_entry(status_code=200)
)["status_class"] == "2xx"
assert extract_request_features(
_make_entry(status_code=404)
)["status_class"] == "4xx"
assert extract_request_features(
_make_entry(status_code=503)
)["status_class"] == "5xx"
def test_temporal_features() -> None:
"""
Hour, day of week, and weekend flag derived from timestamp.
"""
wednesday_2pm = datetime(2026, 2, 11, 14, 0, 0, tzinfo=timezone.utc)
features = extract_request_features(
_make_entry(timestamp=wednesday_2pm)
)
assert features["hour_of_day"] == 14
assert features["day_of_week"] == 2
assert features["is_weekend"] is False
saturday_3am = datetime(2026, 2, 14, 3, 0, 0, tzinfo=timezone.utc)
weekend = extract_request_features(
_make_entry(timestamp=saturday_3am)
)
assert weekend["hour_of_day"] == 3
assert weekend["day_of_week"] == 5
assert weekend["is_weekend"] is True
def test_ua_bot_detection() -> None:
"""
Known bot user agents are flagged.
"""
bot = extract_request_features(
_make_entry(
user_agent="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
)
)
assert bot["is_known_bot"] is True
normal = extract_request_features(
_make_entry(user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
)
assert normal["is_known_bot"] is False
def test_ua_scanner_detection() -> None:
"""
Known vulnerability scanner user agents are flagged.
"""
nikto = extract_request_features(
_make_entry(user_agent="Mozilla/5.00 (Nikto/2.1.6)")
)
assert nikto["is_known_scanner"] is True
sqlmap = extract_request_features(
_make_entry(user_agent="sqlmap/1.8")
)
assert sqlmap["is_known_scanner"] is True
def test_attack_pattern_detection() -> None:
"""
SQLi, XSS, and path traversal patterns in paths are detected.
"""
sqli = extract_request_features(
_make_entry(path="/users", query_string="id=1' OR 1=1--")
)
assert sqli["has_attack_pattern"] is True
xss = extract_request_features(
_make_entry(path="/comment", query_string="body=<script>alert(1)</script>")
)
assert xss["has_attack_pattern"] is True
traversal = extract_request_features(
_make_entry(path="/static/../../etc/passwd")
)
assert traversal["has_attack_pattern"] is True
clean = extract_request_features(
_make_entry(path="/api/v1/health")
)
assert clean["has_attack_pattern"] is False
def test_special_char_ratio() -> None:
"""
Paths with many non-alphanumeric characters have higher ratios.
"""
clean = extract_request_features(
_make_entry(path="/api/users")
)["special_char_ratio"]
noisy = extract_request_features(
_make_entry(path="/<script>alert('xss')</script>")
)["special_char_ratio"]
assert noisy > clean
def test_private_ip_detection() -> None:
"""
RFC 1918 and loopback addresses are classified as private.
"""
assert extract_request_features(
_make_entry(ip="192.168.1.1")
)["is_private_ip"] is True
assert extract_request_features(
_make_entry(ip="127.0.0.1")
)["is_private_ip"] is True
assert extract_request_features(
_make_entry(ip="8.8.8.8")
)["is_private_ip"] is False
def test_file_extension() -> None:
"""
File extension is extracted from the path.
"""
assert extract_request_features(
_make_entry(path="/style.css")
)["file_extension"] == ".css"
assert extract_request_features(
_make_entry(path="/api/users")
)["file_extension"] == ""
def test_country_code_passthrough() -> None:
"""
Country code is passed through from the caller.
"""
features = extract_request_features(
_make_entry(), country_code="US"
)
assert features["country_code"] == "US"
features_empty = extract_request_features(_make_entry())
assert features_empty["country_code"] == ""
import time
import fakeredis.aioredis
import pytest
from app.core.features.aggregator import WindowAggregator
AGGREGATOR_KEYS = {
"req_count_1m",
"req_count_5m",
"req_count_10m",
"error_rate_5m",
"unique_paths_5m",
"unique_uas_10m",
"method_entropy_5m",
"avg_response_size_5m",
"status_diversity_5m",
"path_depth_variance_5m",
"inter_request_time_mean",
"inter_request_time_std",
}
@pytest.fixture
async def aggregator():
"""
WindowAggregator backed by an in-memory fake Redis.
"""
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
yield WindowAggregator(redis)
await redis.aclose()
async def _record(
agg: WindowAggregator,
ip: str = "10.0.0.1",
request_id: str = "r-001",
path: str = "/api/users",
path_depth: int = 2,
method: str = "GET",
status_code: int = 200,
user_agent: str = "Mozilla/5.0",
response_size: int = 1024,
timestamp: float | None = None,
) -> dict[str, float]:
"""
Shorthand for record_and_aggregate with sensible defaults.
"""
return await agg.record_and_aggregate(
ip=ip,
request_id=request_id,
path=path,
path_depth=path_depth,
method=method,
status_code=status_code,
user_agent=user_agent,
response_size=response_size,
timestamp=timestamp or time.time(),
)
@pytest.mark.asyncio
async def test_aggregator_single_request(aggregator) -> None:
"""
A single request yields count of 1.
"""
result = await _record(aggregator)
assert set(result.keys()) == AGGREGATOR_KEYS
assert result["req_count_1m"] == 1
assert result["req_count_5m"] == 1
assert result["req_count_10m"] == 1
@pytest.mark.asyncio
async def test_aggregator_ten_requests(aggregator) -> None:
"""
Ten requests within 30 seconds all count in the 1-minute window.
"""
now = time.time()
result = None
for i in range(10):
result = await _record(
aggregator,
request_id=f"r-{i:03d}",
timestamp=now - 30 + i * 3,
)
assert result is not None
assert result["req_count_1m"] == 10
@pytest.mark.asyncio
async def test_aggregator_error_rate(aggregator) -> None:
"""
Error rate is the ratio of 4xx/5xx responses.
"""
now = time.time()
for i in range(8):
await _record(
aggregator,
request_id=f"ok-{i}",
status_code=200,
timestamp=now - 60 + i,
)
for i in range(2):
result = await _record(
aggregator,
request_id=f"err-{i}",
status_code=404,
timestamp=now - 10 + i,
)
assert result["error_rate_5m"] == pytest.approx(0.2, abs=0.01)
@pytest.mark.asyncio
async def test_aggregator_unique_paths(aggregator) -> None:
"""
Unique paths counts distinct URL paths in the window.
"""
now = time.time()
paths = ["/api/users", "/api/posts", "/api/users", "/api/health"]
result = None
for i, p in enumerate(paths):
result = await _record(
aggregator,
request_id=f"r-{i}",
path=p,
timestamp=now - 10 + i,
)
assert result is not None
assert result["unique_paths_5m"] == 3
@pytest.mark.asyncio
async def test_aggregator_unique_uas(aggregator) -> None:
"""
Unique UAs counts distinct user agents in the 10-minute window.
"""
now = time.time()
uas = ["Mozilla/5.0", "curl/8.0", "Mozilla/5.0", "python-httpx/0.28"]
result = None
for i, ua in enumerate(uas):
result = await _record(
aggregator,
request_id=f"r-{i}",
user_agent=ua,
timestamp=now - 10 + i,
)
assert result is not None
assert result["unique_uas_10m"] == 3
@pytest.mark.asyncio
async def test_aggregator_ttl_set(aggregator) -> None:
"""
All Redis keys are set with a 900-second TTL.
"""
await _record(aggregator, ip="5.5.5.5")
ttl = await aggregator._redis.ttl("ip:5.5.5.5:requests")
assert 0 < ttl <= 900
@pytest.mark.asyncio
async def test_aggregator_window_boundary(aggregator) -> None:
"""
Requests outside the 1-minute window are excluded from req_count_1m
but still counted in req_count_5m.
"""
now = time.time()
await _record(
aggregator,
request_id="old",
timestamp=now - 120,
)
result = await _record(
aggregator,
request_id="new",
timestamp=now,
)
assert result["req_count_1m"] == 1
assert result["req_count_5m"] == 2
from app.core.features.encoder import encode_for_inference
from app.core.features.mappings import FEATURE_ORDER, METHOD_MAP, STATUS_CLASS_MAP
def _full_features() -> dict[str, int | float | bool | str]:
"""
Build a complete 35-key feature dict with realistic values.
"""
return {
"http_method": "GET",
"path_depth": 3,
"path_entropy": 3.12,
"path_length": 14,
"query_string_length": 6,
"query_param_count": 1,
"has_encoded_chars": False,
"has_double_encoding": False,
"status_code": 200,
"status_class": "2xx",
"response_size": 1234,
"hour_of_day": 14,
"day_of_week": 2,
"is_weekend": False,
"ua_length": 42,
"ua_entropy": 4.01,
"is_known_bot": False,
"is_known_scanner": False,
"has_attack_pattern": False,
"special_char_ratio": 0.21,
"file_extension": ".html",
"country_code": "US",
"is_private_ip": False,
"req_count_1m": 5.0,
"req_count_5m": 20.0,
"req_count_10m": 45.0,
"error_rate_5m": 0.1,
"unique_paths_5m": 8.0,
"unique_uas_10m": 2.0,
"method_entropy_5m": 0.5,
"avg_response_size_5m": 2048.0,
"status_diversity_5m": 3.0,
"path_depth_variance_5m": 1.2,
"inter_request_time_mean": 250.0,
"inter_request_time_std": 80.0,
}
def test_encoder_output_shape_and_type() -> None:
"""
Encoded vector has exactly 35 float elements.
"""
result = encode_for_inference(_full_features())
assert len(result) == 35
assert all(isinstance(v, float) for v in result)
def test_encoder_method_ordinal() -> None:
"""
HTTP methods map to deterministic ordinal indices.
"""
features = _full_features()
features["http_method"] = "GET"
vec = encode_for_inference(features)
assert vec[FEATURE_ORDER.index("http_method")] == float(METHOD_MAP["GET"])
features["http_method"] = "POST"
vec = encode_for_inference(features)
assert vec[FEATURE_ORDER.index("http_method")] == float(METHOD_MAP["POST"])
def test_encoder_status_class_ordinal() -> None:
"""
Status classes map to deterministic ordinal indices.
"""
features = _full_features()
features["status_class"] = "4xx"
vec = encode_for_inference(features)
idx = FEATURE_ORDER.index("status_class")
assert vec[idx] == float(STATUS_CLASS_MAP["4xx"])
def test_encoder_boolean_to_float() -> None:
"""
Boolean features encode to 0.0 or 1.0.
"""
features = _full_features()
features["is_known_bot"] = True
features["is_weekend"] = False
vec = encode_for_inference(features)
assert vec[FEATURE_ORDER.index("is_known_bot")] == 1.0
assert vec[FEATURE_ORDER.index("is_weekend")] == 0.0
def test_encoder_numerical_passthrough() -> None:
"""
Numerical features pass through as raw float values.
"""
features = _full_features()
vec = encode_for_inference(features)
assert vec[FEATURE_ORDER.index("path_depth")] == 3.0
assert vec[FEATURE_ORDER.index("response_size")] == 1234.0
assert vec[FEATURE_ORDER.index("req_count_1m")] == 5.0
def test_encoder_unknown_categorical() -> None:
"""
Unknown categorical values fall back to 0.
"""
features = _full_features()
features["http_method"] = "BREW"
features["status_class"] = "9xx"
vec = encode_for_inference(features)
assert vec[FEATURE_ORDER.index("http_method")] == 0.0
assert vec[FEATURE_ORDER.index("status_class")] == 0.0