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,491 @@
1
+ """Deployable detection-rule templates (#251).
2
+
3
+ Each ``*.yml`` file next to this module holds Sigma rule templates keyed by
4
+ template id, plus the data the generator needs to fill them (payload markers
5
+ per rule id, provider signature headers). :func:`render_rule` fills a
6
+ template with a route, method, parameter and file and returns a structurally
7
+ valid Sigma rule (and, for templates that define one, a correlation rule)
8
+ with its log source, the fields it needs, false-positive notes and sample
9
+ positive/negative events.
10
+
11
+ Rule ids are deterministic: a UUIDv5 over the template id, the AttackMap rule
12
+ id and a per-instance key (method + route, or the file), so re-running a scan
13
+ yields the same Sigma ``id`` and SIEM imports update in place.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import functools
19
+ import re
20
+ import uuid
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import yaml
26
+
27
+ from ..taxonomy import attack_url, cwe_url, taxonomy_for
28
+
29
+ TEMPLATE_DIR = Path(__file__).parent
30
+ AUTHOR = "AttackMap"
31
+ STATUS = "experimental"
32
+ # Fixed namespace so rule ids are stable across machines and releases.
33
+ NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://github.com/mlaify/AttackMap/detections")
34
+
35
+ _PLACEHOLDER = re.compile(r"\$\{([a-z_][a-z0-9_]{0,40})\}")
36
+ _REQUIRED = ("date", "title", "description", "logsource", "detection", "falsepositives", "level", "test_events")
37
+
38
+
39
+ class TemplateError(ValueError):
40
+ """A template file or entry is malformed, or a placeholder has no value."""
41
+
42
+
43
+ @dataclass
44
+ class RenderedRule:
45
+ sigma: dict[str, Any]
46
+ logsource: dict[str, str]
47
+ fields_required: list[str]
48
+ false_positive_notes: list[str]
49
+ test_events: dict[str, list[dict[str, Any]]]
50
+ correlation: dict[str, Any] | None = None
51
+ extra: dict[str, Any] = field(default_factory=dict)
52
+
53
+
54
+ @functools.lru_cache(maxsize=1)
55
+ def _library() -> tuple[dict[str, dict], dict[str, dict]]:
56
+ templates: dict[str, dict] = {}
57
+ data: dict[str, dict] = {}
58
+ for path in sorted(TEMPLATE_DIR.glob("*.yml")):
59
+ doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
60
+ if not isinstance(doc, dict):
61
+ raise TemplateError(f"{path.name}: top level must be a mapping")
62
+ for tid, tpl in (doc.get("templates") or {}).items():
63
+ if tid in templates:
64
+ raise TemplateError(f"{path.name}: duplicate template id {tid!r}")
65
+ missing = [k for k in _REQUIRED if k not in tpl]
66
+ if missing:
67
+ raise TemplateError(f"{path.name}: template {tid!r} lacks {missing}")
68
+ templates[tid] = tpl
69
+ data[path.stem] = doc.get("data") or {}
70
+ return templates, data
71
+
72
+
73
+ def templates() -> dict[str, dict]:
74
+ """All templates, keyed by template id."""
75
+ return _library()[0]
76
+
77
+
78
+ def template_data(name: str) -> dict:
79
+ """The ``data:`` block of ``<name>.yml``."""
80
+ return _library()[1].get(name, {})
81
+
82
+
83
+ def _fill(node: Any, params: dict[str, Any]) -> Any:
84
+ """Substitute ``${name}`` placeholders. A string that is exactly one
85
+ placeholder becomes the parameter's value (any type); otherwise values are
86
+ interpolated as text, in one pass, so text from the scanned repository is
87
+ never re-expanded. Mapping entries whose key or value is a placeholder
88
+ that fills to ``None`` are dropped, as are such list items; a literal
89
+ ``null`` in the template stays (Sigma's "field is absent")."""
90
+ if isinstance(node, str):
91
+ whole = _PLACEHOLDER.fullmatch(node)
92
+ if whole:
93
+ return _param(params, whole.group(1))
94
+ return _PLACEHOLDER.sub(lambda m: str(_param(params, m.group(1))), node)
95
+ if isinstance(node, dict):
96
+ out: dict[Any, Any] = {}
97
+ for key, value in node.items():
98
+ k = _fill(key, params)
99
+ v = _fill(value, params)
100
+ if k is None or (v is None and value is not None):
101
+ continue
102
+ out[k] = v
103
+ return out
104
+ if isinstance(node, list):
105
+ return [v for v, item in ((_fill(item, params), item) for item in node) if v is not None or item is None]
106
+ return node
107
+
108
+
109
+ def _param(params: dict[str, Any], name: str) -> Any:
110
+ if name not in params:
111
+ raise TemplateError(f"no value for placeholder ${{{name}}}")
112
+ return params[name]
113
+
114
+
115
+ def rule_uuid(template_id: str, rule_id: str, key: str) -> str:
116
+ return str(uuid.uuid5(NAMESPACE, f"{template_id}|{rule_id}|{key}"))
117
+
118
+
119
+ def taxonomy_tags(rule_ids: list[str]) -> list[str]:
120
+ """Sigma tags for the rules' ATT&CK techniques (``attack.t1190``),
121
+ deduplicated in order. CWE has no Sigma tag namespace (pySigma's
122
+ namespace validator rejects ``cwe.*``), so CWEs go in ``references``."""
123
+ tags: list[str] = []
124
+ for rule_id in rule_ids:
125
+ entry = taxonomy_for(rule_id)
126
+ if entry is None:
127
+ continue
128
+ for tag in [f"attack.{t.lower()}" for t in entry.attack]:
129
+ if tag not in tags:
130
+ tags.append(tag)
131
+ return tags
132
+
133
+
134
+ def taxonomy_references(rule_ids: list[str]) -> list[str]:
135
+ refs: list[str] = []
136
+ for rule_id in rule_ids:
137
+ entry = taxonomy_for(rule_id)
138
+ if entry is None:
139
+ continue
140
+ for ref in [cwe_url(c) for c in entry.cwe] + [attack_url(t) for t in entry.attack]:
141
+ if ref not in refs:
142
+ refs.append(ref)
143
+ return refs
144
+
145
+
146
+ def render_rule(
147
+ template_id: str,
148
+ *,
149
+ rule_ids: list[str],
150
+ key: str,
151
+ params: dict[str, Any],
152
+ extra_falsepositives: list[str] | None = None,
153
+ ) -> RenderedRule:
154
+ """Fill ``template_id`` and assemble the Sigma rule. ``rule_ids`` are the
155
+ AttackMap rules it detects (tags, references, the id hash); ``key``
156
+ distinguishes instances of the same template and rule (method + route)."""
157
+ try:
158
+ tpl = templates()[template_id]
159
+ except KeyError:
160
+ raise TemplateError(f"unknown template {template_id!r}") from None
161
+ filled = _fill({k: v for k, v in tpl.items() if k != "correlation"}, params)
162
+ falsepositives = [*filled["falsepositives"], *(extra_falsepositives or [])]
163
+ fields_required = [f for f in filled.get("fields_required", []) if isinstance(f, str) and f]
164
+ logsource = {k: str(v) for k, v in filled["logsource"].items()}
165
+ # The id hashes a fixed primary (the smallest rule id), so it doesn't
166
+ # depend on the order the rule ids arrive in (plugin/record order).
167
+ primary = min(rule_ids)
168
+ # Titles carry repo text (routes): one line, whitespace collapsed.
169
+ title = " ".join(str(filled["title"]).split())[:256]
170
+ sigma: dict[str, Any] = {"title": title, "id": rule_uuid(template_id, primary, key)}
171
+ if filled.get("name"):
172
+ sigma["name"] = filled["name"]
173
+ sigma.update(
174
+ {
175
+ "status": STATUS,
176
+ "description": " ".join(str(filled["description"]).split()),
177
+ "references": taxonomy_references(rule_ids),
178
+ "author": AUTHOR,
179
+ "date": str(filled["date"]),
180
+ "tags": taxonomy_tags(rule_ids),
181
+ "logsource": logsource,
182
+ "detection": filled["detection"],
183
+ "fields": fields_required,
184
+ "falsepositives": falsepositives,
185
+ "level": filled["level"],
186
+ }
187
+ )
188
+ if not sigma["references"]:
189
+ del sigma["references"]
190
+ correlation = None
191
+ if "correlation" in tpl:
192
+ correlation = _correlation(template_id, primary, key, sigma, _fill(tpl["correlation"], params))
193
+ test_events = {
194
+ "positive": list(filled["test_events"].get("positive", [])),
195
+ "negative": list(filled["test_events"].get("negative", [])),
196
+ }
197
+ return RenderedRule(
198
+ sigma=sigma,
199
+ logsource=logsource,
200
+ fields_required=fields_required,
201
+ false_positive_notes=list(falsepositives) + (list(correlation["falsepositives"]) if correlation else []),
202
+ test_events=test_events,
203
+ correlation=correlation,
204
+ )
205
+
206
+
207
+ def _correlation(template_id: str, rule_id: str, key: str, base: dict, spec: dict) -> dict[str, Any]:
208
+ condition: dict[str, Any] = {"gte": int(spec["threshold"])}
209
+ if spec.get("field"):
210
+ condition["field"] = spec["field"]
211
+ rule: dict[str, Any] = {
212
+ "title": spec["title"],
213
+ "id": rule_uuid(template_id, rule_id, f"{key}|correlation"),
214
+ "status": STATUS,
215
+ "description": " ".join(str(spec.get("description", "")).split()) or base["description"],
216
+ **({"references": base["references"]} if "references" in base else {}),
217
+ "author": AUTHOR,
218
+ "date": base["date"],
219
+ "tags": base["tags"],
220
+ "correlation": {
221
+ "type": spec["type"],
222
+ "rules": [base.get("name") or base["id"]],
223
+ "group-by": list(spec.get("group-by") or []),
224
+ "timespan": spec["timespan"],
225
+ "condition": condition,
226
+ "generate": False,
227
+ },
228
+ "falsepositives": list(spec.get("falsepositives") or []),
229
+ "level": spec.get("level", base["level"]),
230
+ }
231
+ return rule
232
+
233
+
234
+ # ---------------------------------------------------------------- export
235
+
236
+ _FILE_SLUG = re.compile(r"[^a-z0-9]+")
237
+ # Names `rule_filename` produces; only these are ever removed as stale.
238
+ RULE_FILE = re.compile(r"\A[a-z0-9-]{1,100}-[0-9a-f]{8}\.yml\Z")
239
+
240
+
241
+ def rule_filename(opportunity_id: str, sigma_id: str) -> str:
242
+ """``<slug of the opportunity id>-<first 8 hex of the Sigma id>.yml``."""
243
+ slug = _FILE_SLUG.sub("-", opportunity_id.lower().removeprefix("detect:")).strip("-")[:90].strip("-")
244
+ return f"{slug or 'rule'}-{sigma_id.replace('-', '')[:8]}.yml"
245
+
246
+
247
+ def rule_yaml(sigma: dict[str, Any], correlation: dict[str, Any] | None = None) -> str:
248
+ """The rule as a Sigma YAML file: the detection rule, then its
249
+ correlation rule (if any) as a second document."""
250
+ docs = [sigma] + ([correlation] if correlation else [])
251
+ return "---\n".join(yaml.safe_dump(d, sort_keys=False, allow_unicode=True, width=120) for d in docs)
252
+
253
+
254
+ # ---------------------------------------------------------------- routes
255
+
256
+ # In-segment parameter syntaxes: <id>, {id}, [id] / [[...path]], `*`, and
257
+ # `:name` right after `-` or `.` (Express `/flights/:from-:to`,
258
+ # `/plantae/:genus.:species`). Elsewhere `:name` counts only at the start of
259
+ # a segment (`/v1/things:batchGet` is a literal), optionally with an
260
+ # optional-marker `?`.
261
+ _INLINE_PARAM = re.compile(
262
+ r"<[^<>/]{1,80}>|\{[^{}/]{1,80}\}|\[{1,2}[^\[\]/]{1,80}\]{1,2}|\*+"
263
+ r"|(?<=[-.]):[A-Za-z_][A-Za-z0-9_]{0,63}(\?)?"
264
+ )
265
+ # A parameter with an inline regex: `:id(\d+)`, `{id:[0-9]{2}}`,
266
+ # `<regex("[a-z]+"):slug>`. We can't translate those faithfully, and a
267
+ # rule built from them would be dead, so the route gets none.
268
+ _REGEX_PARAM = re.compile(
269
+ r":[A-Za-z_][A-Za-z0-9_]{0,63}\("
270
+ r"|\{[^{}/]{0,80}:[^{}/]{0,80}[\[\]()*+?\\|^${]"
271
+ r"|<[^<>/]{0,80}\("
272
+ )
273
+ _COLON_HEAD = re.compile(r"\A:[A-Za-z_][A-Za-z0-9_]{0,63}(?:\([^()/]{0,80}\))?(\?)?")
274
+ _WORD = re.compile(r"[A-Za-z0-9]{2}")
275
+ _HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"})
276
+ MAX_ROUTE_CHARS = 160
277
+ # Limits that keep generated regexes small and rules narrow.
278
+ MAX_ROUTE_PARAMS = 8
279
+ MAX_OPTIONAL_SEGMENTS = 2
280
+
281
+ _PARAM = "\x00" # a parameter part inside a segment
282
+ _REST = "\x01" # a final catch-all `*`
283
+ # An optional parameter after a separator (`/a/b.:fmt?`): "\x02" + the
284
+ # separator; both are optional together, as in Express.
285
+ _OPT = "\x02"
286
+
287
+
288
+ @dataclass(frozen=True)
289
+ class _Segment:
290
+ parts: tuple[str, ...] # literal strings and _PARAM / _REST markers
291
+ optional: bool = False
292
+
293
+
294
+ @functools.lru_cache(maxsize=4096)
295
+ def _parse(path: str) -> tuple[_Segment, ...]:
296
+ """Split a route template into segments of literal and parameter parts.
297
+ Adjacent parameters merge into one; a `*` that ends the route is a
298
+ catch-all (`_REST`), anywhere else it is one parameter."""
299
+ raw = (path or "/").lstrip("/").split("/")
300
+ segments: list[_Segment] = []
301
+ for index, text in enumerate(raw):
302
+ parts: list[str] = []
303
+ optional = False
304
+ head = _COLON_HEAD.match(text)
305
+ if head:
306
+ parts.append(_PARAM)
307
+ optional = bool(head.group(1)) and head.end() == len(text)
308
+ text = text[head.end():]
309
+ pos = 0
310
+ for match in _INLINE_PARAM.finditer(text):
311
+ if match.lastindex and match.group(1): # `.:fmt?` / `-:to?`
312
+ if match.start() - 1 > pos:
313
+ parts.append(text[pos:match.start() - 1])
314
+ parts.append(_OPT + text[match.start() - 1])
315
+ else:
316
+ if match.start() > pos:
317
+ parts.append(text[pos:match.start()])
318
+ parts.append(_PARAM)
319
+ pos = match.end()
320
+ if pos < len(text):
321
+ parts.append(text[pos:])
322
+ merged: list[str] = []
323
+ for part in parts:
324
+ if part == _PARAM and merged and merged[-1] == _PARAM:
325
+ continue
326
+ merged.append(part)
327
+ if index == len(raw) - 1 and merged and merged[-1] == _PARAM and text.endswith("*"):
328
+ merged[-1] = _REST
329
+ segments.append(_Segment(tuple(merged), optional))
330
+ return tuple(segments)
331
+
332
+
333
+ def _is_marker(part: str) -> bool:
334
+ return part in (_PARAM, _REST) or part.startswith(_OPT)
335
+
336
+
337
+ def _marker_text(part: str, value: str) -> str:
338
+ """A marker filled with a sample value (for examples and globs)."""
339
+ return part[1] + value if part.startswith(_OPT) else value
340
+
341
+
342
+ def _param_count(segments: tuple[_Segment, ...]) -> int:
343
+ return sum(1 for seg in segments for part in seg.parts if _is_marker(part))
344
+
345
+
346
+ def has_path_params(path: str) -> bool:
347
+ return _param_count(_parse(path or "")) > 0
348
+
349
+
350
+ def route_scopable(path: str) -> bool:
351
+ """Whether a rule can be scoped to this route. Refused:
352
+
353
+ - routes longer than ``MAX_ROUTE_CHARS`` (never truncated into a dead
354
+ exact match) or with non-printable / non-ASCII characters;
355
+ - parameterized routes without a literal word of at least two letters or
356
+ digits (``/``, ``*``, ``/:id``, ``/:a.:b``, ``*.*``), whose rule would
357
+ match nearly any request; an exact route needs one letter or digit;
358
+ - routes whose parameters carry an inline regex (``:id(\\d+)``,
359
+ ``{id:[0-9]{2}}``);
360
+ - routes with more than ``MAX_ROUTE_PARAMS`` parameters or
361
+ ``MAX_OPTIONAL_SEGMENTS`` optional segments.
362
+ """
363
+ if not path or len(path) > MAX_ROUTE_CHARS or not path.isascii() or not path.isprintable():
364
+ return False
365
+ if _REGEX_PARAM.search(path):
366
+ return False
367
+ segments = _parse(path)
368
+ # Literal pieces joined with a separator, so `/a/b` or `a{x}b` doesn't
369
+ # pass as the word "ab".
370
+ literal = "/".join(part for seg in segments for part in seg.parts if not _is_marker(part))
371
+ params = _param_count(segments)
372
+ if params == 0:
373
+ return any(ch.isalnum() for ch in literal)
374
+ return (
375
+ _WORD.search(literal) is not None
376
+ and params <= MAX_ROUTE_PARAMS
377
+ and sum(seg.optional for seg in segments) <= MAX_OPTIONAL_SEGMENTS
378
+ )
379
+
380
+
381
+ def regex_literal(text: str) -> str:
382
+ """Escape every character outside ``[A-Za-z0-9_/-]``: safe in PCRE,
383
+ Python, RE2 and Lucene-style regex dialects (which treat ``~ # & @ <
384
+ > "`` as operators)."""
385
+ return "".join(ch if (ch.isascii() and ch.isalnum()) or ch in "_/-" else "\\" + ch for ch in text)
386
+
387
+
388
+ def _segment_body(seg: _Segment) -> str:
389
+ r"""One segment's regex. A parameter excludes `/` and the first character
390
+ of the literal that follows it (`<a>.<b>` -> `[^/.]+\.[^/]+`), so no two
391
+ pieces can match the same characters and matching stays linear. Only a
392
+ final catch-all is `.*`."""
393
+ out: list[str] = []
394
+ for i, part in enumerate(seg.parts):
395
+ if part == _REST:
396
+ out.append(".*")
397
+ elif part == _PARAM or part.startswith(_OPT):
398
+ nxt = seg.parts[i + 1] if i + 1 < len(seg.parts) else None
399
+ if nxt and nxt.startswith(_OPT):
400
+ stop = regex_literal(nxt[1])
401
+ else:
402
+ stop = regex_literal(nxt[0]) if nxt and not _is_marker(nxt) else ""
403
+ if part == _PARAM:
404
+ out.append(f"[^/{stop}]+")
405
+ else:
406
+ out.append(f"(?:{regex_literal(part[1])}[^/{stop}]+)?")
407
+ else:
408
+ out.append(regex_literal(part))
409
+ return "".join(out)
410
+
411
+
412
+ def _route_regex(path: str) -> str:
413
+ """Unanchored regex body for a route (see `_segment_body`); optional
414
+ segments become `(?:/...)?`."""
415
+ out: list[str] = []
416
+ for seg in _parse(path):
417
+ body = "/" + _segment_body(seg)
418
+ out.append(f"(?:{body})?" if seg.optional else body)
419
+ return "".join(out)
420
+
421
+
422
+ def _sigma_literal(text: str) -> str:
423
+ return text.replace("\\", "\\\\").replace("*", "\\*").replace("?", "\\?")
424
+
425
+
426
+ def route_glob(path: str) -> str:
427
+ """Display form of a route: parameters become ``*``."""
428
+ segs = ["".join(_marker_text(p, "*") if _is_marker(p) else p for p in seg.parts) for seg in _parse(path or "/")]
429
+ return "/" + "/".join(segs)
430
+
431
+
432
+ def route_selector(path: str) -> tuple[str, str] | None:
433
+ """``(field, value)`` matching the request path of a route on
434
+ ``cs-uri-stem``: the exact path when it has no parameters, otherwise a
435
+ ``|re`` anchored at both ends in which each parameter matches within one
436
+ segment and only a final ``*`` spans segments. ``None`` when the route
437
+ can't be scoped (:func:`route_scopable`)."""
438
+ if not route_scopable(path):
439
+ return None
440
+ if not has_path_params(path):
441
+ value = path if path.startswith("/") else f"/{path}"
442
+ return "cs-uri-stem", _sigma_literal(value)
443
+ return "cs-uri-stem|re", f"^{_route_regex(path)}$"
444
+
445
+
446
+ def stem_selector(paths: list[str]) -> tuple[str, object] | None:
447
+ """One ``cs-uri-stem`` selection for several routes: a list of exact
448
+ paths, or one anchored alternation regex if any route has parameters.
449
+ Unscopable routes are left out; ``None`` if none remain."""
450
+ kept = [p for p in paths if route_scopable(p)]
451
+ if not kept:
452
+ return None
453
+ if not any(has_path_params(p) for p in kept):
454
+ return "cs-uri-stem", [_sigma_literal(p if p.startswith("/") else f"/{p}") for p in kept]
455
+ return "cs-uri-stem|re", "^(?:" + "|".join(_route_regex(p) for p in kept) + ")$"
456
+
457
+
458
+ def route_example(path: str, value: str = "1001") -> str:
459
+ """A concrete URI path that matches :func:`route_selector` (parameters
460
+ and wildcards filled with ``value``, optional segments included)."""
461
+ segs = ["".join(_marker_text(p, value) if _is_marker(p) else p for p in seg.parts) for seg in _parse(path or "/")]
462
+ return "/" + "/".join(segs)
463
+
464
+
465
+ def sigma_method(method: str | None) -> str | None:
466
+ """The HTTP verb to match, or ``None`` (any verb) for ANY/ALL/RPC kinds."""
467
+ upper = (method or "").upper()
468
+ return upper if upper in _HTTP_METHODS else None
469
+
470
+
471
+ __all__ = [
472
+ "RULE_FILE",
473
+ "RenderedRule",
474
+ "TemplateError",
475
+ "has_path_params",
476
+ "render_rule",
477
+ "route_example",
478
+ "regex_literal",
479
+ "route_glob",
480
+ "route_scopable",
481
+ "route_selector",
482
+ "rule_filename",
483
+ "stem_selector",
484
+ "rule_uuid",
485
+ "rule_yaml",
486
+ "sigma_method",
487
+ "taxonomy_references",
488
+ "taxonomy_tags",
489
+ "template_data",
490
+ "templates",
491
+ ]
@@ -0,0 +1,57 @@
1
+ # BOLA/IDOR cardinality (#251). A per-route base rule selects successful
2
+ # object accesses; a Sigma `value_count` correlation fires when one principal
3
+ # touches more distinct object ids on that route than a normal user would in
4
+ # the window. The base rule is only the correlation's input (`generate:
5
+ # false` keeps backends from emitting it as its own alert).
6
+
7
+ templates:
8
+ bola-cardinality:
9
+ date: "2026-10-12"
10
+ name: "${rule_name}"
11
+ title: "Object access through ${method_route}"
12
+ description: >-
13
+ Successful requests to ${method_route}, the input to the cardinality
14
+ correlation below. AttackMap found no object-level authorization check
15
+ for the ${id_param} identifier (rule ${rule_id}, ${location}), so one
16
+ principal enumerating ids would read or change other users' objects.
17
+ logsource:
18
+ category: webserver
19
+ fields_required: [cs-method, cs-uri-stem, cs-uri-query, sc-status, "${principal_field}"]
20
+ detection:
21
+ selection:
22
+ "${route_field}": "${route_value}"
23
+ cs-method: "${method}"
24
+ sc-status: [200, 201, 202, 204]
25
+ condition: selection
26
+ falsepositives:
27
+ - Not an alert on its own; see the correlation rule
28
+ level: low
29
+ correlation:
30
+ title: "One principal accesses many distinct objects through ${method_route}"
31
+ description: >-
32
+ At least ${threshold} distinct ${value_field} values for
33
+ ${method_route} from one ${principal_field} within ${timespan}: the
34
+ shape of object-id enumeration against a route without an
35
+ object-level authorization check.
36
+ type: value_count
37
+ group-by: ["${principal_field}"]
38
+ timespan: "${timespan}"
39
+ field: "${value_field}"
40
+ threshold: "${threshold}"
41
+ level: "${level}"
42
+ falsepositives:
43
+ - Exports, sync jobs and admin tooling that legitimately page through many objects; exclude their principals
44
+ - Shared NAT addresses when grouping by c-ip
45
+ test_events:
46
+ positive:
47
+ - cs-method: "${method_example}"
48
+ cs-uri-stem: "${positive_stem}"
49
+ cs-uri-query: "${positive_query}"
50
+ sc-status: 200
51
+ "${principal_field}": "${principal_value}"
52
+ negative:
53
+ - cs-method: "${method_example}"
54
+ cs-uri-stem: /healthz
55
+ cs-uri-query: ""
56
+ sc-status: 200
57
+ "${principal_field}": "${principal_value}"