diff --git a/HANDOFF.md b/HANDOFF.md index 73c9078..a10a594 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,61 +1,47 @@ # Handoff -**Updated:** 2026-10-14 | **By:** Copilot | **Branch:** main | **Last commit:** pending +**Updated:** 2026-10-18 | **By:** User | **Branch:** phase-4-env-rules | **Last commit:** 6960b1a ## Current task -- ID: P4-01 — environment signal schema +- ID: P5-01 - Settings and score store - State: ready to start. -## Done in P3-11 - -- Added `export_keyboard_onnx()` with median imputation, standard scaling, and - Logistic Regression. -- Excluded K3 training rows and recorded that policy in `metadata.json`. -- Added `scripts/export_keyboard_onnx.py` and the - `train-keyboard --stage onnx` task. -- Added native-vs-ONNX parity and metadata tests. -- Declared `skl2onnx` directly in the ML package dependencies. -- Added deterministic same-class pseudo-session pairing in `fuse.py`. -- Added mouse-only, keyboard-only, average, noisy-OR, and validation - Logistic Regression stacker comparisons. -- Added explicit missing-modality behavior and fusion metadata output. -- Added tests for mouse-only, keyboard-only, both, neither, pairing, and - result labeling. -- Updated the model card with keyboard and fusion scope, privacy, data-source, - synthetic-bot, pseudo-session, and missing-modality limitations. +## Done in P4-07 + +- Recorded the decision to defer the optional BotD comparison. +- Did not add a BotD runtime dependency or download an unpinned artifact. +- Documented the local R01-R13 rule suite as the current baseline. +- Added the future requirements for any BotD comparison: pinned version, + licence review, fixture adapter, and a new decision entry. +- Marked Phase 4 acceptance criteria complete in `docs/plan/phase_4.md`. ## Exact next step -- Start Phase 4 by defining the privacy-safe environment signal schema. +- Begin Phase 5 by implementing backend settings and a privacy-safe score + store, preserving the documented model/rule boundaries. -## Files touched in P3-11 +## Files touched in P4-07 -- `ml/src/botguard_ml/export_onnx.py` -- `ml/tests/test_keyboard_onnx.py` -- `scripts/export_keyboard_onnx.py` -- `ml/pyproject.toml` -- `scripts/tasks.py` -- `ml/src/botguard_ml/fuse.py` -- `ml/tests/test_fuse.py` -- `docs/model_card.md` -- `docs/plan/phase_3.md` +- `docs/decision_log.md` +- `docs/signals.md` +- `docs/plan/phase_4.md` - `PROGRESS.md` ## Validation +Documentation and decision-log task; no code validation required. The complete +rule implementation was previously validated with: + - `python scripts/tasks.py test-fast` - `python scripts/tasks.py lint` -All passed. Windows pytest cache warnings are pre-existing and harmless. - ## Known problems / gotchas -- Real final keyboard metrics require a bounded feature-shard run and - sufficient human and bot groups. -- K3 must remain evaluation-only. -- Phase 3 is merged into `main`; the next task is Phase 4 on `main`. +- Phase 4 environment rules remain on `phase-4-env-rules`. +- Do not use persistent identifiers or fingerprint hashes. +- Hard rules must bypass model scoring. -## Questions for the human (P3-08) +## Questions for the human - None. diff --git a/PROGRESS.md b/PROGRESS.md index a9e325f..335b1f9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -49,13 +49,13 @@ | P3-09 | 3 | Keyboard ONNX export | DONE | fde90e8 | Added Logistic Regression ONNX export, metadata, CLI stage, explicit skl2onnx dependency, and native-vs-ONNX parity tests. | | P3-10 | 3 | Desktop fusion | DONE | 5884937 | Added deterministic same-class pseudo-session pairing, missing-modality-safe fusion rules, validation stacker, result labels, metadata writer, and tests. | | P3-11 | 3 | Keyboard and fusion model card | DONE | 0ae687d | Documented keyboard scope, privacy boundaries, single-human-source limitation, synthetic bots, pseudo-session fusion, and missing-modality policy. | -| P4-01 | 4 | Environment signal schema | TODO | | | -| P4-02 | 4 | Pure environment rules | TODO | | | -| P4-03 | 4 | Rule score | TODO | | | -| P4-04 | 4 | Configurable rule weights | TODO | | | -| P4-05 | 4 | Rule-layer tests | TODO | | | -| P4-06 | 4 | Signal privacy documentation | TODO | | | -| P4-07 | 4 | Optional BotD comparison | TODO | | | +| P4-01 | 4 | Environment signal schema | DONE | 8d8b575 | 26-field EnvSignals Pydantic model from Appendix B.2; all optional; no fingerprint hash; 16 tests. | +| P4-02 | 4 | Pure environment rules | DONE | 7f1ac74 | Implemented RuleResult and pure rule functions R01-R13, evaluate_all_rules, and 62 unit tests. | +| P4-03 | 4 | Rule score | DONE | c91ab3b | Added noisy-OR rule scoring, fired-rule reasons, hard-rule detection for R13/invalid nonce or signature, and model-bypass tests. | +| P4-04 | 4 | Configurable rule weights | DONE | 9ddd713 | Added validated YAML loading for enabled flags and weights, with disabled-rule handling in combined evaluation. | +| P4-05 | 4 | Rule-layer tests | DONE | d3195de | Added aggregate normal Chrome and headless-browser fixture assertions, including rule score and fired-rule expectations. | +| P4-06 | 4 | Signal privacy documentation | DONE | pending | Added the complete signal privacy register, prohibited-data boundary, retention guidance, and fixture coverage summary. | +| P4-07 | 4 | Optional BotD comparison | DONE | pending | Deferred by decision: no pinned offline BotD artifact or approved adapter is present; no runtime dependency added. | | P5-01 | 5 | Settings and score store | TODO | | | | P5-02 | 5 | API schemas and limits | TODO | | | | P5-03 | 5 | Nonces | TODO | | | diff --git a/backend/app/config.py b/backend/app/config.py index e9dcc2e..8d79bca 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1 +1,49 @@ """Backend configuration loading and validation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import yaml # type: ignore[import-untyped] + +from app.rules import DEFAULT_RULE_WEIGHTS + + +@dataclass(frozen=True) +class RuleConfig: + """Validated enabled flags and weights for the environment rules.""" + + enabled: dict[str, bool] + weights: dict[str, float] + + +def load_rule_config(path: Path) -> RuleConfig: + """Load and validate a rules YAML file.""" + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except OSError as exc: + raise ValueError(f"unable to read rule configuration: {path}") from exc + if not isinstance(raw, dict) or not isinstance(raw.get("rules"), dict): + raise ValueError("rule configuration must contain a rules mapping") + + enabled: dict[str, bool] = {} + weights: dict[str, float] = {} + for rule_id, defaults in raw["rules"].items(): + if rule_id not in DEFAULT_RULE_WEIGHTS: + raise ValueError(f"unknown rule ID: {rule_id}") + if not isinstance(defaults, dict): + raise ValueError(f"configuration for {rule_id} must be a mapping") + is_enabled = defaults.get("enabled", True) + configured_weight = defaults.get("weight", DEFAULT_RULE_WEIGHTS[rule_id]) + if not isinstance(is_enabled, bool): + raise ValueError(f"enabled flag for {rule_id} must be boolean") + if not isinstance(configured_weight, (int, float)) or not 0 <= configured_weight <= 1: + raise ValueError(f"weight for {rule_id} must be in [0, 1]") + enabled[rule_id] = is_enabled + weights[rule_id] = float(configured_weight) + + for rule_id, default_weight in DEFAULT_RULE_WEIGHTS.items(): + enabled.setdefault(rule_id, True) + weights.setdefault(rule_id, default_weight) + return RuleConfig(enabled=enabled, weights=weights) diff --git a/backend/app/rules.py b/backend/app/rules.py index 1051ad2..b264304 100644 --- a/backend/app/rules.py +++ b/backend/app/rules.py @@ -1 +1,761 @@ -"""Environment rule-layer checks.""" +"""Pure environment rule-layer checks (Phase 4, P4-02). + +Deterministic, explainable checks that require no machine learning model. +They catch trivial automation and feed the final decision. + +Each rule is implemented as a small pure function returning: + RuleResult(id, fired: bool, weight: float, reason: str) + +Privacy: rules operate solely on bucketed/coarse environment signals and +aggregate behaviour metrics. No unique fingerprint hash is computed. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite +from typing import Any + +from app.schemas import EnvSignals + +# --------------------------------------------------------------------------- +# Rule result model +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class RuleResult: + """Outcome of evaluating a single detection rule. + + Attributes: + id: Unique rule identifier (e.g. 'R02_WEBDRIVER'). + fired: True if the suspicious condition was triggered. + weight: Default or configured weight of the rule in [0.0, 1.0]. + reason: Explanatory message indicating why the rule fired or passed. + """ + + id: str + fired: bool + weight: float + reason: str + + def to_dict(self) -> dict[str, Any]: + """Convert rule result to a dictionary representation.""" + return { + "id": self.id, + "fired": self.fired, + "weight": self.weight, + "reason": self.reason, + } + + +@dataclass(frozen=True) +class RuleScore: + """Combined rule-layer result used by the later model decision layer.""" + + rule_score: float + reasons: list[str] + fired_rules: list[str] + model_bypassed: bool + hard_rule: str | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert the combined result to a response-safe dictionary.""" + return { + "rule_score": self.rule_score, + "reasons": list(self.reasons), + "fired_rules": list(self.fired_rules), + "model_bypassed": self.model_bypassed, + "hard_rule": self.hard_rule, + } + + +def combine_rule_results( + results: list[RuleResult], + *, + invalid_nonce_or_signature: bool = False, +) -> RuleScore: + """Combine fired rule weights with noisy-OR and identify hard bypasses.""" + fired = [result for result in results if result.fired] + for result in fired: + if not 0.0 <= result.weight <= 1.0 or not isfinite(result.weight): + raise ValueError(f"rule weight must be finite and in [0, 1]: {result.id}") + + hard_rule: str | None = None + if invalid_nonce_or_signature: + hard_rule = "INVALID_NONCE_OR_SIGNATURE" + elif any(result.id == "R13_RATE_LIMIT" for result in fired): + hard_rule = "R13_RATE_LIMIT" + + if hard_rule is not None: + score = 1.0 + reasons = [result.reason for result in fired] + reasons.append(f"{hard_rule} requires model bypass") + else: + score = 1.0 + for result in fired: + score *= 1.0 - result.weight + score = 1.0 - score + reasons = [result.reason for result in fired] + + return RuleScore( + rule_score=score, + reasons=reasons, + fired_rules=[result.id for result in fired], + model_bypassed=hard_rule is not None, + hard_rule=hard_rule, + ) + + +# --------------------------------------------------------------------------- +# Default rule weights (Appendix C.2 / Phase 4 plan) +# --------------------------------------------------------------------------- + +DEFAULT_RULE_WEIGHTS: dict[str, float] = { + "R01_NO_PAYLOAD": 0.80, + "R02_WEBDRIVER": 0.60, + "R03_UA_PLATFORM_MISMATCH": 0.30, + "R04_UA_HINTS_MISMATCH": 0.30, + "R05_TZ_LANG_MISMATCH": 0.10, + "R06_SOFTWARE_RENDERER": 0.15, + "R07_ZERO_PLUGINS_DESKTOP": 0.10, + "R08_VIEWPORT_ANOMALY": 0.30, + "R09_TOUCH_INCONSISTENT": 0.10, + "R10_NATIVE_FUNCTION_TAMPER": 0.30, + "R11_UNTRUSTED_EVENTS": 0.60, + "R12_TOO_FAST": 0.30, + "R13_RATE_LIMIT": 1.00, +} + + +def _coerce_env(env: EnvSignals | dict[str, Any] | None) -> EnvSignals | None: + """Coerce input into an EnvSignals instance if a dictionary is provided.""" + if env is None or isinstance(env, EnvSignals): + return env + return EnvSignals(**env) + + +def _resolve_weight(rule_id: str, custom_weight: float | None) -> float: + """Resolve weight using custom override or default dictionary.""" + if custom_weight is not None: + return float(custom_weight) + return DEFAULT_RULE_WEIGHTS.get(rule_id, 0.30) + + +# --------------------------------------------------------------------------- +# R01 — No behaviour/environment payload at all +# --------------------------------------------------------------------------- + + +def check_r01_no_payload( + env: EnvSignals | dict[str, Any] | None = None, + behaviour: dict[str, Any] | None = None, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R01: Fired when no environment or behaviour payload is provided.""" + rule_id = "R01_NO_PAYLOAD" + w = _resolve_weight(rule_id, weight) + coerced_env = _coerce_env(env) + + env_empty = coerced_env is None or coerced_env.is_empty() + behaviour_empty = ( + behaviour is None + or len(behaviour) == 0 + or all( + v is None or (isinstance(v, (dict, list)) and len(v) == 0) for v in behaviour.values() + ) + ) + + fired = env_empty and behaviour_empty + reason = ( + "No environment or behaviour payload provided" + if fired + else "Environment or behaviour payload present" + ) + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R02 — navigator.webdriver == true +# --------------------------------------------------------------------------- + + +def check_r02_webdriver( + env: EnvSignals | dict[str, Any] | None = None, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R02: Fired when navigator.webdriver is true.""" + rule_id = "R02_WEBDRIVER" + w = _resolve_weight(rule_id, weight) + coerced_env = _coerce_env(env) + + fired = bool(coerced_env and coerced_env.webdriver is True) + reason = ( + "navigator.webdriver flag is true" + if fired + else "navigator.webdriver flag is not set or false" + ) + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R03 — UA OS family inconsistent with navigator.platform +# --------------------------------------------------------------------------- + + +def check_r03_ua_platform_mismatch( + env: EnvSignals | dict[str, Any] | None = None, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R03: Fired when UA OS family is inconsistent with navigator.platform.""" + rule_id = "R03_UA_PLATFORM_MISMATCH" + w = _resolve_weight(rule_id, weight) + coerced_env = _coerce_env(env) + + fired = False + reason = "UA OS and platform are consistent" + + if coerced_env and coerced_env.ua_os and coerced_env.platform: + os_name = coerced_env.ua_os.strip().lower() + platform_name = coerced_env.platform.strip().lower() + + if os_name == "windows" and not platform_name.startswith("win"): + fired = True + elif os_name == "macos" and not platform_name.startswith("mac"): + fired = True + elif os_name == "linux" and ( + platform_name.startswith("win") or platform_name.startswith("mac") + ): + fired = True + elif os_name == "android" and ( + platform_name.startswith("win") or platform_name.startswith("mac") + ): + fired = True + elif os_name == "ios" and not ( + any(k in platform_name for k in ("iphone", "ipad", "ipod")) + or platform_name.startswith("mac") + ): + fired = True + + if fired: + reason = ( + f"UA OS '{coerced_env.ua_os}' is inconsistent with " + f"navigator.platform '{coerced_env.platform}'" + ) + + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R04 — UA vs Client Hints brand/version/platform inconsistent +# --------------------------------------------------------------------------- + + +def check_r04_ua_hints_mismatch( + env: EnvSignals | dict[str, Any] | None = None, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R04: Fired when UA contradicts Client Hints platform or brand values.""" + rule_id = "R04_UA_HINTS_MISMATCH" + w = _resolve_weight(rule_id, weight) + coerced_env = _coerce_env(env) + + fired = False + reason = "UA and client hints are consistent" + + if coerced_env: + # Check platform mismatch + if coerced_env.ua_os and coerced_env.hints_platform: + ua_os = coerced_env.ua_os.strip().lower() + hp = coerced_env.hints_platform.strip().lower() + if ua_os == "windows" and "win" not in hp: + fired = True + elif ua_os == "macos" and "mac" not in hp: + fired = True + elif ua_os == "linux" and "linux" not in hp and "android" not in hp: + fired = True + elif ua_os == "android" and "android" not in hp: + fired = True + elif ua_os == "ios" and "ios" not in hp: + fired = True + + if fired: + reason = ( + f"UA OS '{coerced_env.ua_os}' contradicts " + f"client-hints platform '{coerced_env.hints_platform}'" + ) + + # Check browser family vs hints brands + if not fired and coerced_env.ua_family and coerced_env.hints_brands: + fam = coerced_env.ua_family.strip().lower() + brands_text = " ".join(b.strip().lower() for b in coerced_env.hints_brands) + if fam in ("firefox", "safari"): + if "chromium" in brands_text or "google chrome" in brands_text: + fired = True + reason = ( + f"UA browser '{coerced_env.ua_family}' contradicts " + f"Chromium client hints {coerced_env.hints_brands}" + ) + elif fam == "chrome": + if ( + "chromium" not in brands_text + and "google chrome" not in brands_text + and "chrome" not in brands_text + ): + fired = True + reason = ( + f"UA browser 'chrome' missing from client hints {coerced_env.hints_brands}" + ) + + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R05 — Timezone and language list implausible together (soft) +# --------------------------------------------------------------------------- + + +def check_r05_tz_lang_mismatch( + env: EnvSignals | dict[str, Any] | None = None, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R05: Soft check for implausible timezone offset and primary language.""" + rule_id = "R05_TZ_LANG_MISMATCH" + w = _resolve_weight(rule_id, weight) + coerced_env = _coerce_env(env) + + fired = False + reason = "Timezone offset and language are plausible" + + if coerced_env and coerced_env.timezone_offset_min is not None: + offset = coerced_env.timezone_offset_min + # Earth valid timezone offsets: UTC-12 (+720 min) to UTC+14 (-840 min) + if offset < -840 or offset > 720: + fired = True + reason = ( + f"Timezone offset {offset} min is outside valid terrestrial range (-840 to +720)" + ) + elif coerced_env.lang_primary: + lang = coerced_env.lang_primary.strip() + parts = lang.replace("_", "-").split("-") + country = parts[-1].upper() if len(parts) > 1 and len(parts[-1]) == 2 else None + + # Standard regional bounds (min_offset_min, max_offset_min) + regional_bounds: dict[str, tuple[int, int]] = { + "IN": (-390, -270), # India: UTC+5:30 = -330 + "JP": (-600, -480), # Japan: UTC+9 = -540 + "KR": (-600, -480), # Korea: UTC+9 = -540 + "CN": (-540, -420), # China: UTC+8 = -480 + "TW": (-540, -420), # Taiwan: UTC+8 = -480 + "AU": (-660, -480), # Australia: -480 to -660 + "NZ": (-780, -660), # New Zealand: -660 to -780 + "GB": (-60, 0), # UK: 0 to -60 + "US": (240, 600), # US: +240 to +600 + "CA": (210, 510), # Canada: +210 to +510 + } + + if country and country in regional_bounds: + min_exp, max_exp = regional_bounds[country] + # Allow soft 6-hour tolerance before firing + if offset < (min_exp - 360) or offset > (max_exp + 360): + fired = True + reason = ( + f"Timezone offset {offset} min is implausible " + f"for region '{country}' in language '{lang}'" + ) + + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R06 — WebGL renderer class is a software renderer +# --------------------------------------------------------------------------- + + +def check_r06_software_renderer( + env: EnvSignals | dict[str, Any] | None = None, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R06: Fired when WebGL renderer class is classified as software/virtual.""" + rule_id = "R06_SOFTWARE_RENDERER" + w = _resolve_weight(rule_id, weight) + coerced_env = _coerce_env(env) + + fired = False + reason = "WebGL renderer is hardware or unclassified" + + if coerced_env and coerced_env.webgl_renderer_class: + rend = coerced_env.webgl_renderer_class.strip().lower() + if rend in ("software", "virtual", "swiftshader", "llvmpipe"): + fired = True + reason = ( + f"WebGL renderer classified as software/virtual: " + f"'{coerced_env.webgl_renderer_class}'" + ) + + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R07 — Desktop UA with zero plugins +# --------------------------------------------------------------------------- + + +def check_r07_zero_plugins_desktop( + env: EnvSignals | dict[str, Any] | None = None, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R07: Fired when a desktop browser reports 0 plugins.""" + rule_id = "R07_ZERO_PLUGINS_DESKTOP" + w = _resolve_weight(rule_id, weight) + coerced_env = _coerce_env(env) + + fired = False + reason = "Plugins count is consistent with platform" + + if coerced_env and coerced_env.plugins_count is not None and coerced_env.plugins_count == 0: + is_desktop = False + if coerced_env.ua_os and coerced_env.ua_os.strip().lower() in ("windows", "macos", "linux"): + is_desktop = True + elif coerced_env.platform: + plat = coerced_env.platform.strip().lower() + if any(k in plat for k in ("win", "mac", "linux")) and "android" not in plat: + is_desktop = True + + if is_desktop: + plat_name = coerced_env.ua_os or coerced_env.platform + fired = True + reason = f"Desktop platform ('{plat_name}') reported 0 plugins" + + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R08 — Viewport anomaly +# --------------------------------------------------------------------------- + + +def check_r08_viewport_anomaly( + env: EnvSignals | dict[str, Any] | None = None, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R08: Fired when viewport dimensions are non-positive or exceed screen/outer.""" + rule_id = "R08_VIEWPORT_ANOMALY" + w = _resolve_weight(rule_id, weight) + coerced_env = _coerce_env(env) + + fired = False + reason = "Viewport and screen dimensions are consistent" + + if coerced_env: + vw = coerced_env.viewport_w + vh = coerced_env.viewport_h + ow = coerced_env.outer_w + oh = coerced_env.outer_h + sw = coerced_env.screen_w_bucket + sh = coerced_env.screen_h_bucket + + if (ow is not None and ow <= 0) or (oh is not None and oh <= 0): + fired = True + reason = f"Outer window dimensions are non-positive: ({ow}x{oh})" + elif (vw is not None and vw <= 0) or (vh is not None and vh <= 0): + fired = True + reason = f"Viewport dimensions are non-positive: ({vw}x{vh})" + elif (vw is not None and sw is not None and vw > sw) or ( + vh is not None and sh is not None and vh > sh + ): + fired = True + reason = f"Viewport ({vw}x{vh}) is larger than screen resolution ({sw}x{sh})" + elif ( + ow is not None + and oh is not None + and ow > 0 + and oh > 0 + and vw is not None + and vh is not None + and (vw > ow or vh > oh) + ): + fired = True + reason = f"Viewport ({vw}x{vh}) is larger than outer window ({ow}x{oh})" + + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R09 — Touch events vs claimed device class mismatch +# --------------------------------------------------------------------------- + + +def check_r09_touch_inconsistent( + env: EnvSignals | dict[str, Any] | None = None, + behaviour: dict[str, Any] | None = None, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R09: Fired when touch capabilities contradict claimed device class.""" + rule_id = "R09_TOUCH_INCONSISTENT" + w = _resolve_weight(rule_id, weight) + coerced_env = _coerce_env(env) + + fired = False + reason = "Touch capabilities are consistent with claimed device" + + device_class = behaviour.get("device_class") if behaviour else None + if coerced_env: + is_mobile = False + if coerced_env.ua_os and coerced_env.ua_os.strip().lower() in ("android", "ios"): + is_mobile = True + elif coerced_env.platform and any( + k in coerced_env.platform.strip().lower() for k in ("iphone", "ipad", "android") + ): + is_mobile = True + elif device_class and str(device_class).strip().lower() in ("mobile", "tablet"): + is_mobile = True + + if is_mobile: + dev_label = coerced_env.ua_os or device_class + if coerced_env.max_touch_points is not None and coerced_env.max_touch_points == 0: + fired = True + reason = f"Mobile device ('{dev_label}') reported 0 maxTouchPoints" + elif coerced_env.touch_event_supported is False: + fired = True + reason = f"Mobile device ('{dev_label}') reported touch_event_supported=False" + elif ( + coerced_env.touch_event_supported is True + and coerced_env.max_touch_points is not None + and coerced_env.max_touch_points == 0 + ): + fired = True + reason = "touch_event_supported is True but maxTouchPoints is 0" + + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R10 — Native-function integrity check failed +# --------------------------------------------------------------------------- + + +def check_r10_native_function_tamper( + env: EnvSignals | dict[str, Any] | None = None, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R10: Fired when native-function integrity check failed.""" + rule_id = "R10_NATIVE_FUNCTION_TAMPER" + w = _resolve_weight(rule_id, weight) + coerced_env = _coerce_env(env) + + fired = bool(coerced_env and coerced_env.native_fn_integrity_ok is False) + reason = ( + "Native function integrity check failed (function tampering detected)" + if fired + else "Native function integrity check passed or not evaluated" + ) + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R11 — High fraction of isTrusted == false input events +# --------------------------------------------------------------------------- + + +def check_r11_untrusted_events( + behaviour: dict[str, Any] | None = None, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R11: Fired when synthesized/untrusted input events are reported.""" + rule_id = "R11_UNTRUSTED_EVENTS" + w = _resolve_weight(rule_id, weight) + + fired = False + reason = "No untrusted input events detected" + untrusted = 0 + + if behaviour: + if "mouse" in behaviour and isinstance(behaviour["mouse"], dict): + untrusted += int(behaviour["mouse"].get("untrusted_count") or 0) + if "keys" in behaviour and isinstance(behaviour["keys"], dict): + untrusted += int(behaviour["keys"].get("untrusted_count") or 0) + untrusted += int(behaviour.get("untrusted_count") or 0) + + if untrusted > 0: + fired = True + reason = f"Untrusted input events detected: count={untrusted}" + + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R12 — Time from nonce issue to action under plausible minimum +# --------------------------------------------------------------------------- + + +def check_r12_too_fast( + behaviour: dict[str, Any] | None = None, + *, + nonce_age_ms: float | None = None, + min_ms: float = 200.0, + weight: float | None = None, +) -> RuleResult: + """Rule R12: Fired when action was completed unnaturally fast.""" + rule_id = "R12_TOO_FAST" + w = _resolve_weight(rule_id, weight) + + fired = False + reason = "Action timing within plausible limits" + + ms_since_nonce: float | None = None + ms_to_first: float | None = None + + if behaviour and "timing" in behaviour and isinstance(behaviour["timing"], dict): + ms_since_nonce = behaviour["timing"].get("ms_since_nonce") + ms_to_first = behaviour["timing"].get("ms_to_first_interaction") + + if nonce_age_ms is not None: + ms_since_nonce = ( + nonce_age_ms if ms_since_nonce is None else min(ms_since_nonce, nonce_age_ms) + ) + + if ms_since_nonce is not None and ms_since_nonce < min_ms: + fired = True + reason = f"Interaction submitted too fast after nonce: {ms_since_nonce}ms < {min_ms}ms" + elif ms_to_first is not None and ms_to_first < 50.0: + fired = True + reason = f"First interaction occurred too fast after load: {ms_to_first}ms < 50ms" + + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# R13 — Request-rate caps exceeded (server-side, Store) +# --------------------------------------------------------------------------- + + +def check_r13_rate_limit( + rate_limit_exceeded: bool = False, + *, + weight: float | None = None, +) -> RuleResult: + """Rule R13: Hard rule fired when server-side request rate limit is exceeded.""" + rule_id = "R13_RATE_LIMIT" + w = _resolve_weight(rule_id, weight) + + fired = bool(rate_limit_exceeded) + reason = "Request rate limit exceeded" if fired else "Request rate within limits" + return RuleResult(id=rule_id, fired=fired, weight=w, reason=reason) + + +# --------------------------------------------------------------------------- +# Convenience aliases (rule_r01_... -> check_r01_...) +# --------------------------------------------------------------------------- + +rule_r01_no_payload = check_r01_no_payload +rule_r02_webdriver = check_r02_webdriver +rule_r03_ua_platform_mismatch = check_r03_ua_platform_mismatch +rule_r04_ua_hints_mismatch = check_r04_ua_hints_mismatch +rule_r05_tz_lang_mismatch = check_r05_tz_lang_mismatch +rule_r06_software_renderer = check_r06_software_renderer +rule_r07_zero_plugins_desktop = check_r07_zero_plugins_desktop +rule_r08_viewport_anomaly = check_r08_viewport_anomaly +rule_r09_touch_inconsistent = check_r09_touch_inconsistent +rule_r10_native_function_tamper = check_r10_native_function_tamper +rule_r11_untrusted_events = check_r11_untrusted_events +rule_r12_too_fast = check_r12_too_fast +rule_r13_rate_limit = check_r13_rate_limit + + +# --------------------------------------------------------------------------- +# Combined rule evaluation +# --------------------------------------------------------------------------- + + +def evaluate_all_rules( + env: EnvSignals | dict[str, Any] | None, + behaviour: dict[str, Any] | None = None, + *, + weights: dict[str, float] | None = None, + enabled: dict[str, bool] | None = None, + rate_limit_exceeded: bool = False, + nonce_age_ms: float | None = None, + min_action_ms: float = 200.0, +) -> list[RuleResult]: + """Evaluate all 13 rules in order and return their RuleResult list. + + Args: + env: Environment signals (EnvSignals model or dictionary). + behaviour: Behaviour payload dictionary. + weights: Optional dictionary of rule ID to weight overrides. + rate_limit_exceeded: Server-side flag indicating rate limit exceeded. + nonce_age_ms: Server-side elapsed milliseconds since nonce was issued. + min_action_ms: Plausible threshold in ms for minimum action time. + + Returns: + List of RuleResult instances for R01 through R13. + """ + w = weights or {} + e = enabled or {} + results = [ + check_r01_no_payload(env, behaviour, weight=w.get("R01_NO_PAYLOAD")), + check_r02_webdriver(env, weight=w.get("R02_WEBDRIVER")), + check_r03_ua_platform_mismatch(env, weight=w.get("R03_UA_PLATFORM_MISMATCH")), + check_r04_ua_hints_mismatch(env, weight=w.get("R04_UA_HINTS_MISMATCH")), + check_r05_tz_lang_mismatch(env, weight=w.get("R05_TZ_LANG_MISMATCH")), + check_r06_software_renderer(env, weight=w.get("R06_SOFTWARE_RENDERER")), + check_r07_zero_plugins_desktop(env, weight=w.get("R07_ZERO_PLUGINS_DESKTOP")), + check_r08_viewport_anomaly(env, weight=w.get("R08_VIEWPORT_ANOMALY")), + check_r09_touch_inconsistent(env, behaviour, weight=w.get("R09_TOUCH_INCONSISTENT")), + check_r10_native_function_tamper(env, weight=w.get("R10_NATIVE_FUNCTION_TAMPER")), + check_r11_untrusted_events(behaviour, weight=w.get("R11_UNTRUSTED_EVENTS")), + check_r12_too_fast( + behaviour, + nonce_age_ms=nonce_age_ms, + min_ms=min_action_ms, + weight=w.get("R12_TOO_FAST"), + ), + check_r13_rate_limit(rate_limit_exceeded, weight=w.get("R13_RATE_LIMIT")), + ] + if not e: + return results + return [ + result + if e.get(result.id, True) + else RuleResult(result.id, False, result.weight, "Rule disabled by configuration") + for result in results + ] + + +def evaluate_rule_score( + env: EnvSignals | dict[str, Any] | None, + behaviour: dict[str, Any] | None = None, + *, + weights: dict[str, float] | None = None, + enabled: dict[str, bool] | None = None, + rate_limit_exceeded: bool = False, + invalid_nonce_or_signature: bool = False, + nonce_age_ms: float | None = None, + min_action_ms: float = 200.0, +) -> RuleScore: + """Evaluate all rules and return their combined score and explanations.""" + results = evaluate_all_rules( + env, + behaviour, + weights=weights, + enabled=enabled, + rate_limit_exceeded=rate_limit_exceeded, + nonce_age_ms=nonce_age_ms, + min_action_ms=min_action_ms, + ) + return combine_rule_results( + results, + invalid_nonce_or_signature=invalid_nonce_or_signature, + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d1797dd..ebbb1e5 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1 +1,159 @@ -"""HTTP request and response schemas.""" +"""HTTP request and response schemas. + +Environment signal schema (Appendix B.2), behaviour payload (B.3), +assess request/response (B.4), and related models for the scoring API. + +Privacy: all environment values are coarse or bucketed. No unique +fingerprint hash is ever computed from these signals. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# B.2 — Environment signals +# --------------------------------------------------------------------------- + + +class EnvSignals(BaseModel): + """Privacy-safe, coarse environment signals collected by the SDK. + + Every field is optional so that a missing or partial payload can be + handled by the rule layer (rule R01_NO_PAYLOAD fires when the entire + object is absent or empty). Values are bucketed or categorical — no + raw high-entropy strings are accepted. + """ + + # Automation markers + webdriver: bool | None = Field( + default=None, + description="navigator.webdriver flag; true in most automation frameworks.", + ) + + # User-Agent parsed fields + ua_family: str | None = Field( + default=None, + description="Browser family in lowercase, e.g. 'chrome', 'firefox', 'safari'.", + ) + ua_major: int | None = Field( + default=None, + description="Major version of the browser parsed from the UA string.", + ) + ua_os: str | None = Field( + default=None, + description="OS family in lowercase, e.g. 'windows', 'macos', 'linux', 'android', 'ios'.", + ) + + # Platform and client-hints + platform: str | None = Field( + default=None, + description="navigator.platform value, e.g. 'Win32', 'MacIntel', 'Linux x86_64'.", + ) + hints_brands: list[str] | None = Field( + default=None, + description="Client-hints brand names, e.g. ['Chromium', 'Google Chrome'].", + ) + hints_platform: str | None = Field( + default=None, + description="Client-hints platform string, e.g. 'Windows', 'macOS'.", + ) + + # Locale and timezone + languages_count: int | None = Field( + default=None, + description="Length of navigator.languages.", + ) + lang_primary: str | None = Field( + default=None, + description="First language tag, e.g. 'en-IN'. Used for timezone plausibility.", + ) + timezone_offset_min: int | None = Field( + default=None, + description="new Date().getTimezoneOffset() in minutes (negative = east of UTC).", + ) + + # Screen and viewport (bucketed) + screen_w_bucket: int | None = Field( + default=None, + description="screen.width rounded to the nearest common resolution.", + ) + screen_h_bucket: int | None = Field( + default=None, + description="screen.height rounded to the nearest common resolution.", + ) + viewport_w: int | None = Field( + default=None, + description="window.innerWidth in pixels.", + ) + viewport_h: int | None = Field( + default=None, + description="window.innerHeight in pixels.", + ) + outer_w: int | None = Field( + default=None, + description="window.outerWidth in pixels.", + ) + outer_h: int | None = Field( + default=None, + description="window.outerHeight in pixels.", + ) + dpr_bucket: float | None = Field( + default=None, + description="window.devicePixelRatio, bucketed (1, 1.5, 2, 3).", + ) + + # Hardware hints (bucketed) + hw_concurrency_bucket: int | None = Field( + default=None, + description="navigator.hardwareConcurrency, bucketed.", + ) + device_memory_bucket: float | None = Field( + default=None, + description="navigator.deviceMemory in GB, bucketed (0.25, 0.5, 1, 2, 4, 8).", + ) + + # Rendering + webgl_renderer_class: str | None = Field( + default=None, + description=("Classified WebGL renderer: 'hardware', 'software', 'virtual', 'unknown'."), + ) + + # Browser features + plugins_count: int | None = Field( + default=None, + description="navigator.plugins.length.", + ) + + # Touch capabilities + max_touch_points: int | None = Field( + default=None, + description="navigator.maxTouchPoints.", + ) + touch_event_supported: bool | None = Field( + default=None, + description="'ontouchstart' in window or similar probe result.", + ) + + # Integrity + native_fn_integrity_ok: bool | None = Field( + default=None, + description=( + "True when toString() of key native functions matches the expected " + "pattern; false when they have been overridden/proxied." + ), + ) + + # Storage and visibility + storage_available: bool | None = Field( + default=None, + description="True when localStorage / sessionStorage are accessible.", + ) + visibility: str | None = Field( + default=None, + description="document.visibilityState at collection time: 'visible', 'hidden', etc.", + ) + + def is_empty(self) -> bool: + """Return True when every field is None (no signal provided).""" + return all(v is None for v in self.model_dump().values()) diff --git a/backend/config/rules.yaml b/backend/config/rules.yaml new file mode 100644 index 0000000..77b1de0 --- /dev/null +++ b/backend/config/rules.yaml @@ -0,0 +1,15 @@ +rules: + R01_NO_PAYLOAD: {weight: 0.80, enabled: true} + R02_WEBDRIVER: {weight: 0.60, enabled: true} + R03_UA_PLATFORM_MISMATCH: {weight: 0.30, enabled: true} + R04_UA_HINTS_MISMATCH: {weight: 0.30, enabled: true} + R05_TZ_LANG_MISMATCH: {weight: 0.10, enabled: true} + R06_SOFTWARE_RENDERER: {weight: 0.15, enabled: true} + R07_ZERO_PLUGINS_DESKTOP: {weight: 0.10, enabled: true} + R08_VIEWPORT_ANOMALY: {weight: 0.30, enabled: true} + R09_TOUCH_INCONSISTENT: {weight: 0.10, enabled: true} + R10_NATIVE_FUNCTION_TAMPER: {weight: 0.30, enabled: true} + R11_UNTRUSTED_EVENTS: {weight: 0.60, enabled: true} + R12_TOO_FAST: {weight: 0.30, enabled: true} + R13_RATE_LIMIT: {weight: 1.00, enabled: true} +combine: noisy_or diff --git a/backend/tests/test_env_signals.py b/backend/tests/test_env_signals.py new file mode 100644 index 0000000..8cc6429 --- /dev/null +++ b/backend/tests/test_env_signals.py @@ -0,0 +1,200 @@ +"""Tests for the environment signal schema (P4-01).""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.schemas import EnvSignals + +# --------------------------------------------------------------------------- +# Construction and defaults +# --------------------------------------------------------------------------- + + +class TestEnvSignalsConstruction: + """EnvSignals can be built from the example in Appendix B.2.""" + + def test_full_payload_from_appendix_b2(self) -> None: + """All Appendix B.2 fields are accepted and round-trip correctly.""" + signals = EnvSignals( + webdriver=False, + ua_family="chrome", + ua_major=126, + ua_os="windows", + platform="Win32", + hints_brands=["Chromium", "Google Chrome"], + hints_platform="Windows", + languages_count=2, + lang_primary="en-IN", + timezone_offset_min=-330, + screen_w_bucket=1920, + screen_h_bucket=1080, + viewport_w=1280, + viewport_h=720, + outer_w=1296, + outer_h=800, + dpr_bucket=1, + hw_concurrency_bucket=8, + device_memory_bucket=8, + plugins_count=5, + max_touch_points=0, + touch_event_supported=False, + webgl_renderer_class="hardware", + native_fn_integrity_ok=True, + storage_available=True, + visibility="visible", + ) + assert signals.webdriver is False + assert signals.ua_family == "chrome" + assert signals.ua_major == 126 + assert signals.ua_os == "windows" + assert signals.platform == "Win32" + assert signals.hints_brands == ["Chromium", "Google Chrome"] + assert signals.hints_platform == "Windows" + assert signals.languages_count == 2 + assert signals.lang_primary == "en-IN" + assert signals.timezone_offset_min == -330 + assert signals.screen_w_bucket == 1920 + assert signals.screen_h_bucket == 1080 + assert signals.viewport_w == 1280 + assert signals.viewport_h == 720 + assert signals.outer_w == 1296 + assert signals.outer_h == 800 + assert signals.dpr_bucket == 1 + assert signals.hw_concurrency_bucket == 8 + assert signals.device_memory_bucket == 8 + assert signals.webgl_renderer_class == "hardware" + assert signals.plugins_count == 5 + assert signals.max_touch_points == 0 + assert signals.touch_event_supported is False + assert signals.native_fn_integrity_ok is True + assert signals.storage_available is True + assert signals.visibility == "visible" + + def test_all_defaults_none(self) -> None: + """An empty constructor succeeds and all fields default to None.""" + signals = EnvSignals() + dump = signals.model_dump() + for key, value in dump.items(): + assert value is None, f"{key} should default to None" + + def test_partial_payload(self) -> None: + """A payload with only a few fields is valid.""" + signals = EnvSignals(webdriver=True, ua_family="firefox") + assert signals.webdriver is True + assert signals.ua_family == "firefox" + assert signals.ua_major is None + + def test_from_dict(self) -> None: + """Construct from a dict, simulating JSON deserialization.""" + raw = { + "webdriver": False, + "ua_family": "chrome", + "screen_w_bucket": 1920, + "max_touch_points": 10, + } + signals = EnvSignals(**raw) + assert signals.screen_w_bucket == 1920 + assert signals.max_touch_points == 10 + + def test_extra_fields_ignored_by_default(self) -> None: + """Unknown fields don't cause validation errors.""" + # Pydantic v2 ignores extra fields by default. + signals = EnvSignals.model_validate({"webdriver": False, "unknown_field": 42}) + assert signals.webdriver is False + assert not hasattr(signals, "unknown_field") + + +# --------------------------------------------------------------------------- +# is_empty helper +# --------------------------------------------------------------------------- + + +class TestIsEmpty: + """The is_empty() method detects a fully-None payload.""" + + def test_empty_payload(self) -> None: + assert EnvSignals().is_empty() is True + + def test_non_empty_when_one_field_set(self) -> None: + assert EnvSignals(webdriver=False).is_empty() is False + + def test_non_empty_with_zero_value(self) -> None: + """Zero is a valid value, not None.""" + assert EnvSignals(max_touch_points=0).is_empty() is False + + def test_non_empty_with_empty_list(self) -> None: + """An empty list is not None.""" + assert EnvSignals(hints_brands=[]).is_empty() is False + + +# --------------------------------------------------------------------------- +# Serialization round-trip +# --------------------------------------------------------------------------- + + +class TestSerialization: + """model_dump / model_validate round-trips preserve values.""" + + def test_round_trip(self) -> None: + original = EnvSignals( + webdriver=True, + ua_family="safari", + ua_major=17, + hints_brands=["Safari"], + dpr_bucket=2.0, + timezone_offset_min=0, + ) + rebuilt = EnvSignals.model_validate(original.model_dump()) + assert rebuilt == original + + def test_json_round_trip(self) -> None: + original = EnvSignals( + webdriver=False, + plugins_count=5, + touch_event_supported=True, + ) + json_str = original.model_dump_json() + rebuilt = EnvSignals.model_validate_json(json_str) + assert rebuilt == original + + +# --------------------------------------------------------------------------- +# Type validation +# --------------------------------------------------------------------------- + + +class TestTypeValidation: + """Pydantic enforces the declared types.""" + + def test_webdriver_rejects_non_bool(self) -> None: + with pytest.raises(ValidationError): + EnvSignals(webdriver=[1, 2]) # type: ignore[arg-type] + + def test_ua_major_rejects_float_string(self) -> None: + with pytest.raises(ValidationError): + EnvSignals(ua_major="abc") # type: ignore[arg-type] + + def test_hints_brands_rejects_non_list(self) -> None: + with pytest.raises(ValidationError): + EnvSignals(hints_brands="chrome") # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Privacy guarantee: no fingerprint hash field +# --------------------------------------------------------------------------- + + +class TestPrivacyGuarantees: + """The schema must never contain a unique fingerprint hash.""" + + def test_no_fingerprint_field(self) -> None: + fields = set(EnvSignals.model_fields.keys()) + forbidden = {"fingerprint", "fingerprint_hash", "fp_hash", "device_id", "visitor_id"} + overlap = fields & forbidden + assert not overlap, f"Forbidden fingerprint fields found: {overlap}" + + def test_field_count_matches_b2(self) -> None: + """We expect exactly the 26 fields from Appendix B.2.""" + assert len(EnvSignals.model_fields) == 26 diff --git a/backend/tests/test_rules.py b/backend/tests/test_rules.py new file mode 100644 index 0000000..1df7834 --- /dev/null +++ b/backend/tests/test_rules.py @@ -0,0 +1,566 @@ +"""Unit tests for the environment rule layer (P4-02). + +Tests each rule R01 through R13 for both passing and failing conditions, +weight overrides, missing-data safety, and full ruleset evaluation. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.config import load_rule_config +from app.rules import ( + DEFAULT_RULE_WEIGHTS, + RuleResult, + RuleScore, + check_r01_no_payload, + check_r02_webdriver, + check_r03_ua_platform_mismatch, + check_r04_ua_hints_mismatch, + check_r05_tz_lang_mismatch, + check_r06_software_renderer, + check_r07_zero_plugins_desktop, + check_r08_viewport_anomaly, + check_r09_touch_inconsistent, + check_r10_native_function_tamper, + check_r11_untrusted_events, + check_r12_too_fast, + check_r13_rate_limit, + combine_rule_results, + evaluate_all_rules, + evaluate_rule_score, +) +from app.schemas import EnvSignals + +# --------------------------------------------------------------------------- +# Fixtures: Benign desktop browser vs automation/bot +# --------------------------------------------------------------------------- + + +@pytest.fixture +def normal_chrome_env() -> EnvSignals: + """Standard legitimate Chrome on Windows desktop from Appendix B.2.""" + return EnvSignals( + webdriver=False, + ua_family="chrome", + ua_major=126, + ua_os="windows", + platform="Win32", + hints_brands=["Chromium", "Google Chrome"], + hints_platform="Windows", + languages_count=2, + lang_primary="en-IN", + timezone_offset_min=-330, + screen_w_bucket=1920, + screen_h_bucket=1080, + viewport_w=1280, + viewport_h=720, + outer_w=1296, + outer_h=800, + dpr_bucket=1.0, + hw_concurrency_bucket=8, + device_memory_bucket=8.0, + webgl_renderer_class="hardware", + plugins_count=5, + max_touch_points=0, + touch_event_supported=False, + native_fn_integrity_ok=True, + storage_available=True, + visibility="visible", + ) + + +@pytest.fixture +def headless_bot_env() -> EnvSignals: + """Standard headless automation default fixture.""" + return EnvSignals( + webdriver=True, + ua_family="chrome", + ua_major=126, + ua_os="windows", + platform="Win32", + hints_brands=["Chromium"], + hints_platform="Windows", + languages_count=1, + lang_primary="en-US", + timezone_offset_min=0, + screen_w_bucket=1920, + screen_h_bucket=1080, + viewport_w=800, + viewport_h=600, + outer_w=0, + outer_h=0, + webgl_renderer_class="software", + plugins_count=0, + max_touch_points=0, + touch_event_supported=False, + native_fn_integrity_ok=False, + ) + + +# --------------------------------------------------------------------------- +# RuleResult structure and properties +# --------------------------------------------------------------------------- + + +class TestRuleResult: + def test_construction_and_attributes(self) -> None: + r = RuleResult(id="R02_WEBDRIVER", fired=True, weight=0.6, reason="webdriver true") + assert r.id == "R02_WEBDRIVER" + assert r.fired is True + assert r.weight == 0.6 + assert r.reason == "webdriver true" + + def test_to_dict(self) -> None: + r = RuleResult(id="R02_WEBDRIVER", fired=False, weight=0.6, reason="pass") + d = r.to_dict() + assert d == {"id": "R02_WEBDRIVER", "fired": False, "weight": 0.6, "reason": "pass"} + + def test_immutability(self) -> None: + r = RuleResult(id="R02_WEBDRIVER", fired=False, weight=0.6, reason="pass") + with pytest.raises(Exception): + r.fired = True # type: ignore[misc] + + +class TestRuleScore: + def test_no_fired_rules_have_zero_score(self) -> None: + result = combine_rule_results([RuleResult("R02_WEBDRIVER", False, 0.6, "passed")]) + assert isinstance(result, RuleScore) + assert result.rule_score == 0.0 + assert result.reasons == [] + assert result.model_bypassed is False + + def test_fired_rules_use_noisy_or(self) -> None: + result = combine_rule_results( + [ + RuleResult("R02_WEBDRIVER", True, 0.6, "webdriver"), + RuleResult("R06_SOFTWARE_RENDERER", True, 0.15, "software"), + ] + ) + assert result.rule_score == pytest.approx(0.66) + assert result.fired_rules == ["R02_WEBDRIVER", "R06_SOFTWARE_RENDERER"] + assert result.reasons == ["webdriver", "software"] + assert result.model_bypassed is False + + def test_rate_limit_bypasses_model(self) -> None: + result = combine_rule_results([RuleResult("R13_RATE_LIMIT", True, 1.0, "rate limit")]) + assert result.rule_score == 1.0 + assert result.model_bypassed is True + assert result.hard_rule == "R13_RATE_LIMIT" + assert "model bypass" in result.reasons[-1] + + def test_invalid_nonce_bypasses_model(self) -> None: + result = combine_rule_results( + [], + invalid_nonce_or_signature=True, + ) + assert result.rule_score == 1.0 + assert result.model_bypassed is True + assert result.hard_rule == "INVALID_NONCE_OR_SIGNATURE" + + def test_rejects_invalid_fired_weight(self) -> None: + with pytest.raises(ValueError, match=r"in \[0, 1\]"): + combine_rule_results([RuleResult("R02_WEBDRIVER", True, 1.1, "bad")]) + + def test_evaluate_rule_score_integrates_rule_evaluation( + self, normal_chrome_env: EnvSignals + ) -> None: + result = evaluate_rule_score(normal_chrome_env, {}) + assert result.rule_score == pytest.approx(0.0) + assert result.model_bypassed is False + + def test_disabled_rule_does_not_fire(self, headless_bot_env: EnvSignals) -> None: + results = evaluate_all_rules( + headless_bot_env, + enabled={"R02_WEBDRIVER": False}, + ) + webdriver = next(result for result in results if result.id == "R02_WEBDRIVER") + assert webdriver.fired is False + assert webdriver.reason == "Rule disabled by configuration" + + def test_yaml_rule_config_loads(self) -> None: + config = load_rule_config(Path(__file__).parents[1] / "config" / "rules.yaml") + assert config.enabled["R02_WEBDRIVER"] is True + assert config.weights["R13_RATE_LIMIT"] == 1.0 + + def test_normal_chrome_fixture_has_near_zero_score(self, normal_chrome_env: EnvSignals) -> None: + result = evaluate_rule_score(normal_chrome_env, {}) + assert result.rule_score == pytest.approx(0.0) + assert result.fired_rules == [] + assert result.reasons == [] + + def test_headless_fixture_fires_several_rules(self, headless_bot_env: EnvSignals) -> None: + result = evaluate_rule_score(headless_bot_env, {}) + assert result.rule_score > 0.8 + assert len(result.fired_rules) >= 4 + assert { + "R02_WEBDRIVER", + "R06_SOFTWARE_RENDERER", + "R07_ZERO_PLUGINS_DESKTOP", + "R08_VIEWPORT_ANOMALY", + "R10_NATIVE_FUNCTION_TAMPER", + }.issubset(result.fired_rules) + + +# --------------------------------------------------------------------------- +# Individual rule tests: R01 to R13 +# --------------------------------------------------------------------------- + + +class TestRule01NoPayload: + def test_fires_when_both_none(self) -> None: + res = check_r01_no_payload(None, None) + assert res.fired is True + assert res.id == "R01_NO_PAYLOAD" + assert res.weight == DEFAULT_RULE_WEIGHTS["R01_NO_PAYLOAD"] + + def test_fires_when_both_empty(self) -> None: + res = check_r01_no_payload(EnvSignals(), {}) + assert res.fired is True + + def test_passes_with_env_present(self, normal_chrome_env: EnvSignals) -> None: + res = check_r01_no_payload(normal_chrome_env, None) + assert res.fired is False + + def test_passes_with_behaviour_present(self) -> None: + res = check_r01_no_payload(None, {"mouse": {"points": [[1, 2, 3]]}}) + assert res.fired is False + + def test_custom_weight(self) -> None: + res = check_r01_no_payload(None, None, weight=0.95) + assert res.weight == 0.95 + + +class TestRule02Webdriver: + def test_fires_when_true(self) -> None: + res = check_r02_webdriver(EnvSignals(webdriver=True)) + assert res.fired is True + assert "webdriver" in res.reason + + def test_passes_when_false(self, normal_chrome_env: EnvSignals) -> None: + res = check_r02_webdriver(normal_chrome_env) + assert res.fired is False + + def test_passes_when_none(self) -> None: + res = check_r02_webdriver(EnvSignals()) + assert res.fired is False + + def test_dict_coercion(self) -> None: + res = check_r02_webdriver({"webdriver": True}) + assert res.fired is True + + +class TestRule03UaPlatformMismatch: + def test_fires_on_windows_ua_with_mac_platform(self) -> None: + res = check_r03_ua_platform_mismatch(EnvSignals(ua_os="windows", platform="MacIntel")) + assert res.fired is True + + def test_fires_on_macos_ua_with_win_platform(self) -> None: + res = check_r03_ua_platform_mismatch(EnvSignals(ua_os="macos", platform="Win32")) + assert res.fired is True + + def test_fires_on_linux_ua_with_win_platform(self) -> None: + res = check_r03_ua_platform_mismatch(EnvSignals(ua_os="linux", platform="Win32")) + assert res.fired is True + + def test_passes_on_consistent_windows(self, normal_chrome_env: EnvSignals) -> None: + res = check_r03_ua_platform_mismatch(normal_chrome_env) + assert res.fired is False + + def test_passes_on_consistent_mac(self) -> None: + res = check_r03_ua_platform_mismatch(EnvSignals(ua_os="macos", platform="MacIntel")) + assert res.fired is False + + def test_passes_when_missing_values(self) -> None: + assert check_r03_ua_platform_mismatch(EnvSignals(ua_os="windows")).fired is False + assert check_r03_ua_platform_mismatch(EnvSignals(platform="Win32")).fired is False + + +class TestRule04UaHintsMismatch: + def test_fires_on_platform_mismatch(self) -> None: + res = check_r04_ua_hints_mismatch(EnvSignals(ua_os="windows", hints_platform="Linux")) + assert res.fired is True + + def test_fires_on_firefox_with_chromium_hints(self) -> None: + res = check_r04_ua_hints_mismatch( + EnvSignals(ua_family="firefox", hints_brands=["Chromium", "Google Chrome"]) + ) + assert res.fired is True + + def test_fires_on_chrome_missing_from_brands(self) -> None: + res = check_r04_ua_hints_mismatch( + EnvSignals(ua_family="chrome", hints_brands=["MyCustomBrowser"]) + ) + assert res.fired is True + + def test_passes_on_consistent_hints(self, normal_chrome_env: EnvSignals) -> None: + res = check_r04_ua_hints_mismatch(normal_chrome_env) + assert res.fired is False + + def test_passes_when_hints_absent(self) -> None: + res = check_r04_ua_hints_mismatch(EnvSignals(ua_family="firefox", ua_os="windows")) + assert res.fired is False + + +class TestRule05TzLangMismatch: + def test_fires_on_invalid_terrestrial_offset(self) -> None: + res = check_r05_tz_lang_mismatch(EnvSignals(timezone_offset_min=1000)) + assert res.fired is True + + def test_fires_on_india_lang_with_us_tz(self) -> None: + # India is -330; +480 is US West Coast (>13h difference) + res = check_r05_tz_lang_mismatch(EnvSignals(lang_primary="en-IN", timezone_offset_min=480)) + assert res.fired is True + + def test_fires_on_japan_lang_with_us_tz(self) -> None: + # Japan is -540; +300 is US East Coast + res = check_r05_tz_lang_mismatch(EnvSignals(lang_primary="ja-JP", timezone_offset_min=300)) + assert res.fired is True + + def test_passes_on_matching_tz_and_lang(self, normal_chrome_env: EnvSignals) -> None: + res = check_r05_tz_lang_mismatch(normal_chrome_env) + assert res.fired is False + + def test_passes_on_generic_lang(self) -> None: + res = check_r05_tz_lang_mismatch(EnvSignals(lang_primary="en", timezone_offset_min=0)) + assert res.fired is False + + +class TestRule06SoftwareRenderer: + def test_fires_on_software_renderer(self) -> None: + res = check_r06_software_renderer(EnvSignals(webgl_renderer_class="software")) + assert res.fired is True + + def test_fires_on_swiftshader(self) -> None: + res = check_r06_software_renderer(EnvSignals(webgl_renderer_class="swiftshader")) + assert res.fired is True + + def test_passes_on_hardware_renderer(self, normal_chrome_env: EnvSignals) -> None: + res = check_r06_software_renderer(normal_chrome_env) + assert res.fired is False + + def test_passes_on_unknown_or_none(self) -> None: + res = check_r06_software_renderer(EnvSignals(webgl_renderer_class="unknown")) + assert res.fired is False + + +class TestRule07ZeroPluginsDesktop: + def test_fires_on_desktop_with_zero_plugins(self) -> None: + res = check_r07_zero_plugins_desktop(EnvSignals(ua_os="windows", plugins_count=0)) + assert res.fired is True + + def test_passes_on_desktop_with_plugins(self, normal_chrome_env: EnvSignals) -> None: + res = check_r07_zero_plugins_desktop(normal_chrome_env) + assert res.fired is False + + def test_passes_on_mobile_with_zero_plugins(self) -> None: + # Mobile browsers legitimately report 0 plugins + res = check_r07_zero_plugins_desktop(EnvSignals(ua_os="android", plugins_count=0)) + assert res.fired is False + + +class TestRule08ViewportAnomaly: + def test_fires_on_zero_outer_dimensions(self) -> None: + res = check_r08_viewport_anomaly( + EnvSignals(viewport_w=800, viewport_h=600, outer_w=0, outer_h=0) + ) + assert res.fired is True + + def test_fires_on_viewport_larger_than_screen(self) -> None: + res = check_r08_viewport_anomaly( + EnvSignals( + viewport_w=2560, + viewport_h=1440, + screen_w_bucket=1920, + screen_h_bucket=1080, + ) + ) + assert res.fired is True + + def test_fires_on_viewport_larger_than_outer(self) -> None: + res = check_r08_viewport_anomaly( + EnvSignals(viewport_w=1000, viewport_h=800, outer_w=900, outer_h=700) + ) + assert res.fired is True + + def test_passes_on_normal_dimensions(self, normal_chrome_env: EnvSignals) -> None: + res = check_r08_viewport_anomaly(normal_chrome_env) + assert res.fired is False + + +class TestRule09TouchInconsistent: + def test_fires_on_mobile_with_zero_touch_points(self) -> None: + res = check_r09_touch_inconsistent( + EnvSignals(ua_os="android", max_touch_points=0, touch_event_supported=True) + ) + assert res.fired is True + + def test_fires_on_mobile_with_touch_events_unsupported(self) -> None: + res = check_r09_touch_inconsistent( + EnvSignals(ua_os="ios", max_touch_points=5, touch_event_supported=False) + ) + assert res.fired is True + + def test_fires_on_touch_supported_but_zero_points(self) -> None: + res = check_r09_touch_inconsistent( + EnvSignals(ua_os="windows", touch_event_supported=True, max_touch_points=0) + ) + assert res.fired is True + + def test_passes_on_standard_desktop(self, normal_chrome_env: EnvSignals) -> None: + res = check_r09_touch_inconsistent(normal_chrome_env) + assert res.fired is False + + def test_passes_on_standard_mobile(self) -> None: + res = check_r09_touch_inconsistent( + EnvSignals(ua_os="android", max_touch_points=5, touch_event_supported=True) + ) + assert res.fired is False + + +class TestRule10NativeFunctionTamper: + def test_fires_when_integrity_failed(self) -> None: + res = check_r10_native_function_tamper(EnvSignals(native_fn_integrity_ok=False)) + assert res.fired is True + + def test_passes_when_integrity_ok(self, normal_chrome_env: EnvSignals) -> None: + res = check_r10_native_function_tamper(normal_chrome_env) + assert res.fired is False + + def test_passes_when_none(self) -> None: + res = check_r10_native_function_tamper(EnvSignals()) + assert res.fired is False + + +class TestRule11UntrustedEvents: + def test_fires_on_mouse_untrusted(self) -> None: + res = check_r11_untrusted_events({"mouse": {"untrusted_count": 5}}) + assert res.fired is True + assert "5" in res.reason + + def test_fires_on_keys_untrusted(self) -> None: + res = check_r11_untrusted_events({"keys": {"untrusted_count": 2}}) + assert res.fired is True + + def test_fires_on_top_level_untrusted(self) -> None: + res = check_r11_untrusted_events({"untrusted_count": 1}) + assert res.fired is True + + def test_passes_on_clean_events(self) -> None: + res = check_r11_untrusted_events( + {"mouse": {"untrusted_count": 0}, "keys": {"untrusted_count": 0}} + ) + assert res.fired is False + + def test_passes_when_empty_or_none(self) -> None: + assert check_r11_untrusted_events(None).fired is False + assert check_r11_untrusted_events({}).fired is False + + +class TestRule12TooFast: + def test_fires_on_very_short_nonce_time(self) -> None: + res = check_r12_too_fast( + behaviour={"timing": {"ms_since_nonce": 45}}, + min_ms=200.0, + ) + assert res.fired is True + + def test_fires_on_server_nonce_age(self) -> None: + res = check_r12_too_fast(nonce_age_ms=80.0, min_ms=200.0) + assert res.fired is True + + def test_fires_on_sub_50ms_first_interaction(self) -> None: + res = check_r12_too_fast( + behaviour={"timing": {"ms_since_nonce": 500, "ms_to_first_interaction": 12}} + ) + assert res.fired is True + + def test_passes_on_plausible_timing(self) -> None: + res = check_r12_too_fast( + behaviour={"timing": {"ms_since_nonce": 2400, "ms_to_first_interaction": 850}}, + min_ms=200.0, + ) + assert res.fired is False + + +class TestRule13RateLimit: + def test_fires_when_exceeded(self) -> None: + res = check_r13_rate_limit(True) + assert res.fired is True + assert res.weight == 1.0 + + def test_passes_when_normal(self) -> None: + res = check_r13_rate_limit(False) + assert res.fired is False + + +# --------------------------------------------------------------------------- +# Full ruleset evaluation tests +# --------------------------------------------------------------------------- + + +class TestEvaluateAllRules: + def test_all_13_rules_evaluated(self, normal_chrome_env: EnvSignals) -> None: + results = evaluate_all_rules( + normal_chrome_env, behaviour={"timing": {"ms_since_nonce": 3000}} + ) + assert len(results) == 13 + rule_ids = [r.id for r in results] + assert rule_ids == [ + "R01_NO_PAYLOAD", + "R02_WEBDRIVER", + "R03_UA_PLATFORM_MISMATCH", + "R04_UA_HINTS_MISMATCH", + "R05_TZ_LANG_MISMATCH", + "R06_SOFTWARE_RENDERER", + "R07_ZERO_PLUGINS_DESKTOP", + "R08_VIEWPORT_ANOMALY", + "R09_TOUCH_INCONSISTENT", + "R10_NATIVE_FUNCTION_TAMPER", + "R11_UNTRUSTED_EVENTS", + "R12_TOO_FAST", + "R13_RATE_LIMIT", + ] + + def test_normal_chrome_fixture_fires_zero_rules(self, normal_chrome_env: EnvSignals) -> None: + results = evaluate_all_rules( + normal_chrome_env, + behaviour={ + "mouse": {"untrusted_count": 0}, + "timing": {"ms_since_nonce": 3500, "ms_to_first_interaction": 800}, + }, + ) + fired = [r.id for r in results if r.fired] + assert fired == [], f"Expected 0 fired rules for normal chrome fixture, got: {fired}" + + def test_headless_bot_fixture_fires_multiple_rules(self, headless_bot_env: EnvSignals) -> None: + results = evaluate_all_rules( + headless_bot_env, + behaviour={ + "mouse": {"untrusted_count": 10}, + "timing": {"ms_since_nonce": 20}, + }, + ) + fired = {r.id for r in results if r.fired} + # Expected to fire: R02 (webdriver), R06 (software renderer), R07 (zero plugins), + # R08 (viewport outer 0x0), R10 (native integrity False), R11 (untrusted), R12 (too fast) + expected = { + "R02_WEBDRIVER", + "R06_SOFTWARE_RENDERER", + "R07_ZERO_PLUGINS_DESKTOP", + "R08_VIEWPORT_ANOMALY", + "R10_NATIVE_FUNCTION_TAMPER", + "R11_UNTRUSTED_EVENTS", + "R12_TOO_FAST", + } + assert expected.issubset(fired), f"Missing expected rules: {expected - fired}" + + def test_custom_weight_overrides(self, normal_chrome_env: EnvSignals) -> None: + overrides = {"R02_WEBDRIVER": 0.99, "R03_UA_PLATFORM_MISMATCH": 0.44} + results = evaluate_all_rules(normal_chrome_env, weights=overrides) + by_id = {r.id: r for r in results} + assert by_id["R02_WEBDRIVER"].weight == 0.99 + assert by_id["R03_UA_PLATFORM_MISMATCH"].weight == 0.44 + assert by_id["R05_TZ_LANG_MISMATCH"].weight == 0.10 diff --git a/docs/decision_log.md b/docs/decision_log.md index ecdc17b..afbd276 100644 --- a/docs/decision_log.md +++ b/docs/decision_log.md @@ -27,3 +27,16 @@ Do not alter an existing decision; add a dated entry instead. `skl2onnx` alone does not provide that converter. - **Scope:** The converter will be validated with an ONNX-vs-native parity test before any exported model is used by the backend. + +### 2026-10-18 — Defer optional BotD comparison + +- **Decision:** Do not add BotD as a dependency or implement an external + comparison in the current phase. +- **Rationale:** The project has a complete local rule-layer fixture suite, + while no approved BotD adapter, pinned offline artifact, or reproducible + comparison dataset is present in the repository. Adding a third-party + runtime or downloading an unpinned implementation would expand scope and + make the comparison difficult to reproduce. +- **Scope:** The project's pure R01-R13 rules remain the evaluated baseline. + A future offline BotD comparison may be added only with a pinned version, + documented licence, fixture adapter, and a new decision entry. diff --git a/docs/plan/phase_4.md b/docs/plan/phase_4.md index 06f58e5..acd4f5d 100644 --- a/docs/plan/phase_4.md +++ b/docs/plan/phase_4.md @@ -4,8 +4,8 @@ ### Tasks -- [ ] **P4-01** Define the environment payload schema (`backend/app/schemas.py: EnvSignals`) with the fields in Appendix B.2. -- [ ] **P4-02** Implement `rules.py` with each rule as a small pure function returning `RuleResult(id, fired: bool, weight: float, reason: str)`: +- [x] **P4-01** Define the environment payload schema (`backend/app/schemas.py: EnvSignals`) with the fields in Appendix B.2. +- [x] **P4-02** Implement `rules.py` with each rule as a small pure function returning `RuleResult(id, fired: bool, weight: float, reason: str)`: | Rule id | Condition (examples) | Default weight | |---------|----------------------|----------------| @@ -23,16 +23,16 @@ | R12_TOO_FAST | Time from nonce issue to action under a plausible minimum | medium | | R13_RATE_LIMIT | Request-rate caps exceeded (server-side, Store) | hard | -- [ ] **P4-03** Combine rule weights into a **rule score** in [0, 1] (documented formula) and a list of reasons. A hard rule (R13, invalid nonce/signature) bypasses the model. -- [ ] **P4-04** Make weights configurable in `backend/config/rules.yaml`. -- [ ] **P4-05** Tests: one passing and one failing payload per rule; a "normal Chrome on Windows" fixture must produce rule score near 0; a "headless default" fixture must fire several rules. -- [ ] **P4-06** Document each signal and its privacy class in `docs/signals.md`. -- [ ] **P4-07** Optional baseline: compare against the open-source BotD library's verdicts on the same fixtures (only as an offline comparison; do not add it as a runtime dependency without a decision-log entry). +- [x] **P4-03** Combine rule weights into a **rule score** in [0, 1] (documented formula) and a list of reasons. A hard rule (R13, invalid nonce/signature) bypasses the model. +- [x] **P4-04** Make weights configurable in `backend/config/rules.yaml`. +- [x] **P4-05** Tests: one passing and one failing payload per rule; a "normal Chrome on Windows" fixture must produce rule score near 0; a "headless default" fixture must fire several rules. +- [x] **P4-06** Document each signal and its privacy class in `docs/signals.md`. +- [x] **P4-07** Optional baseline: comparison deferred by decision; no approved pinned BotD artifact or fixture adapter is present, and no runtime dependency was added. ### Acceptance criteria -- [ ] All rule tests pass. -- [ ] A fixture list shows expected rule outcomes in `docs/signals.md`. -- [ ] No rule uses persistent identifiers or a unique fingerprint hash. +- [x] All rule tests pass. +- [x] A fixture list shows expected rule outcomes in `docs/signals.md`. +- [x] No rule uses persistent identifiers or a unique fingerprint hash. --- diff --git a/docs/signals.md b/docs/signals.md new file mode 100644 index 0000000..adbaf85 --- /dev/null +++ b/docs/signals.md @@ -0,0 +1,105 @@ +# Environment signal privacy register + +This register defines the environment signals accepted by +`backend/app/schemas.py`. They are coarse, contextual signals for +deterministic rules, not a device fingerprint and not an identity system. + +## Privacy classes + +| Class | Meaning | Handling | +|---|---|---| +| C1 — low-sensitivity context | Coarse browser, platform, locale, display, or capability category | Collect only when needed for a rule; do not combine into an identifier. | +| C2 — derived integrity signal | A boolean result of a local integrity or automation check | Store the boolean outcome and rule explanation, never the inspected source text or function body. | +| C3 — server-side control | A request-level control result generated by the backend | Keep only for the request/rate-limit decision; do not use as a client fingerprint. | + +## Accepted environment fields + +| Field | Privacy class | Collection and transformation | Used by | +|---|---|---|---| +| `webdriver` | C2 | Boolean automation marker; no raw driver details | R02 | +| `ua_family` | C1 | Browser family category such as Chrome, Firefox, or Safari | R04 | +| `ua_major` | C1 | Browser major version only; never retain the full UA string | Context | +| `ua_os` | C1 | OS family category such as Windows, macOS, Linux, Android, or iOS | R03, R04 | +| `platform` | C1 | Platform category/value needed for OS consistency checks | R03 | +| `hints_brands` | C1 | Client-Hints brand categories; do not retain raw request headers | R04 | +| `hints_platform` | C1 | Client-Hints platform category | R04 | +| `languages_count` | C1 | Count of preferred languages, not the full language list | Context | +| `lang_primary` | C1 | Primary language tag for coarse plausibility checking | R05 | +| `timezone_offset_min` | C1 | Numeric UTC offset only; do not collect location coordinates or timezone history | R05 | +| `screen_w_bucket` | C1 | Screen width rounded to a common resolution bucket | R08 | +| `screen_h_bucket` | C1 | Screen height rounded to a common resolution bucket | R08 | +| `viewport_w` | C1 | Current viewport width for consistency checks | R08 | +| `viewport_h` | C1 | Current viewport height for consistency checks | R08 | +| `outer_w` | C1 | Coarse outer-window width for viewport anomaly checks | R08 | +| `outer_h` | C1 | Coarse outer-window height for viewport anomaly checks | R08 | +| `dpr_bucket` | C1 | Device-pixel-ratio bucket, not an exact hardware measurement | Context | +| `hw_concurrency_bucket` | C1 | Bucketed logical CPU count | Context | +| `device_memory_bucket` | C1 | Bucketed memory estimate | Context | +| `webgl_renderer_class` | C1 | Renderer class (`hardware`, `software`, `virtual`, or `unknown`), not the raw renderer string | R06 | +| `plugins_count` | C1 | Plugin count only; never plugin names or file paths | R07 | +| `max_touch_points` | C1 | Maximum touch-point count for device-class consistency | R09 | +| `touch_event_supported` | C1 | Boolean touch API capability | R09 | +| `native_fn_integrity_ok` | C2 | Boolean result of a local native-function integrity check | R10 | +| `storage_available` | C1 | Boolean storage availability result | Context | +| `visibility` | C1 | Coarse document visibility state such as visible or hidden | Context | + +## Behaviour and server controls + +Environment signals are evaluated alongside aggregate behaviour values. The +behaviour payload may contain timing and event-trust aggregates needed by R11 +and R12, but must not contain typed characters, key identities, raw input +content, or persistent identifiers. The backend may supply the following +request-level values: + +- `rate_limit_exceeded`: server-side result for R13. +- `nonce_age_ms`: elapsed time from nonce issuance to an action. +- `invalid_nonce_or_signature`: hard validation result that bypasses model + scoring. + +These controls are request decisions, not client attributes. + +## Explicitly prohibited + +The environment layer must not collect, derive, or persist: + +- Full User-Agent strings or raw Client-Hints headers after parsing. +- IP addresses as model features, stable device IDs, cookies, account IDs, or + cross-request correlation IDs. +- Canvas, audio, font, or raw WebGL fingerprint hashes. +- Exact hardware identifiers, serial numbers, MAC addresses, or installed + software lists. +- GPS coordinates, precise location, browsing history, page content, or form + values. +- Typed characters, sentences, key identities, keystroke IDs, or clipboard + contents. +- Raw function bodies, renderer strings, plugin names, or file paths. + +No unique fingerprint hash is computed. Missing signals remain missing and +must not be silently replaced with suspicious values. Rule explanations may +be returned for debugging or policy decisions, but they must not include raw +payload values that the schema intentionally excludes. + +## Retention and use boundary + +Signals are intended for one assessment and its privacy-reviewed aggregate +audit record. The current project target is a local mock portal. Raw +environment payloads should not be retained beyond the approved experiment; +stored results should contain the rule IDs, score, reasons, model version, and +request correlation information only when that correlation is required by the +approved local workflow. This document does not authorize production +deployment or re-identification. + +## Fixture coverage + +The rule tests cover: + +- A normal Chrome-on-Windows fixture with no fired rules and rule score `0`. +- A headless-browser fixture that fires multiple expected automation rules and + produces a high rule score. +- One passing and one failing case for each R01-R13 rule. + +## Optional BotD comparison + +The optional BotD comparison is deferred. No BotD package is a runtime +dependency, and no unpinned external implementation or downloaded artifact is +used by this project. The local R01-R13 rule suite is the current baseline.