Skip to content

Commit acf1fc7

Browse files
authored
Merge branch 'master' into fix/ipinfo-deprecation-skip-none
2 parents 46b48d1 + c33407b commit acf1fc7

41 files changed

Lines changed: 1135 additions & 19 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,4 +209,5 @@ __marimo__/
209209
.DS_Store
210210
manual_testing/
211211
ZZZ/
212-
.claude/
212+
.claude/
213+
tests/attack_simulation/out/

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,11 @@ local-test:
231231
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
232232

233233

234+
.PHONY: attack-sim
235+
attack-sim:
236+
@uv run python -m tests.attack_simulation
237+
238+
234239
.PHONY: integration-test
235240
integration-test:
236241
@INTEGRATION_TESTS=1 REDIS_URL=$${REDIS_URL:-redis://localhost:6379} IPINFO_TOKEN=$${IPINFO_TOKEN:-test_token} REDIS_PREFIX=$${REDIS_PREFIX:-test:guard_core:} uv run pytest tests/integration -m integration -v

guard_core/detection_engine/preprocessor.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,9 @@ def __init__(
5050
)
5151
_HEX_ESCAPE_RE = re.compile(r"\\x([0-9a-fA-F]{2})")
5252
_UNICODE_ESCAPE_RE = re.compile(r"\\u([0-9a-fA-F]{4})")
53-
_SQL_BLOCK_COMMENT_INNER_UPPER_RE = re.compile(
54-
r"(?<=[A-Z])/\*.*?\*/(?=[A-Z])", re.DOTALL
53+
_SQL_BLOCK_COMMENT_STRIP_RE = re.compile(
54+
r"(?<!\w)/\*(?!!).*?\*/|/\*(?!!).*?\*/(?!\w)", re.DOTALL
5555
)
56-
_SQL_BLOCK_COMMENT_INNER_LOWER_RE = re.compile(
57-
r"(?<=[a-z])/\*.*?\*/(?=[a-z])", re.DOTALL
58-
)
59-
_SQL_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
6056
_SQL_LINE_COMMENT_RE = re.compile(r"(--|#)[^\n]*")
6157

6258
async def _send_preprocessor_event(
@@ -122,6 +118,9 @@ def normalize_unicode(self, content: str) -> str:
122118
"\u2216": "\\",
123119
"\uff1c": "<",
124120
"\uff1e": ">",
121+
"\uff1b": ";",
122+
"\uff5c": "|",
123+
"\uff06": "&",
125124
}
126125

127126
for char, replacement in lookalikes.items():
@@ -277,9 +276,7 @@ def _replace(match: re.Match[str]) -> str:
277276
return self._UNICODE_ESCAPE_RE.sub(_replace, content)
278277

279278
def _strip_sql_comments(self, content: str) -> str:
280-
content = self._SQL_BLOCK_COMMENT_INNER_UPPER_RE.sub("", content)
281-
content = self._SQL_BLOCK_COMMENT_INNER_LOWER_RE.sub("", content)
282-
content = self._SQL_BLOCK_COMMENT_RE.sub(" ", content)
279+
content = self._SQL_BLOCK_COMMENT_STRIP_RE.sub(" ", content)
283280
content = self._SQL_LINE_COMMENT_RE.sub(" ", content)
284281
return content
285282

guard_core/handlers/suspatterns_handler.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
_CTX_SENSITIVE_FILE = frozenset({"url_path", "request_body", "unknown"})
2727
_CTX_CMS_PROBING = frozenset({"url_path", "request_body", "unknown"})
2828
_CTX_RECON = frozenset({"url_path", "unknown"})
29+
_CTX_PROTO_POLLUTION = frozenset({"query_param", "request_body", "unknown"})
30+
_CTX_CODE_INJECTION = frozenset({"query_param", "request_body", "unknown"})
2931
_CTX_ALL = frozenset({"query_param", "header", "url_path", "request_body", "unknown"})
3032

3133

@@ -47,6 +49,8 @@
4749
"sensitive_file",
4850
"cms_probing",
4951
"recon",
52+
"proto_pollution",
53+
"code_injection",
5054
}
5155
)
5256

