Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .mike.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ version_selector: true
title_switch: true
versions_file: docs/versions/versions.json
versions:
- 3.3.0
- 3.2.0
- 3.1.1
- 3.1.0
Expand All @@ -20,4 +21,4 @@ versions:
- 0.1.0
- latest
aliases:
latest: 3.2.0
latest: 3.3.0
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.

___

v3.3.0 (2026-07-01)
-------------------

Detection overhaul — recall 0.42 → 0.86, false positives 0.125 → 0.0, graduated anomaly scoring, and an attack-simulation benchmark harness (v3.3.0)
--------------------------------------------------------------------------------------------------------------

### Added

- **Graduated anomaly scoring.** New `SecurityConfig.detection_threat_score_threshold` (`float`, default `1.0`, `ge=0.0, le=10.0`): the anomaly score a request must reach before it is flagged as a threat. Detection now accumulates a graduated per-request anomaly score instead of relying on a single binary pattern match. The default threshold of `1.0` reproduces the prior flag-on-any-match behavior, so upgrading is behavior-neutral unless you deliberately raise the threshold (fewer, higher-confidence flags) or lower it (more sensitive). Async and sync mirrors updated identically.
- **Attack-simulation benchmark harness.** A reproducible benchmark (`make attack-sim`) that scores the detector against a labelled corpus of malicious and benign payloads and reports detection (recall) and false-positive rates against a committed `baseline.json`, plus an AI-coordinated red-team campaign generator with verified attack seeds. Test/CI infrastructure only — no runtime or public API surface.

### Changed

- **Detection recall raised from 0.42 to 0.86.** Repaired the content preprocessor's comment stripping — SQL block/line comments and several encoded payload forms were not normalized before pattern matching — and expanded coverage across the suspicious-pattern set, so a large class of previously-missed injection and traversal attempts is now caught. Async and sync mirrors updated identically.
- **False-positive rate reduced from 0.125 to 0.0, with recall held.** Tightened patterns to require genuine attack context instead of matching benign traffic: `SELECT … FROM` is now scored by corroboration rather than a bare keyword, and the `ORDER BY`, DDL, ERB-template, and NoSQL-operator patterns require surrounding attack context. The benign corpus was expanded and the baseline re-measured. Async and sync mirrors updated identically.

### Fixed

- **`ipinfo_token` / `ipinfo_db_path` deprecation warning no longer fires on `None`.** The `DeprecationWarning` added in 3.2.0 keyed only on whether the field was passed to the constructor, so a caller forwarding an optional setting — e.g. `SecurityConfig(ipinfo_token=settings.ipinfo_token)` where the setting may be `None` — received a spurious warning even when ipinfo was not in use. The warning now fires only when the deprecated field has a non-`None` value. Async and sync mirrors updated identically.

___

v3.2.0 (2026-06-23)
-------------------

Expand Down
22 changes: 22 additions & 0 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@ Release Notes

___

v3.3.0 (2026-07-01)
-------------------

Detection overhaul — recall 0.42 → 0.86, false positives 0.125 → 0.0, graduated anomaly scoring, and an attack-simulation benchmark harness (v3.3.0)
--------------------------------------------------------------------------------------------------------------

### Added

- **Graduated anomaly scoring.** New `SecurityConfig.detection_threat_score_threshold` (`float`, default `1.0`, `ge=0.0, le=10.0`): the anomaly score a request must reach before it is flagged as a threat. Detection now accumulates a graduated per-request anomaly score instead of relying on a single binary pattern match. The default threshold of `1.0` reproduces the prior flag-on-any-match behavior, so upgrading is behavior-neutral unless you deliberately raise the threshold (fewer, higher-confidence flags) or lower it (more sensitive). Async and sync mirrors updated identically.
- **Attack-simulation benchmark harness.** A reproducible benchmark (`make attack-sim`) that scores the detector against a labelled corpus of malicious and benign payloads and reports detection (recall) and false-positive rates against a committed `baseline.json`, plus an AI-coordinated red-team campaign generator with verified attack seeds. Test/CI infrastructure only — no runtime or public API surface.

