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,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}"
|