@@ -67,6 +71,8 @@
6771
"sensitive_file": _CTX_SENSITIVE_FILE,
6872
"cms_probing": _CTX_CMS_PROBING,
6973
"recon": _CTX_RECON,
74+
"proto_pollution": _CTX_PROTO_POLLUTION,
75+
"code_injection": _CTX_CODE_INJECTION,
7076
}
7177

7278

@@ -122,6 +128,19 @@ class SusPatternsManager:
122128
_CTX_SQLI,
123129
"sqli",
124130
),
131+
(r"\w/\*(?!!)[^*]*\*/\w", _CTX_SQLI, "sqli"),
132+
(r"(?i)(?:OR|AND)\s+'[\w\d]*'='[\w\d]*'?", _CTX_SQLI, "sqli"),
133+
(
134+
r"(?i)(?:^|;)\s*(?:DROP|TRUNCATE|ALTER)\s+(?:TABLE|DATABASE|SCHEMA)\b",
135+
_CTX_SQLI,
136+
"sqli",
137+
),
138+
(
139+
r"(?i);\s*(?:INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b",
140+
_CTX_SQLI,
141+
"sqli",
142+
),
143+
(r"(?i)\bORDER\s+BY\s+\d+\b", _CTX_SQLI, "sqli"),
125144
(r"(?:\.\.\/|\.\.\\)(?:\.\.\/|\.\.\\)+", _CTX_DIR_TRAVERSAL, "dir_traversal"),
126145
(
127146
r"(?:/etc/(?:passwd|shadow|group|hosts|motd|issue|mysql/my.cnf|ssh/"
@@ -162,6 +181,16 @@ class SusPatternsManager:
162181
_CTX_CMD_INJECTION,
163182
"cmd_injection",
164183
),
184+
(
185+
r"[;|&]\s*(?:ls|cat|rm|id|whoami|uname|wget|curl|nc|netcat|socat|bash|sh|python|perl)\b",
186+
_CTX_CMD_INJECTION,
187+
"cmd_injection",
188+
),
189+
(
190+
r"(?i)\b(?:nc|netcat|ncat)\s+-[a-z]*e\b|/dev/tcp/\d",
191+
_CTX_CMD_INJECTION,
192+
"cmd_injection",
193+
),
165194
(
166195
r"(?:php|data|zip|rar|file|glob|expect|input|phpinfo|zlib|phar|ssh2|"
167196
r"rar|ogg|expect)://[^\s]+",
@@ -194,6 +223,11 @@ class SusPatternsManager:
194223
"nosql",
195224
),
196225
(r"(?:\{\s*\$[a-zA-Z]+\s*:\s*(?:\{|\[))", _CTX_NOSQL, "nosql"),
226+
(
227+
r'"\$(?:where|gt|gte|lt|lte|ne|eq|regex|in|nin|all|size|exists|type|mod|options|expr|jsonSchema)"\s*:',
228+
_CTX_NOSQL,
229+
"nosql",
230+
),
197231
(
198232
r"(?i)filename=[\"'].*?\.(?:php\d*|phar|phtml|exe|jsp|asp|aspx|sh|"
199233
r"bash|rb|py|pl|cgi|com|bat|cmd|vbs|vbe|js|ws|wsf|msi|hta)[\"\']",
@@ -216,6 +250,12 @@ class SusPatternsManager:
216250
_CTX_TEMPLATE,
217251
"template",
218252
),
253+
(r"<%[=#]?[^%]*%>", _CTX_TEMPLATE, "template"),
254+
(
255+
r"\$\{[^}]*(?:@[\w.]+@|\b\w+\s*\(|\d+\s*[*/%+\-]\s*\d+)[^}]*\}",
256+
_CTX_TEMPLATE,
257+
"template",
258+
),
219259
(
220260
r"[\r\n]\s*(?:HTTP\/[0-9.]+|Location:|Set-Cookie:)",
221261
_CTX_HTTP_SPLIT,
@@ -334,6 +374,16 @@ class SusPatternsManager:
334374
(r"(?:^|/)autodiscover/", _CTX_RECON, "recon"),
335375
(r"^/dns-query(?:\?|$)", _CTX_RECON, "recon"),
336376
(r"(?:^|/)\.git/(?:refs|index|HEAD|objects|logs)(?:/|$)", _CTX_RECON, "recon"),
377+
(
378+
r"(?:__proto__|constructor)\s*(?:\[\s*[\"']prototype[\"']\s*\]|\.\s*prototype)|[\"']__proto__[\"']\s*:",
379+
_CTX_PROTO_POLLUTION,
380+
"proto_pollution",
381+
),
382+
(
383+
r"System\.Diagnostics\.Process\.Start\s*\(|System\.Reflection\.|Assembly\.Load\s*\(",
384+
_CTX_CODE_INJECTION,
385+
"code_injection",
386+
),
337387
]
338388