### Changed

- **Detection recall raised from 0.42 to 0.86.** Repaired the content preprocessor's comment stripping — SQL block/line comments and several encoded payload forms were not normalized before pattern matching — and expanded coverage across the suspicious-pattern set, so a large class of previously-missed injection and traversal attempts is now caught. Async and sync mirrors updated identically.
- **False-positive rate reduced from 0.125 to 0.0, with recall held.** Tightened patterns to require genuine attack context instead of matching benign traffic: `SELECT … FROM` is now scored by corroboration rather than a bare keyword, and the `ORDER BY`, DDL, ERB-template, and NoSQL-operator patterns require surrounding attack context. The benign corpus was expanded and the baseline re-measured. Async and sync mirrors updated identically.

### Fixed

- **`ipinfo_token` / `ipinfo_db_path` deprecation warning no longer fires on `None`.** The `DeprecationWarning` added in 3.2.0 keyed only on whether the field was passed to the constructor, so a caller forwarding an optional setting — e.g. `SecurityConfig(ipinfo_token=settings.ipinfo_token)` where the setting may be `None` — received a spurious warning even when ipinfo was not in use. The warning now fires only when the deprecated field has a non-`None` value. Async and sync mirrors updated identically.

___

v3.2.0 (2026-06-23)
-------------------

Expand Down
3 changes: 2 additions & 1 deletion docs/versions/versions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"3.3.0": "3.3.0",
"3.2.0": "3.2.0",
"3.1.1": "3.1.1",
"3.1.0": "3.1.0",
Expand All @@ -16,5 +17,5 @@
"1.0.1": "1.0.1",
"1.0.0": "1.0.0",
"0.1.0": "0.1.0",
"latest": "3.2.0"
"latest": "3.3.0"
}
5 changes: 3 additions & 2 deletions guard_core/handlers/suspatterns_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ def _resolve_pattern_weight(pattern: str, category: str) -> float:
return DETECTION_CATEGORY_WEIGHTS.get(category, 1.0)


def _regex_anomaly(regex_threats: list) -> float:
return sum(t.get("weight", 1.0) for t in regex_threats)
def _regex_anomaly(regex_threats: list[dict[str, Any]]) -> float:
return float(sum(t.get("weight", 1.0) for t in regex_threats))


class SusPatternsManager:
Expand Down Expand Up @@ -435,6 +435,7 @@ class SusPatternsManager:
_semantic_analyzer: SemanticAnalyzer | None
_performance_monitor: PerformanceMonitor | None
_semantic_threshold: float
_threat_score_threshold: float

