Repository navigation
Expand file tree
/
Copy pathscope.py
More file actions
121 lines (94 loc) · 3.79 KB
/
Copy pathscope.py
File metadata and controls
121 lines (94 loc) · 3.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
"""Browser validation scope — origin allowlists, verb restrictions, engagement expiry."""
from __future__ import annotations
import datetime as _dt
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import urlparse
try:
import yaml
except ImportError:
yaml = None
def _origin(url: str) -> str | None:
"""Return scheme://host[:port] for a URL, or None if unparseable."""
if not url:
return None
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return None
return f"{parsed.scheme}://{parsed.netloc}".lower()
@dataclass
class BrowserTarget:
victim_origin: str
attacker_origins: list[str] = field(default_factory=list)
credentials: dict = field(default_factory=dict)
allowed_verbs: list[str] = field(default_factory=list)
def matches_url(self, url: str) -> bool:
origin = _origin(url)
if not origin:
return False
allowed = [_origin(self.victim_origin), *(_origin(ao) for ao in self.attacker_origins)]
return origin in {a for a in allowed if a}
def allows_verb(self, verb: str) -> bool:
# Fail closed: missing/empty allowed_verbs denies everything.
return bool(self.allowed_verbs) and verb in self.allowed_verbs
@dataclass
class Scope:
engagement: str | None = None
authorized_by: str | None = None
expires: _dt.date | None = None
environment: str = "lab"
targets: list[BrowserTarget] = field(default_factory=list)
@classmethod
def from_file(cls, path: str | Path) -> Scope:
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"targets.yaml not found: {p}")
data = _parse(p)
meta = data.get("metadata", data.get("meta", {}))
scope = cls(
engagement=meta.get("engagement") or data.get("engagement"),
authorized_by=meta.get("authorized_by") or data.get("authorized_by"),
environment=meta.get("environment") or data.get("environment", "lab"),
)
if exp := (meta.get("expires") or data.get("expires")):
scope.expires = _dt.date.fromisoformat(exp) if isinstance(exp, str) else exp
scope.targets = [
BrowserTarget(
victim_origin=b["victim_origin"],
attacker_origins=b.get("attacker_origins", []),
credentials=b.get("credentials", {}),
allowed_verbs=b.get("allowed_verbs", []),
)
for b in data.get("browser", [])
]
return scope
@property
def is_expired(self) -> bool:
return bool(self.expires and _dt.date.today() > self.expires)
def check(self, verb: str, url: str) -> tuple[bool, str]:
if self.is_expired:
return False, "engagement expired"
if not self.targets:
return False, "no browser targets defined"
# Fail closed: every scoped action needs a concrete URL so we can
# bind the verb to the origin actually being acted on.
if not url:
return False, "url required for scope check"
target = self._match_target(url)
if not target:
return False, f"url '{url}' not in any target origin"
if verb and not target.allows_verb(verb):
return False, f"verb '{verb}' not in allowed_verbs"
return True, "ok"
def credentials_for(self, url: str) -> dict:
if target := self._match_target(url):
return target.credentials
return {}
def _match_target(self, url: str) -> BrowserTarget | None:
return next((t for t in self.targets if t.matches_url(url)), None)
def _parse(path: Path) -> dict:
text = path.read_text(encoding="utf-8")
if yaml:
return yaml.safe_load(text) or {}
import json
return json.loads(text)