339389
patterns: list[str] = [p[0] for p in _pattern_definitions]

guard_core/sync/detection_engine/preprocessor.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,9 @@ def __init__(
5050
)
5151
_HEX_ESCAPE_RE = re.compile(r"\\x([0-9a-fA-F]{2})")
5252
_UNICODE_ESCAPE_RE = re.compile(r"\\u([0-9a-fA-F]{4})")
53-
_SQL_BLOCK_COMMENT_INNER_UPPER_RE = re.compile(
54-
r"(?<=[A-Z])/\*.*?\*/(?=[A-Z])", re.DOTALL
53+
_SQL_BLOCK_COMMENT_STRIP_RE = re.compile(
54+
r"(?<!\w)/\*(?!!).*?\*/|/\*(?!!).*?\*/(?!\w)", re.DOTALL
5555
)
56-
_SQL_BLOCK_COMMENT_INNER_LOWER_RE = re.compile(
57-
r"(?<=[a-z])/\*.*?\*/(?=[a-z])", re.DOTALL
58-
)
59-
_SQL_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
6056
_SQL_LINE_COMMENT_RE = re.compile(r"(--|#)[^\n]*")
6157

6258
def _send_preprocessor_event(
@@ -122,6 +118,9 @@ def normalize_unicode(self, content: str) -> str:
122118
"\u2216": "\\",
123119
"\uff1c": "<",
124120
"\uff1e": ">",
121+
"\uff1b": ";",
122+
"\uff5c": "|",
123+
"\uff06": "&",
125124
}
126125

127126
for char, replacement in lookalikes.items():
@@ -277,9 +276,7 @@ def _replace(match: re.Match[str]) -> str:
277276
return self._UNICODE_ESCAPE_RE.sub(_replace, content)
278277

279278
def _strip_sql_comments(self, content: str) -> str:
280-
content = self._SQL_BLOCK_COMMENT_INNER_UPPER_RE.sub("", content)
281-
content = self._SQL_BLOCK_COMMENT_INNER_LOWER_RE.sub("", content)
282-
content = self._SQL_BLOCK_COMMENT_RE.sub(" ", content)
279+
content = self._SQL_BLOCK_COMMENT_STRIP_RE.sub(" ", content)
283280
content = self._SQL_LINE_COMMENT_RE.sub(" ", content)
284281
return content
285282

guard_core/sync/handlers/suspatterns_handler.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
_CTX_SENSITIVE_FILE = frozenset({"url_path", "request_body", "unknown"})
2727
_CTX_CMS_PROBING = frozenset({"url_path", "request_body", "unknown"})
2828
_CTX_RECON = frozenset({"url_path", "unknown"})
29+
_CTX_PROTO_POLLUTION = frozenset({"query_param", "request_body", "unknown"})
30+
_CTX_CODE_INJECTION = frozenset({"query_param", "request_body", "unknown"})
2931
_CTX_ALL = frozenset({"query_param", "header", "url_path", "request_body", "unknown"})
3032

3133

@@ -47,6 +49,8 @@
4749
"sensitive_file",
4850
"cms_probing",
4951
"recon",
52+
"proto_pollution",
53+
"code_injection",
5054
}
5155
)
5256