def __new__(
cls: type["SusPatternsManager"], config: Any = None
Expand Down
2 changes: 2 additions & 0 deletions guard_core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,8 @@ def validate_agent_config(self) -> Self:
@model_validator(mode="after")
def warn_deprecated_fields(self) -> Self:
for name in sorted({"ipinfo_token", "ipinfo_db_path"} & self.model_fields_set):
if getattr(self, name) is None:
continue
warnings.warn(
f"{name} is deprecated and will be removed in a future release; "
"create a custom geo_ip_handler instead.",
Expand Down
5 changes: 3 additions & 2 deletions guard_core/sync/handlers/suspatterns_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ def _resolve_pattern_weight(pattern: str, category: str) -> float:
return DETECTION_CATEGORY_WEIGHTS.get(category, 1.0)


def _regex_anomaly(regex_threats: list) -> float:
return sum(t.get("weight", 1.0) for t in regex_threats)
def _regex_anomaly(regex_threats: list[dict[str, Any]]) -> float:
return float(sum(t.get("weight", 1.0) for t in regex_threats))


class SusPatternsManager:
Expand Down Expand Up @@ -435,6 +435,7 @@ class SusPatternsManager:
_semantic_analyzer: SemanticAnalyzer | None
_performance_monitor: PerformanceMonitor | None
_semantic_threshold: float
_threat_score_threshold: float

def __new__(
cls: type["SusPatternsManager"], config: Any = None
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "guard-core"
version = "3.2.0"
version = "3.3.0"
description = "Framework-agnostic security engine for the Guard ecosystem."
authors = [
{name = "Renzo Franceschini", email = "rennf93@users.noreply.github.com"}
Expand Down
2 changes: 1 addition & 1 deletion scripts/unasync.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@
(r"^\s+loop_task\s*$", ""),
]

TEST_SKIP_DIRS = {"__pycache__", "test_sync"}
TEST_SKIP_DIRS = {"__pycache__", "test_sync", "attack_simulation"}


def apply_subs(content: str, subs: list[tuple[str, str]]) -> str:
Expand Down
12 changes: 12 additions & 0 deletions tests/test_models/test_deprecated_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,15 @@ def test_no_deprecation_warning_when_ipinfo_unset() -> None:
if "ipinfo" in str(record.message) and "deprecated" in str(record.message)
]
assert ipinfo_deprecations == []


def test_no_deprecation_warning_when_ipinfo_explicitly_none() -> None:
with warnings.catch_warnings(record=True) as records:
warnings.simplefilter("always")
SecurityConfig(ipinfo_token=None, ipinfo_db_path=None)
ipinfo_deprecations = [
record
for record in records
if "ipinfo" in str(record.message) and "deprecated" in str(record.message)
]
assert ipinfo_deprecations == []
9 changes: 6 additions & 3 deletions tests/test_sync/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,6 @@ def create_redirect_response(self, url: str, status_code: int) -> MockGuardRespo
def reset_state() -> Generator[None, None]:
IPBanManager._instance = None

original_patterns = sus_patterns_handler.patterns.copy()

cloud_instance = cloud_handler._instance
if cloud_instance:
from guard_core.sync.handlers.cloud_ip_stores import InMemoryCloudIpStore
Expand All @@ -154,7 +152,12 @@ def reset_state() -> Generator[None, None]:
IPInfoManager._instance = None

yield
sus_patterns_handler.patterns = original_patterns.copy()
spm = type(sus_patterns_handler)
spm._instance = sus_patterns_handler
spm._config = None
sus_patterns_handler.patterns = [p[0] for p in spm._pattern_definitions]
sus_patterns_handler.custom_patterns = set()
sus_patterns_handler.compiled_custom_patterns = set()

IPBanManager._instance = None

Expand Down
12 changes: 12 additions & 0 deletions tests/test_sync/test_models/test_deprecated_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,15 @@ def test_no_deprecation_warning_when_ipinfo_unset() -> None:
if "ipinfo" in str(record.message) and "deprecated" in str(record.message)
]
assert ipinfo_deprecations == []


def test_no_deprecation_warning_when_ipinfo_explicitly_none() -> None:
with warnings.catch_warnings(record=True) as records:
warnings.simplefilter("always")
SecurityConfig(ipinfo_token=None, ipinfo_db_path=None)
ipinfo_deprecations = [
record
for record in records
if "ipinfo" in str(record.message) and "deprecated" in str(record.message)
]
assert ipinfo_deprecations == []
57 changes: 57 additions & 0 deletions tests/test_sync/test_sus_patterns/test_anomaly_scoring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import pytest
from pydantic import ValidationError

from guard_core.models import SecurityConfig
from guard_core.sync.handlers.suspatterns_handler import (
DETECTION_CATEGORY_WEIGHTS,
SusPatternsManager,
_resolve_pattern_weight,
)


def test_threshold_field_default_and_bounds():
assert SecurityConfig().detection_threat_score_threshold == 1.0
raised = SecurityConfig(detection_threat_score_threshold=2.5)
assert raised.detection_threat_score_threshold == 2.5
with pytest.raises(ValidationError):
SecurityConfig(detection_threat_score_threshold=-1.0)


def test_resolve_weight_defaults_to_category_one():
assert _resolve_pattern_weight(r"some-pattern", "sqli") == 1.0
assert DETECTION_CATEGORY_WEIGHTS["sqli"] == 1.0


def test_regex_threat_dict_carries_weight(sus_patterns_manager_with_detection):
result = sus_patterns_manager_with_detection.detect(
"<script>alert(1)</script>", "127.0.0.1", context="unknown"
)
regex_threats = [t for t in result["threats"] if t["type"] == "regex"]
assert regex_threats
assert all(t["weight"] == 1.0 for t in regex_threats)


def test_single_match_still_flagged_at_default_threshold(
sus_patterns_manager_with_detection,
):
result = sus_patterns_manager_with_detection.detect(
"<script>alert(1)</script>", "127.0.0.1", context="unknown"
)
assert result["is_threat"] is True
assert result["threat_score"] == 1.0


def test_threshold_gate_suppresses_below_threshold():
SusPatternsManager._instance = None
SusPatternsManager._config = None
config = SecurityConfig(detection_threat_score_threshold=2.0)
manager = SusPatternsManager(config)
try:
result = manager.detect(
"<script>alert(1)</script>", "127.0.0.1", context="unknown"
)
assert result["is_threat"] is False
finally:
manager.reset()
SusPatternsManager._instance = None
SusPatternsManager._config = None
77 changes: 77 additions & 0 deletions tests/test_sync/test_sus_patterns/test_fp_reduction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
def _flagged(manager, payload: str) -> bool:
result = manager.detect(payload, "127.0.0.1", context="unknown")
return result["is_threat"]


def test_select_star_still_flagged(sus_patterns_manager_with_detection):
assert _flagged(sus_patterns_manager_with_detection, "SELECT * FROM users")


def test_select_where_still_flagged(sus_patterns_manager_with_detection):
assert _flagged(
sus_patterns_manager_with_detection, "SELECT password FROM users WHERE id=1"
)


def test_select_prose_not_flagged(sus_patterns_manager_with_detection):
assert not _flagged(
sus_patterns_manager_with_detection,
"I'll select a few items from the catalog for you",
)


def test_select_candidates_prose_not_flagged(sus_patterns_manager_with_detection):
assert not _flagged(
sus_patterns_manager_with_detection,
"we will select candidates from the applicant pool",
)


def test_order_by_attack_flagged(sus_patterns_manager_with_detection):
assert _flagged(sus_patterns_manager_with_detection, "1' ORDER BY 1--")


def test_order_by_prose_not_flagged(sus_patterns_manager_with_detection):
assert not _flagged(
sus_patterns_manager_with_detection,
"sort the results, order by 1 ascending then by name",
)


def test_ddl_attack_flagged(sus_patterns_manager_with_detection):
assert _flagged(sus_patterns_manager_with_detection, "'; DROP TABLE users;--")


def test_ddl_prose_not_flagged(sus_patterns_manager_with_detection):
assert not _flagged(
sus_patterns_manager_with_detection,
"drop table 7 is reserved for the wedding party",
)


def test_erb_attack_flagged(sus_patterns_manager_with_detection):
assert _flagged(sus_patterns_manager_with_detection, "<%= 7*7 %>")


def test_erb_docs_not_flagged(sus_patterns_manager_with_detection):
assert not _flagged(
sus_patterns_manager_with_detection,
"render <%= @user.name %> inside your erb view",
)


def test_nosql_where_op_flagged(sus_patterns_manager_with_detection):
assert _flagged(
sus_patterns_manager_with_detection, '{"$where": "this.password.length > 0"}'
)


def test_nosql_ne_null_flagged(sus_patterns_manager_with_detection):
assert _flagged(sus_patterns_manager_with_detection, '{"$ne":null}')


def test_nosql_legit_value_not_flagged(sus_patterns_manager_with_detection):
assert not _flagged(
sus_patterns_manager_with_detection,
'the mongo filter {"$gt": 5} returns larger values',
)
Loading
Loading