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.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. 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