@@ -67,6 +71,8 @@
6771
"sensitive_file": _CTX_SENSITIVE_FILE,
6872
"cms_probing": _CTX_CMS_PROBING,
6973
"recon": _CTX_RECON,
74+
"proto_pollution": _CTX_PROTO_POLLUTION,
75+
"code_injection": _CTX_CODE_INJECTION,
7076
}
7177

7278

@@ -122,6 +128,19 @@ class SusPatternsManager:
122128
_CTX_SQLI,
123129
"sqli",
124130
),
131+
(r"\w/\*(?!!)[^*]*\*/\w", _CTX_SQLI, "sqli"),
132+
(r"(?i)(?:OR|AND)\s+'[\w\d]*'='[\w\d]*'?", _CTX_SQLI, "sqli"),
133+
(
134+
r"(?i)(?:^|;)\s*(?:DROP|TRUNCATE|ALTER)\s+(?:TABLE|DATABASE|SCHEMA)\b",
135+
_CTX_SQLI,
136+
"sqli",
137+
),
138+
(
139+
r"(?i);\s*(?:INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b",
140+
_CTX_SQLI,
141+
"sqli",
142+
),
143+
(r"(?i)\bORDER\s+BY\s+\d+\b", _CTX_SQLI, "sqli"),
125144
(r"(?:\.\.\/|\.\.\\)(?:\.\.\/|\.\.\\)+", _CTX_DIR_TRAVERSAL, "dir_traversal"),
126145
(
127146
r"(?:/etc/(?:passwd|shadow|group|hosts|motd|issue|mysql/my.cnf|ssh/"
@@ -162,6 +181,16 @@ class SusPatternsManager:
162181
_CTX_CMD_INJECTION,
163182
"cmd_injection",
164183
),
184+
(
185+
r"[;|&]\s*(?:ls|cat|rm|id|whoami|uname|wget|curl|nc|netcat|socat|bash|sh|python|perl)\b",
186+
_CTX_CMD_INJECTION,
187+
"cmd_injection",
188+
),
189+
(
190+
r"(?i)\b(?:nc|netcat|ncat)\s+-[a-z]*e\b|/dev/tcp/\d",
191+
_CTX_CMD_INJECTION,
192+
"cmd_injection",
193+
),
165194
(
166195
r"(?:php|data|zip|rar|file|glob|expect|input|phpinfo|zlib|phar|ssh2|"
167196
r"rar|ogg|expect)://[^\s]+",
@@ -194,6 +223,11 @@ class SusPatternsManager:
194223
"nosql",
195224
),
196225
(r"(?:\{\s*\$[a-zA-Z]+\s*:\s*(?:\{|\[))", _CTX_NOSQL, "nosql"),
226+
(
227+
r'"\$(?:where|gt|gte|lt|lte|ne|eq|regex|in|nin|all|size|exists|type|mod|options|expr|jsonSchema)"\s*:',
228+
_CTX_NOSQL,
229+
"nosql",
230+
),
197231
(
198232
r"(?i)filename=[\"'].*?\.(?:php\d*|phar|phtml|exe|jsp|asp|aspx|sh|"
199233
r"bash|rb|py|pl|cgi|com|bat|cmd|vbs|vbe|js|ws|wsf|msi|hta)[\"\']",
@@ -216,6 +250,12 @@ class SusPatternsManager:
216250
_CTX_TEMPLATE,
217251
"template",
218252
),
253+
(r"<%[=#]?[^%]*%>", _CTX_TEMPLATE, "template"),
254+
(
255+
r"\$\{[^}]*(?:@[\w.]+@|\b\w+\s*\(|\d+\s*[*/%+\-]\s*\d+)[^}]*\}",
256+
_CTX_TEMPLATE,
257+
"template",
258+
),
219259
(
220260
r"[\r\n]\s*(?:HTTP\/[0-9.]+|Location:|Set-Cookie:)",
221261
_CTX_HTTP_SPLIT,
@@ -334,6 +374,16 @@ class SusPatternsManager:
334374
(r"(?:^|/)autodiscover/", _CTX_RECON, "recon"),
335375
(r"^/dns-query(?:\?|$)", _CTX_RECON, "recon"),
336376
(r"(?:^|/)\.git/(?:refs|index|HEAD|objects|logs)(?:/|$)", _CTX_RECON, "recon"),
377+
(
378+
r"(?:__proto__|constructor)\s*(?:\[\s*[\"']prototype[\"']\s*\]|\.\s*prototype)|[\"']__proto__[\"']\s*:",
379+
_CTX_PROTO_POLLUTION,
380+
"proto_pollution",
381+
),
382+
(
383+
r"System\.Diagnostics\.Process\.Start\s*\(|System\.Reflection\.|Assembly\.Load\s*\(",
384+
_CTX_CODE_INJECTION,
385+
"code_injection",
386+
),
337387
]
338388

