attackmap 0.6.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- attackmap-0.6.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Structural Sigma checks and a small event matcher (#251).
|
|
2
|
+
|
|
3
|
+
AttackMap generates Sigma rules but does not depend on pySigma. This module
|
|
4
|
+
holds what the generator and its tests need without it:
|
|
5
|
+
|
|
6
|
+
- :func:`sigma_problems` / :func:`correlation_problems` check a rule dict
|
|
7
|
+
against the Sigma specification (required keys, value formats, tag syntax,
|
|
8
|
+
and that the condition only names search identifiers the rule defines).
|
|
9
|
+
- :func:`sigma_matches` evaluates a rule against one flat or nested event, so
|
|
10
|
+
each rule's ``test_events`` can be checked: positives must match, negatives
|
|
11
|
+
must not. It supports the subset of Sigma the templates use: field maps and
|
|
12
|
+
lists of maps, ``null``, wildcards, the ``contains`` / ``startswith`` /
|
|
13
|
+
``endswith`` / ``all`` / ``re`` / ``cidr`` modifiers, and conditions built
|
|
14
|
+
from ``and`` / ``or`` / ``not`` / parentheses / ``1 of`` / ``all of``.
|
|
15
|
+
- :func:`correlation_fires` evaluates a ``value_count`` or ``event_count``
|
|
16
|
+
correlation over a list of events (no time window: the caller passes the
|
|
17
|
+
events of one window).
|
|
18
|
+
|
|
19
|
+
None of this raises on malformed input: problems are returned as strings and
|
|
20
|
+
an expression that can't be parsed simply doesn't match.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import fnmatch
|
|
26
|
+
import ipaddress
|
|
27
|
+
import re
|
|
28
|
+
import uuid
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
STATUSES = ("stable", "test", "experimental", "deprecated", "unsupported")
|
|
32
|
+
LEVELS = ("informational", "low", "medium", "high", "critical")
|
|
33
|
+
MODIFIERS = frozenset({"contains", "startswith", "endswith", "all", "re", "cidr", "i", "m", "s", "exists"})
|
|
34
|
+
CORRELATION_TYPES = ("event_count", "value_count", "temporal", "temporal_ordered")
|
|
35
|
+
|
|
36
|
+
# Used with fullmatch only; the \A anchors keep a stray search() linear.
|
|
37
|
+
_TAG = re.compile(r"\A[a-z0-9_-]+\.[a-z0-9._-]+")
|
|
38
|
+
_ATTACK_TAG = re.compile(r"\Aattack\.[a-z0-9-]+(?:\.\d{3})?")
|
|
39
|
+
_CWE_TAG = re.compile(r"\Acwe\.\d{1,5}")
|
|
40
|
+
_DATE = re.compile(r"\A\d{4}-\d{2}-\d{2}")
|
|
41
|
+
_TIMESPAN = re.compile(r"\A\d{1,6}[smhdwMy]")
|
|
42
|
+
_IDENTIFIER = re.compile(r"\A[A-Za-z0-9_]+")
|
|
43
|
+
_NAME = re.compile(r"\A[a-z0-9_]{1,128}")
|
|
44
|
+
# A condition token, or any other non-space character (which is an error).
|
|
45
|
+
_CONDITION_TOKEN = re.compile(r"[()]|[A-Za-z0-9_*]+|\S")
|
|
46
|
+
_WORDS = frozenset({"and", "or", "not", "of", "them", "1", "all"})
|
|
47
|
+
_MAX_CONDITION_TOKENS = 200
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------- validation
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_uuid(value: object) -> bool:
|
|
54
|
+
if not isinstance(value, str):
|
|
55
|
+
return False
|
|
56
|
+
try:
|
|
57
|
+
uuid.UUID(value)
|
|
58
|
+
except ValueError:
|
|
59
|
+
return False
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _str_list(value: object) -> bool:
|
|
64
|
+
return isinstance(value, list) and all(isinstance(v, str) and v for v in value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _search_problems(name: str, search: object) -> list[str]:
|
|
68
|
+
if isinstance(search, dict):
|
|
69
|
+
maps = [search]
|
|
70
|
+
elif isinstance(search, list) and search and all(isinstance(m, dict) for m in search):
|
|
71
|
+
maps = search
|
|
72
|
+
else:
|
|
73
|
+
return [f"detection.{name} must be a field map or a list of field maps"]
|
|
74
|
+
problems: list[str] = []
|
|
75
|
+
for field_map in maps:
|
|
76
|
+
if not field_map:
|
|
77
|
+
problems.append(f"detection.{name} has an empty field map")
|
|
78
|
+
for key, value in field_map.items():
|
|
79
|
+
if not isinstance(key, str) or not key:
|
|
80
|
+
problems.append(f"detection.{name} has a non-string field name")
|
|
81
|
+
continue
|
|
82
|
+
field, *mods = key.split("|")
|
|
83
|
+
if not field:
|
|
84
|
+
problems.append(f"detection.{name}: empty field name in {key!r}")
|
|
85
|
+
unknown = [m for m in mods if m not in MODIFIERS]
|
|
86
|
+
if unknown:
|
|
87
|
+
problems.append(f"detection.{name}: unknown modifier(s) {unknown} in {key!r}")
|
|
88
|
+
values = value if isinstance(value, list) else [value]
|
|
89
|
+
if isinstance(value, list) and not value:
|
|
90
|
+
problems.append(f"detection.{name}.{key} is an empty list")
|
|
91
|
+
for v in values:
|
|
92
|
+
if v is not None and not isinstance(v, (str, int, float, bool)):
|
|
93
|
+
problems.append(f"detection.{name}.{key} has a non-scalar value")
|
|
94
|
+
break
|
|
95
|
+
if "re" in mods and isinstance(v, str):
|
|
96
|
+
try:
|
|
97
|
+
re.compile(v)
|
|
98
|
+
except re.error as exc:
|
|
99
|
+
problems.append(f"detection.{name}.{key}: regex does not compile ({exc})")
|
|
100
|
+
if "cidr" in mods and isinstance(v, str):
|
|
101
|
+
try:
|
|
102
|
+
ipaddress.ip_network(v, strict=False)
|
|
103
|
+
except ValueError:
|
|
104
|
+
problems.append(f"detection.{name}.{key}: {v!r} is not a CIDR range")
|
|
105
|
+
return problems
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def sigma_problems(rule: object) -> list[str]:
|
|
109
|
+
"""Problems that make ``rule`` an invalid Sigma detection rule; empty when
|
|
110
|
+
it is structurally valid."""
|
|
111
|
+
if not isinstance(rule, dict):
|
|
112
|
+
return ["rule is not a mapping"]
|
|
113
|
+
problems: list[str] = []
|
|
114
|
+
for key in ("title", "id", "status", "description", "author", "date", "logsource", "detection", "level"):
|
|
115
|
+
if key not in rule:
|
|
116
|
+
problems.append(f"missing required key {key!r}")
|
|
117
|
+
title = rule.get("title")
|
|
118
|
+
if "title" in rule and (not isinstance(title, str) or not title or len(title) > 256):
|
|
119
|
+
problems.append("title must be a non-empty string of at most 256 characters")
|
|
120
|
+
if "id" in rule and not _is_uuid(rule.get("id")):
|
|
121
|
+
problems.append("id must be a UUID")
|
|
122
|
+
if "name" in rule and not (isinstance(rule["name"], str) and _NAME.fullmatch(rule["name"])):
|
|
123
|
+
problems.append("name must be lower-case letters, digits and underscores")
|
|
124
|
+
if "status" in rule and rule.get("status") not in STATUSES:
|
|
125
|
+
problems.append(f"status must be one of {STATUSES}")
|
|
126
|
+
if "level" in rule and rule.get("level") not in LEVELS:
|
|
127
|
+
problems.append(f"level must be one of {LEVELS}")
|
|
128
|
+
for key in ("description", "author"):
|
|
129
|
+
if key in rule and not (isinstance(rule[key], str) and rule[key]):
|
|
130
|
+
problems.append(f"{key} must be a non-empty string")
|
|
131
|
+
if "date" in rule and not (isinstance(rule["date"], str) and _DATE.fullmatch(rule["date"])):
|
|
132
|
+
problems.append("date must be YYYY-MM-DD")
|
|
133
|
+
for key in ("references", "falsepositives", "fields"):
|
|
134
|
+
if key in rule and not _str_list(rule[key]):
|
|
135
|
+
problems.append(f"{key} must be a list of non-empty strings")
|
|
136
|
+
tags = rule.get("tags", [])
|
|
137
|
+
if not _str_list(tags):
|
|
138
|
+
problems.append("tags must be a list of strings")
|
|
139
|
+
else:
|
|
140
|
+
for tag in tags:
|
|
141
|
+
if not _TAG.fullmatch(tag):
|
|
142
|
+
problems.append(f"malformed tag {tag!r}")
|
|
143
|
+
elif tag.startswith("attack.") and not _ATTACK_TAG.fullmatch(tag):
|
|
144
|
+
problems.append(f"malformed ATT&CK tag {tag!r}")
|
|
145
|
+
elif tag.startswith("cwe.") and not _CWE_TAG.fullmatch(tag):
|
|
146
|
+
problems.append(f"malformed CWE tag {tag!r}")
|
|
147
|
+
logsource = rule.get("logsource")
|
|
148
|
+
if "logsource" in rule:
|
|
149
|
+
if not isinstance(logsource, dict) or not any(logsource.get(k) for k in ("product", "service", "category")):
|
|
150
|
+
problems.append("logsource needs at least one of product, service, category")
|
|
151
|
+
elif not all(isinstance(v, str) and v for v in logsource.values()):
|
|
152
|
+
problems.append("logsource values must be non-empty strings")
|
|
153
|
+
detection = rule.get("detection")
|
|
154
|
+
if "detection" in rule:
|
|
155
|
+
problems += _detection_problems(detection)
|
|
156
|
+
return problems
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _detection_problems(detection: object) -> list[str]:
|
|
160
|
+
if not isinstance(detection, dict):
|
|
161
|
+
return ["detection must be a mapping"]
|
|
162
|
+
condition = detection.get("condition")
|
|
163
|
+
searches = {k: v for k, v in detection.items() if k not in ("condition", "timeframe")}
|
|
164
|
+
problems: list[str] = []
|
|
165
|
+
if not searches:
|
|
166
|
+
problems.append("detection defines no search identifiers")
|
|
167
|
+
for name, search in searches.items():
|
|
168
|
+
if not isinstance(name, str) or not _IDENTIFIER.fullmatch(name):
|
|
169
|
+
problems.append(f"bad search identifier {name!r}")
|
|
170
|
+
continue
|
|
171
|
+
problems += _search_problems(name, search)
|
|
172
|
+
conditions = condition if isinstance(condition, list) else [condition]
|
|
173
|
+
if condition is None or not all(isinstance(c, str) and c.strip() for c in conditions):
|
|
174
|
+
problems.append("detection.condition must be a non-empty string")
|
|
175
|
+
return problems
|
|
176
|
+
for text in conditions:
|
|
177
|
+
names = _condition_names(text)
|
|
178
|
+
if names is None:
|
|
179
|
+
problems.append(f"condition {text!r} does not parse")
|
|
180
|
+
continue
|
|
181
|
+
for name in names:
|
|
182
|
+
if "*" in name:
|
|
183
|
+
if not any(fnmatch.fnmatchcase(s, name) for s in searches):
|
|
184
|
+
problems.append(f"condition pattern {name!r} matches no search identifier")
|
|
185
|
+
elif name not in searches:
|
|
186
|
+
problems.append(f"condition names undefined search identifier {name!r}")
|
|
187
|
+
return problems
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def correlation_problems(rule: object, base_rules: list[dict] | None = None) -> list[str]:
|
|
191
|
+
"""Problems with a Sigma correlation rule. ``base_rules`` are the rules it
|
|
192
|
+
may reference (by ``name`` or ``id``)."""
|
|
193
|
+
if not isinstance(rule, dict):
|
|
194
|
+
return ["correlation rule is not a mapping"]
|
|
195
|
+
problems: list[str] = []
|
|
196
|
+
for key in ("title", "id", "status", "correlation"):
|
|
197
|
+
if key not in rule:
|
|
198
|
+
problems.append(f"missing required key {key!r}")
|
|
199
|
+
if "id" in rule and not _is_uuid(rule.get("id")):
|
|
200
|
+
problems.append("id must be a UUID")
|
|
201
|
+
if "status" in rule and rule.get("status") not in STATUSES:
|
|
202
|
+
problems.append(f"status must be one of {STATUSES}")
|
|
203
|
+
if "level" in rule and rule.get("level") not in LEVELS:
|
|
204
|
+
problems.append(f"level must be one of {LEVELS}")
|
|
205
|
+
corr = rule.get("correlation")
|
|
206
|
+
if not isinstance(corr, dict):
|
|
207
|
+
return problems + ["correlation must be a mapping"]
|
|
208
|
+
ctype = corr.get("type")
|
|
209
|
+
if ctype not in CORRELATION_TYPES:
|
|
210
|
+
problems.append(f"correlation.type must be one of {CORRELATION_TYPES}")
|
|
211
|
+
refs = corr.get("rules")
|
|
212
|
+
if not _str_list(refs):
|
|
213
|
+
problems.append("correlation.rules must list the referenced rules")
|
|
214
|
+
elif base_rules is not None:
|
|
215
|
+
known = {r.get("name") for r in base_rules} | {r.get("id") for r in base_rules}
|
|
216
|
+
problems += [f"correlation references unknown rule {ref!r}" for ref in refs if ref not in known]
|
|
217
|
+
if "group-by" in corr and not _str_list(corr["group-by"]):
|
|
218
|
+
problems.append("correlation.group-by must be a list of field names")
|
|
219
|
+
if not (isinstance(corr.get("timespan"), str) and _TIMESPAN.fullmatch(corr["timespan"])):
|
|
220
|
+
problems.append("correlation.timespan must look like 10m / 1h / 1d")
|
|
221
|
+
cond = corr.get("condition")
|
|
222
|
+
if ctype in ("event_count", "value_count"):
|
|
223
|
+
ops = {"gt", "gte", "lt", "lte", "eq"}
|
|
224
|
+
if not isinstance(cond, dict) or not ops & set(cond):
|
|
225
|
+
problems.append("correlation.condition needs one of gt/gte/lt/lte/eq")
|
|
226
|
+
elif not all(isinstance(cond[o], int) and not isinstance(cond[o], bool) for o in ops & set(cond)):
|
|
227
|
+
problems.append("correlation.condition thresholds must be integers")
|
|
228
|
+
if ctype == "value_count" and not (isinstance(cond, dict) and isinstance(cond.get("field"), str)):
|
|
229
|
+
problems.append("value_count correlation.condition needs a field")
|
|
230
|
+
return problems
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------- condition
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _tokens(text: str) -> list[str] | None:
|
|
237
|
+
out: list[str] = []
|
|
238
|
+
for m in _CONDITION_TOKEN.finditer(text):
|
|
239
|
+
token = m.group(0)
|
|
240
|
+
if len(token) == 1 and not (token in "()" or token.isalnum() or token in "_*"):
|
|
241
|
+
return None
|
|
242
|
+
out.append(token)
|
|
243
|
+
if len(out) > _MAX_CONDITION_TOKENS:
|
|
244
|
+
return None
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class _Parser:
|
|
249
|
+
"""Recursive-descent parser for the Sigma condition subset. Produces a
|
|
250
|
+
nested tuple AST: ("and", a, b), ("or", a, b), ("not", a),
|
|
251
|
+
("of", "1"|"all", pattern), ("id", name)."""
|
|
252
|
+
|
|
253
|
+
def __init__(self, tokens: list[str]) -> None:
|
|
254
|
+
self.tokens = tokens
|
|
255
|
+
self.pos = 0
|
|
256
|
+
|
|
257
|
+
def peek(self) -> str | None:
|
|
258
|
+
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
|
259
|
+
|
|
260
|
+
def take(self) -> str | None:
|
|
261
|
+
tok = self.peek()
|
|
262
|
+
self.pos += 1
|
|
263
|
+
return tok
|
|
264
|
+
|
|
265
|
+
def parse(self) -> tuple | None:
|
|
266
|
+
node = self.or_expr()
|
|
267
|
+
return node if node is not None and self.peek() is None else None
|
|
268
|
+
|
|
269
|
+
def or_expr(self) -> tuple | None:
|
|
270
|
+
left = self.and_expr()
|
|
271
|
+
while left is not None and self.peek() == "or":
|
|
272
|
+
self.take()
|
|
273
|
+
right = self.and_expr()
|
|
274
|
+
left = ("or", left, right) if right is not None else None
|
|
275
|
+
return left
|
|
276
|
+
|
|
277
|
+
def and_expr(self) -> tuple | None:
|
|
278
|
+
left = self.not_expr()
|
|
279
|
+
while left is not None and self.peek() == "and":
|
|
280
|
+
self.take()
|
|
281
|
+
right = self.not_expr()
|
|
282
|
+
left = ("and", left, right) if right is not None else None
|
|
283
|
+
return left
|
|
284
|
+
|
|
285
|
+
def not_expr(self) -> tuple | None:
|
|
286
|
+
if self.peek() == "not":
|
|
287
|
+
self.take()
|
|
288
|
+
inner = self.not_expr()
|
|
289
|
+
return ("not", inner) if inner is not None else None
|
|
290
|
+
return self.atom()
|
|
291
|
+
|
|
292
|
+
def atom(self) -> tuple | None:
|
|
293
|
+
tok = self.take()
|
|
294
|
+
if tok is None:
|
|
295
|
+
return None
|
|
296
|
+
if tok == "(":
|
|
297
|
+
inner = self.or_expr()
|
|
298
|
+
return inner if inner is not None and self.take() == ")" else None
|
|
299
|
+
if tok in ("1", "all") and self.peek() == "of":
|
|
300
|
+
self.take()
|
|
301
|
+
target = self.take()
|
|
302
|
+
if target is None or target in ("(", ")") or (target in _WORDS and target != "them"):
|
|
303
|
+
return None
|
|
304
|
+
return ("of", tok, target)
|
|
305
|
+
if tok in _WORDS or tok == ")":
|
|
306
|
+
return None
|
|
307
|
+
return ("id", tok)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _parse_condition(text: str) -> tuple | None:
|
|
311
|
+
tokens = _tokens(text)
|
|
312
|
+
if not tokens:
|
|
313
|
+
return None
|
|
314
|
+
return _Parser(tokens).parse()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _condition_names(text: str) -> list[str] | None:
|
|
318
|
+
"""Search identifiers (and ``x*`` patterns) a condition refers to, or
|
|
319
|
+
``None`` when it doesn't parse. ``them`` refers to every identifier."""
|
|
320
|
+
ast = _parse_condition(text)
|
|
321
|
+
if ast is None:
|
|
322
|
+
return None
|
|
323
|
+
names: list[str] = []
|
|
324
|
+
|
|
325
|
+
def walk(node: tuple) -> None:
|
|
326
|
+
if node[0] == "id":
|
|
327
|
+
names.append(node[1])
|
|
328
|
+
elif node[0] == "of":
|
|
329
|
+
if node[2] != "them":
|
|
330
|
+
names.append(node[2])
|
|
331
|
+
else:
|
|
332
|
+
for child in node[1:]:
|
|
333
|
+
walk(child)
|
|
334
|
+
|
|
335
|
+
walk(ast)
|
|
336
|
+
return names
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------- matching
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _lookup(event: dict, field: str) -> Any:
|
|
343
|
+
if field in event:
|
|
344
|
+
return event[field]
|
|
345
|
+
node: Any = event
|
|
346
|
+
for part in field.split("."):
|
|
347
|
+
if not isinstance(node, dict) or part not in node:
|
|
348
|
+
return None
|
|
349
|
+
node = node[part]
|
|
350
|
+
return node
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _value_matches(actual: Any, expected: Any, mods: list[str]) -> bool:
|
|
354
|
+
if expected is None:
|
|
355
|
+
return actual is None or actual == ""
|
|
356
|
+
if actual is None:
|
|
357
|
+
return False
|
|
358
|
+
if "cidr" in mods:
|
|
359
|
+
try:
|
|
360
|
+
return ipaddress.ip_address(str(actual)) in ipaddress.ip_network(str(expected), strict=False)
|
|
361
|
+
except ValueError:
|
|
362
|
+
return False
|
|
363
|
+
if "re" in mods:
|
|
364
|
+
flags = (re.IGNORECASE if "i" in mods else 0) | (re.MULTILINE if "m" in mods else 0)
|
|
365
|
+
flags |= re.DOTALL if "s" in mods else 0
|
|
366
|
+
try:
|
|
367
|
+
return re.search(str(expected), str(actual), flags) is not None
|
|
368
|
+
except re.error:
|
|
369
|
+
return False
|
|
370
|
+
if isinstance(expected, bool) or isinstance(actual, bool):
|
|
371
|
+
return str(actual).lower() == str(expected).lower()
|
|
372
|
+
if isinstance(expected, (int, float)) and not isinstance(expected, bool):
|
|
373
|
+
try:
|
|
374
|
+
return float(actual) == float(expected)
|
|
375
|
+
except (TypeError, ValueError):
|
|
376
|
+
return False
|
|
377
|
+
pattern = str(expected).lower()
|
|
378
|
+
text = str(actual).lower()
|
|
379
|
+
if "contains" in mods:
|
|
380
|
+
pattern = f"*{pattern}*"
|
|
381
|
+
elif "startswith" in mods:
|
|
382
|
+
pattern = f"{pattern}*"
|
|
383
|
+
elif "endswith" in mods:
|
|
384
|
+
pattern = f"*{pattern}"
|
|
385
|
+
return _wildcard(text, pattern)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _wildcard(text: str, pattern: str) -> bool:
|
|
389
|
+
"""Sigma wildcards: ``*`` any run, ``?`` one character, ``\\`` escapes."""
|
|
390
|
+
parts: list[str] = []
|
|
391
|
+
i = 0
|
|
392
|
+
while i < len(pattern):
|
|
393
|
+
ch = pattern[i]
|
|
394
|
+
if ch == "\\" and i + 1 < len(pattern) and pattern[i + 1] in "*?\\":
|
|
395
|
+
parts.append(re.escape(pattern[i + 1]))
|
|
396
|
+
i += 2
|
|
397
|
+
continue
|
|
398
|
+
parts.append(".*" if ch == "*" else "." if ch == "?" else re.escape(ch))
|
|
399
|
+
i += 1
|
|
400
|
+
return re.fullmatch("".join(parts), text, re.DOTALL) is not None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _field_map_matches(field_map: dict, event: dict) -> bool:
|
|
404
|
+
for key, expected in field_map.items():
|
|
405
|
+
field, *mods = str(key).split("|")
|
|
406
|
+
actual = _lookup(event, field)
|
|
407
|
+
if "exists" in mods:
|
|
408
|
+
if (actual is not None) != bool(expected):
|
|
409
|
+
return False
|
|
410
|
+
continue
|
|
411
|
+
values = expected if isinstance(expected, list) else [expected]
|
|
412
|
+
if "all" in mods:
|
|
413
|
+
if not all(_value_matches(actual, v, mods) for v in values):
|
|
414
|
+
return False
|
|
415
|
+
elif not any(_value_matches(actual, v, mods) for v in values):
|
|
416
|
+
return False
|
|
417
|
+
return True
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _search_matches(search: Any, event: dict) -> bool:
|
|
421
|
+
if isinstance(search, dict):
|
|
422
|
+
return _field_map_matches(search, event)
|
|
423
|
+
if isinstance(search, list):
|
|
424
|
+
return any(isinstance(m, dict) and _field_map_matches(m, event) for m in search)
|
|
425
|
+
return False
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _eval(node: tuple, results: dict[str, bool]) -> bool:
|
|
429
|
+
op = node[0]
|
|
430
|
+
if op == "id":
|
|
431
|
+
return results.get(node[1], False)
|
|
432
|
+
if op == "not":
|
|
433
|
+
return not _eval(node[1], results)
|
|
434
|
+
if op == "and":
|
|
435
|
+
return _eval(node[1], results) and _eval(node[2], results)
|
|
436
|
+
if op == "or":
|
|
437
|
+
return _eval(node[1], results) or _eval(node[2], results)
|
|
438
|
+
# "of"
|
|
439
|
+
quantifier, target = node[1], node[2]
|
|
440
|
+
names = list(results) if target == "them" else [n for n in results if fnmatch.fnmatchcase(n, target)]
|
|
441
|
+
if not names:
|
|
442
|
+
return False
|
|
443
|
+
hits = [results[n] for n in names]
|
|
444
|
+
return all(hits) if quantifier == "all" else any(hits)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def sigma_matches(rule: dict, event: dict) -> bool:
|
|
448
|
+
"""Whether ``event`` satisfies ``rule``'s detection. Unparseable rules
|
|
449
|
+
never match."""
|
|
450
|
+
detection = rule.get("detection") if isinstance(rule, dict) else None
|
|
451
|
+
if not isinstance(detection, dict) or not isinstance(event, dict):
|
|
452
|
+
return False
|
|
453
|
+
condition = detection.get("condition")
|
|
454
|
+
conditions = condition if isinstance(condition, list) else [condition]
|
|
455
|
+
results = {
|
|
456
|
+
name: _search_matches(search, event)
|
|
457
|
+
for name, search in detection.items()
|
|
458
|
+
if name not in ("condition", "timeframe")
|
|
459
|
+
}
|
|
460
|
+
for text in conditions:
|
|
461
|
+
if not isinstance(text, str):
|
|
462
|
+
continue
|
|
463
|
+
ast = _parse_condition(text)
|
|
464
|
+
if ast is not None and _eval(ast, results):
|
|
465
|
+
return True
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def correlation_fires(correlation_rule: dict, base_rule: dict, events: list[dict]) -> bool:
|
|
470
|
+
"""Evaluate an ``event_count`` / ``value_count`` correlation over
|
|
471
|
+
``events`` (taken to fall in one ``timespan`` window)."""
|
|
472
|
+
corr = correlation_rule.get("correlation") if isinstance(correlation_rule, dict) else None
|
|
473
|
+
if not isinstance(corr, dict):
|
|
474
|
+
return False
|
|
475
|
+
cond = corr.get("condition") or {}
|
|
476
|
+
group_by = corr.get("group-by") or []
|
|
477
|
+
groups: dict[tuple, list[dict]] = {}
|
|
478
|
+
for event in events:
|
|
479
|
+
if sigma_matches(base_rule, event):
|
|
480
|
+
key = tuple(_lookup(event, f) for f in group_by)
|
|
481
|
+
groups.setdefault(key, []).append(event)
|
|
482
|
+
for grouped in groups.values():
|
|
483
|
+
if corr.get("type") == "value_count":
|
|
484
|
+
count = len({_lookup(e, str(cond.get("field"))) for e in grouped})
|
|
485
|
+
elif corr.get("type") == "event_count":
|
|
486
|
+
count = len(grouped)
|
|
487
|
+
else:
|
|
488
|
+
return False
|
|
489
|
+
if _threshold(count, cond):
|
|
490
|
+
return True
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _threshold(count: int, cond: dict) -> bool:
|
|
495
|
+
checks = {
|
|
496
|
+
"gt": lambda n: count > n,
|
|
497
|
+
"gte": lambda n: count >= n,
|
|
498
|
+
"lt": lambda n: count < n,
|
|
499
|
+
"lte": lambda n: count <= n,
|
|
500
|
+
"eq": lambda n: count == n,
|
|
501
|
+
}
|
|
502
|
+
applied = [checks[op](cond[op]) for op in checks if isinstance(cond.get(op), int)]
|
|
503
|
+
return bool(applied) and all(applied)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
__all__ = [
|
|
507
|
+
"correlation_fires",
|
|
508
|
+
"correlation_problems",
|
|
509
|
+
"sigma_matches",
|
|
510
|
+
"sigma_problems",
|
|
511
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# SSRF follow-on rules (#251), one each per scan with an `ssrf` finding.
|
|
2
|
+
# The route-scoped request rule lives in injection.yml (payload `ssrf`);
|
|
3
|
+
# these catch what a successful SSRF does next: reach the cloud metadata
|
|
4
|
+
# service, then use the instance role's credentials from somewhere else.
|
|
5
|
+
|
|
6
|
+
templates:
|
|
7
|
+
ssrf-metadata-egress:
|
|
8
|
+
date: "2026-10-12"
|
|
9
|
+
title: "Application workload connects to a cloud instance metadata endpoint"
|
|
10
|
+
description: >-
|
|
11
|
+
A connection from the application to the instance metadata service
|
|
12
|
+
(AWS/GCP/Azure/Alibaba). AttackMap found a request-reachable SSRF sink
|
|
13
|
+
(rule ssrf, ${location}); fetching role credentials from the metadata
|
|
14
|
+
service is the usual next step. Scope the rule to the application's
|
|
15
|
+
hosts or containers.
|
|
16
|
+
logsource:
|
|
17
|
+
category: network_connection
|
|
18
|
+
fields_required: [DestinationIp, DestinationHostname, Image, Initiated]
|
|
19
|
+
detection:
|
|
20
|
+
selection_ip:
|
|
21
|
+
Initiated: "true"
|
|
22
|
+
DestinationIp: ["169.254.169.254", "169.254.170.2", "fd00:ec2::254", "100.100.100.200"]
|
|
23
|
+
selection_host:
|
|
24
|
+
Initiated: "true"
|
|
25
|
+
DestinationHostname: ["metadata.google.internal", "metadata.azure.com"]
|
|
26
|
+
condition: 1 of selection_*
|
|
27
|
+
falsepositives:
|
|
28
|
+
- Cloud SDKs on the same host fetch their own credentials from the metadata service; restrict the rule to the application process (Image) or to containers that should never call it
|
|
29
|
+
- Instance bootstrap agents (cloud-init, the SSM agent)
|
|
30
|
+
level: medium
|
|
31
|
+
test_events:
|
|
32
|
+
positive:
|
|
33
|
+
- Image: /usr/local/bin/app
|
|
34
|
+
Initiated: "true"
|
|
35
|
+
DestinationIp: 169.254.169.254
|
|
36
|
+
DestinationPort: 80
|
|
37
|
+
negative:
|
|
38
|
+
- Image: /usr/local/bin/app
|
|
39
|
+
Initiated: "true"
|
|
40
|
+
DestinationIp: 198.51.100.40
|
|
41
|
+
DestinationPort: 443
|
|
42
|
+
|
|
43
|
+
ssrf-instance-credentials-cloudtrail:
|
|
44
|
+
date: "2026-10-12"
|
|
45
|
+
title: "EC2 instance-role credentials used from outside the instance"
|
|
46
|
+
description: >-
|
|
47
|
+
CloudTrail shows an API call made with an EC2 instance role's session
|
|
48
|
+
(the session name is the instance id) from a source IP that is neither
|
|
49
|
+
an AWS service nor a private address, the pattern GuardDuty reports as
|
|
50
|
+
InstanceCredentialExfiltration. AttackMap found a request-reachable SSRF
|
|
51
|
+
sink (rule ssrf, ${location}) that could leak those credentials. Add
|
|
52
|
+
your NAT gateway and VPC egress IPs to the filter.
|
|
53
|
+
logsource:
|
|
54
|
+
product: aws
|
|
55
|
+
service: cloudtrail
|
|
56
|
+
fields_required: [userIdentity.type, userIdentity.arn, sourceIPAddress, eventName, eventSource]
|
|
57
|
+
detection:
|
|
58
|
+
selection:
|
|
59
|
+
userIdentity.type: AssumedRole
|
|
60
|
+
userIdentity.arn|re: ":assumed-role/[^/]+/i-[0-9a-f]{8,17}$"
|
|
61
|
+
filter_aws_service:
|
|
62
|
+
sourceIPAddress|endswith: ".amazonaws.com"
|
|
63
|
+
filter_aws_internal:
|
|
64
|
+
sourceIPAddress: AWS Internal
|
|
65
|
+
filter_private:
|
|
66
|
+
sourceIPAddress|cidr: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
|
|
67
|
+
condition: selection and not 1 of filter_*
|
|
68
|
+
falsepositives:
|
|
69
|
+
- Instances with a public IP (or behind a NAT gateway) call AWS APIs from that public address when there is no VPC endpoint, so every normal call matches until you add your instances' public and NAT addresses to filter_private
|
|
70
|
+
- Calls routed through VPC endpoints report private source addresses and are already excluded
|
|
71
|
+
level: medium
|
|
72
|
+
test_events:
|
|
73
|
+
positive:
|
|
74
|
+
- eventSource: s3.amazonaws.com
|
|
75
|
+
eventName: ListBuckets
|
|
76
|
+
sourceIPAddress: 203.0.113.77
|
|
77
|
+
userIdentity:
|
|
78
|
+
type: AssumedRole
|
|
79
|
+
arn: arn:aws:sts::123456789012:assumed-role/app-instance-role/i-0abc123def4567890
|
|
80
|
+
negative:
|
|
81
|
+
- eventSource: s3.amazonaws.com
|
|
82
|
+
eventName: ListBuckets
|
|
83
|
+
sourceIPAddress: 10.0.12.34
|
|
84
|
+
userIdentity:
|
|
85
|
+
type: AssumedRole
|
|
86
|
+
arn: arn:aws:sts::123456789012:assumed-role/app-instance-role/i-0abc123def4567890
|
|
87
|
+
- eventSource: s3.amazonaws.com
|
|
88
|
+
eventName: ListBuckets
|
|
89
|
+
sourceIPAddress: 203.0.113.77
|
|
90
|
+
userIdentity:
|
|
91
|
+
type: AssumedRole
|
|
92
|
+
arn: arn:aws:sts::123456789012:assumed-role/deploy-role/ci-session
|
|
93
|
+
- eventSource: kms.amazonaws.com
|
|
94
|
+
eventName: Decrypt
|
|
95
|
+
sourceIPAddress: AWS Internal
|
|
96
|
+
userIdentity:
|
|
97
|
+
type: AssumedRole
|
|
98
|
+
arn: arn:aws:sts::123456789012:assumed-role/app-instance-role/i-0abc123def4567890
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Application-wide request rules for code weaknesses that have no route
|
|
2
|
+
# (#251, part 2). One rule per rule id: the weakness sits in a library call,
|
|
3
|
+
# so any request carrying the class's payload markers is a probe for it.
|
|
4
|
+
# Markers follow the same conventions as injection.yml (raw and
|
|
5
|
+
# URL-encoded, no `*`, `?` or `\`).
|
|
6
|
+
|
|
7
|
+
templates:
|
|
8
|
+
weakness-request-markers:
|
|
9
|
+
date: "2026-10-12"
|
|
10
|
+
title: "${label} probe in a request query string"
|
|
11
|
+
description: >-
|
|
12
|
+
Requests whose query string carries ${label} syntax. AttackMap found
|
|
13
|
+
${kind_label} at ${location} (rule ${rule_id}); probes for it show up as
|
|
14
|
+
these markers. Query strings only: payloads in JSON bodies need a WAF or
|
|
15
|
+
application log mapped to cs-uri-query.
|
|
16
|
+
logsource:
|
|
17
|
+
category: webserver
|
|
18
|
+
fields_required: [cs-method, cs-uri-stem, cs-uri-query, sc-status, c-ip]
|
|
19
|
+
detection:
|
|
20
|
+
selection:
|
|
21
|
+
cs-uri-query|contains: "${markers}"
|
|
22
|
+
condition: selection
|
|
23
|
+
falsepositives: "${falsepositives}"
|
|
24
|
+
level: "${level}"
|
|
25
|
+
test_events:
|
|
26
|
+
positive:
|
|
27
|
+
- cs-method: GET
|
|
28
|
+
cs-uri-stem: /api/items
|
|
29
|
+
cs-uri-query: "${sample}"
|
|
30
|
+
sc-status: 200
|
|
31
|
+
negative:
|
|
32
|
+
- cs-method: GET
|
|
33
|
+
cs-uri-stem: /api/items
|
|
34
|
+
cs-uri-query: "${benign}"
|
|
35
|
+
sc-status: 200
|
|
36
|
+
|
|
37
|
+
data:
|
|
38
|
+
markers:
|
|
39
|
+
prototype-pollution:
|
|
40
|
+
label: Prototype pollution
|
|
41
|
+
kind_label: a request object merged into a target without key filtering
|
|
42
|
+
level: medium
|
|
43
|
+
# Only JavaScript has prototypes to pollute; a deep merge in another
|
|
44
|
+
# language is a mass-assignment problem these markers don't catch.
|
|
45
|
+
suffixes: [.js, .mjs, .cjs, .jsx, .ts, .mts, .cts, .tsx, .vue, .svelte]
|
|
46
|
+
contains: ["__proto__", "%5F%5Fproto%5F%5F", "__proto%5F%5F", "constructor[prototype]", "constructor%5Bprototype%5D", "constructor.prototype"]
|
|
47
|
+
sample: "filter%5B__proto__%5D%5Badmin%5D=true"
|
|
48
|
+
benign: "filter%5Bstatus%5D=open"
|
|
49
|
+
falsepositives:
|
|
50
|
+
- Security scanners and penetration tests you run yourself
|
|
51
|
+
graphql-exposure:
|
|
52
|
+
label: GraphQL introspection
|
|
53
|
+
kind_label: GraphQL introspection or a GraphiQL/playground enabled in production
|
|
54
|
+
level: low
|
|
55
|
+
# With a boundary: `__type(` / `__schema{`, never `__typename`, which
|
|
56
|
+
# every Apollo/Relay client sends.
|
|
57
|
+
contains: ["__schema{", "__schema%7B", "__schema+", "__schema%20", "__schema ", "%5F%5Fschema", "__type(", "__type%28", "IntrospectionQuery"]
|
|
58
|
+
sample: "query=%7B__schema%7Btypes%7Bname%7D%7D%7D"
|
|
59
|
+
benign: "query=%7Bproducts%7Bname+__typename%7D%7D"
|
|
60
|
+
falsepositives:
|
|
61
|
+
- Your own developer tooling (GraphiQL, codegen) run against production; exclude its source IPs
|
|
62
|
+
- Introspection sent as POST bodies is not visible in access logs
|