339389
patterns: list[str] = [p[0] for p in _pattern_definitions]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Attack-simulation campaign — human gate
2+
3+
The AI-coordinated campaign (`.claude/workflows/attack-campaign.workflow.js`, untracked)
4+
is a discovery tool. It never edits the committed corpus. It writes proposals to
5+
`tests/attack_simulation/out/` (git-ignored):
6+
7+
- `campaign-report.md` — human-readable confirmed bypasses, clustered by class+technique.
8+
- `campaign-proposals.json` — machine-readable, each with a `disposition` (`seed` or `transform`).
9+
10+
The campaign's own verification is only as strong as the model it ran on. Treat every
11+
proposal as a candidate, not a fact. Re-verify each against the real detector before
12+
promoting it:
13+
14+
```python
15+
import asyncio
16+
from tests.attack_simulation.score_payloads import score_payloads
17+
print(asyncio.run(score_payloads(["<candidate payload>"])))
18+
```
19+
20+
A `detected: false` result confirms the detector genuinely misses it; then judge whether
21+
it is a genuine attack worth keeping.
22+
23+
## Promoting a proposal (manual)
24+
25+
1. Read `out/campaign-report.md`. Decide which confirmed bypasses are worth keeping.
26+
2. Re-verify each chosen payload with `score_payloads` (above) — keep only real misses.
27+
3. For a `seed`: add the payload to `corpus/seeds/<attack_class>.txt` under a
28+
`# source: campaign-derived, verified` header line.
29+
4. For a `transform`: add the function to `mutations.py` and a unit test in
30+
`test_mutations.py`, then register it in `TRANSFORMS`.
31+
5. Regenerate the baseline:
32+
`uv run python -c "import asyncio,json; from pathlib import Path; from tests.attack_simulation.harness import run_benchmark; b=Path('tests/attack_simulation'); r=asyncio.run(run_benchmark(b/'corpus')); (b/'baseline.json').write_text(json.dumps({'detection_rate':r['detection_rate'],'fp_rate':r['fp_rate']},indent=2,sort_keys=True)+chr(10))"`
33+
6. Run `uv run pytest tests/attack_simulation/ --no-cov` and commit the corpus +
34+
baseline changes. The ratchet now protects the new coverage.
35+
36+
Nothing from the campaign is committed except what a human promotes here.

tests/attack_simulation/__init__.py

Whitespace-only changes.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import asyncio
2+
from pathlib import Path
3+
4+
from tests.attack_simulation.harness import run_benchmark
5+
from tests.attack_simulation.reporter import write_reports
6+
7+
_BASE = Path(__file__).parent
8+
9+
10+
def main() -> None:
11+
report = asyncio.run(run_benchmark(_BASE / "corpus"))
12+
json_path, md_path = write_reports(report, _BASE / "out")
13+
print(f"wrote {json_path}")
14+
print(f"wrote {md_path}")
15+
print(
16+
f"detection_rate={report['detection_rate']:.3f} "
17+
f"fp_rate={report['fp_rate']:.3f} "
18+
f"runtime_s={report['runtime_seconds']:.2f}"
19+
)
20+
21+
22+
if __name__ == "__main__":
23+
main()
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"detection_rate": 0.8568421052631578,
3+
"fp_rate": 0.125
4+
}

0 commit comments

Comments
 (0)