leakradar-cli 0.1.0__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.
leakradar/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ LeakRadar - Open-core API security CLI for BOLA/IDOR vulnerability scanning.
3
+ """
4
+
5
+ __version__ = "0.1.0"
leakradar/auth.py ADDED
@@ -0,0 +1,167 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import platform
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+ import httpx
11
+
12
+
13
+ @dataclass
14
+ class LicenseContext:
15
+ active: bool = False
16
+ key: Optional[str] = None
17
+ tier: str = "free" # "free" | "paid" | "enterprise"
18
+ capabilities: Dict[str, bool] = field(default_factory=lambda: {
19
+ "pdf_export": False,
20
+ "cloud_rules": False,
21
+ "unlimited_scans": True,
22
+ })
23
+ expires_at: Optional[str] = None
24
+
25
+
26
+ class LicenseManager:
27
+ """
28
+ Manages Polar.sh license key activation, machine fingerprinting, and local cache.
29
+ """
30
+
31
+ ACTIVATION_URL = "https://api.ajmax76.github.io/leakradar/v1/license/activate"
32
+ CACHE_DIR = Path.home() / ".leakradar"
33
+ CACHE_FILE = CACHE_DIR / "license.json"
34
+
35
+ @classmethod
36
+ def get_machine_fingerprint(cls) -> str:
37
+ """
38
+ Generate a unique hardware fingerprint using platform node, system, machine, and processor.
39
+ """
40
+ raw_info = f"{platform.node()}-{platform.system()}-{platform.machine()}-{platform.processor()}"
41
+ return hashlib.sha256(raw_info.encode("utf-8")).hexdigest()
42
+
43
+ @classmethod
44
+ def load_cached_license(cls) -> LicenseContext:
45
+ """
46
+ Reads cached license from ~/.leakradar/license.json and checks expiration.
47
+ """
48
+ if not cls.CACHE_FILE.exists():
49
+ return LicenseContext(active=False, tier="free")
50
+
51
+ try:
52
+ with open(cls.CACHE_FILE, "r", encoding="utf-8") as f:
53
+ data = json.load(f)
54
+
55
+ expires_at_str = data.get("expires_at")
56
+ if expires_at_str:
57
+ try:
58
+ exp_dt = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
59
+ if datetime.now(timezone.utc) > exp_dt:
60
+ return LicenseContext(active=False, tier="free")
61
+ except Exception:
62
+ pass
63
+
64
+ tier = data.get("tier", "free")
65
+ is_paid = tier.lower() in ("paid", "pro", "enterprise")
66
+ return LicenseContext(
67
+ active=data.get("active", True),
68
+ key=data.get("key"),
69
+ tier=tier,
70
+ capabilities={
71
+ "pdf_export": is_paid or data.get("capabilities", {}).get("pdf_export", False),
72
+ "cloud_rules": is_paid or data.get("capabilities", {}).get("cloud_rules", False),
73
+ "unlimited_scans": True,
74
+ },
75
+ expires_at=expires_at_str,
76
+ )
77
+ except Exception:
78
+ return LicenseContext(active=False, tier="free")
79
+
80
+ @classmethod
81
+ async def activate_license(cls, license_key: str) -> LicenseContext:
82
+ """
83
+ Activates a Dodo Payments / Polar.sh license key against the license server and caches response.
84
+ Enforces key format checks and device limit validation.
85
+ """
86
+ clean_key = license_key.strip()
87
+ if not clean_key or len(clean_key) < 10:
88
+ return LicenseContext(active=False, tier="free")
89
+
90
+ fingerprint = cls.get_machine_fingerprint()
91
+ payload = {
92
+ "key": clean_key,
93
+ "fingerprint": fingerprint,
94
+ "system": platform.system(),
95
+ }
96
+
97
+ cls.CACHE_DIR.mkdir(parents=True, exist_ok=True)
98
+
99
+ async with httpx.AsyncClient(timeout=10.0) as client:
100
+ try:
101
+ resp = await client.post(cls.ACTIVATION_URL, json=payload)
102
+ if resp.status_code == 200:
103
+ data = resp.json()
104
+ tier = data.get("tier", "pro")
105
+ context_data = {
106
+ "active": True,
107
+ "key": clean_key,
108
+ "tier": tier,
109
+ "capabilities": {
110
+ "pdf_export": True,
111
+ "cloud_rules": True,
112
+ "unlimited_scans": True,
113
+ },
114
+ "expires_at": data.get("expires_at"),
115
+ "activated_at": datetime.now(timezone.utc).isoformat(),
116
+ }
117
+ with open(cls.CACHE_FILE, "w", encoding="utf-8") as f:
118
+ json.dump(context_data, f, indent=2)
119
+
120
+ return LicenseContext(
121
+ active=True,
122
+ key=clean_key,
123
+ tier=tier,
124
+ capabilities=context_data["capabilities"],
125
+ expires_at=data.get("expires_at"),
126
+ )
127
+ except Exception:
128
+ pass
129
+
130
+ # Offline / Key Format Verification
131
+ # Valid keys issued by Dodo/Polar follow structured formats (e.g. pdt_..., LR-PRO-..., or 32+ char hex hashes)
132
+ is_valid_format = (
133
+ clean_key.startswith("pdt_") or
134
+ clean_key.startswith("LR-PRO-") or
135
+ clean_key.startswith("POLAR-") or
136
+ (len(clean_key) >= 24 and "-" in clean_key)
137
+ )
138
+
139
+ if is_valid_format:
140
+ context_data = {
141
+ "active": True,
142
+ "key": clean_key,
143
+ "tier": "pro",
144
+ "capabilities": {
145
+ "pdf_export": True,
146
+ "cloud_rules": True,
147
+ "unlimited_scans": True,
148
+ },
149
+ "expires_at": None,
150
+ "activated_at": datetime.now(timezone.utc).isoformat(),
151
+ }
152
+ with open(cls.CACHE_FILE, "w", encoding="utf-8") as f:
153
+ json.dump(context_data, f, indent=2)
154
+
155
+ return LicenseContext(
156
+ active=True,
157
+ key=clean_key,
158
+ tier="pro",
159
+ capabilities=context_data["capabilities"],
160
+ )
161
+
162
+ # Invalid key fallback
163
+ return LicenseContext(active=False, tier="free")
164
+
165
+ @classmethod
166
+ def get_active_context(cls) -> LicenseContext:
167
+ return cls.load_cached_license()
@@ -0,0 +1,265 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Dict, List, Optional, Set, Tuple
3
+ import httpx
4
+
5
+ from leakradar.canonicalizer import ErrorDetector, normalize_value, strip_paths
6
+ from leakradar.seeder import ResourceSeed, SeedResult
7
+ from leakradar.secrets import SecretDetector, SecretFinding
8
+
9
+ ID_FIELD_EXACT = {"id", "uuid", "guid", "key", "slug", "code", "identifier"}
10
+ ID_FIELD_SUFFIXES = ("_id", "-id", "Id")
11
+
12
+
13
+ def is_id_field(field_name: str) -> bool:
14
+ """
15
+ Check if a field name is an ID field using exact matches or specific suffixes.
16
+ Prevents broad matches on words like 'valid' or 'solid'.
17
+ """
18
+ fname = field_name.strip()
19
+ if fname in ID_FIELD_EXACT:
20
+ return True
21
+ for suff in ID_FIELD_SUFFIXES:
22
+ if fname.endswith(suff):
23
+ return True
24
+ return False
25
+
26
+
27
+ @dataclass
28
+ class Finding:
29
+ seed: ResourceSeed
30
+ probe_url: str
31
+ probe_method: str
32
+ probe_status_code: int
33
+ confidence: str # "high" | "medium" | "low"
34
+ evidence_fields: List[Dict[str, Any]]
35
+ overlap_score: float
36
+ baseline_representative: Any
37
+ probe_response: Any
38
+ cvss_suggestion: str = "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N (7.5 High)"
39
+ cwe_suggestion: str = "CWE-639: Authorization Bypass Through User-Controlled Key"
40
+ secret_findings: List[SecretFinding] = field(default_factory=list)
41
+
42
+ def to_curl(self) -> str:
43
+ headers_str = " \\\n ".join(
44
+ f'-H "{k}: <REDACTED_USER_B_TOKEN>"' if k.lower() == "authorization" else f'-H "{k}: {v}"'
45
+ for k, v in self.seed.headers.items()
46
+ )
47
+ if headers_str:
48
+ return f"curl -X {self.probe_method} \"{self.probe_url}\" \\\n {headers_str}"
49
+ return f"curl -X {self.probe_method} \"{self.probe_url}\""
50
+
51
+
52
+ class IdentityExtractor:
53
+ """
54
+ Flattens payload structures into scalar leaf maps and extracts identity ownership values.
55
+ """
56
+
57
+ OWNERSHIP_FIELDS = {
58
+ "user_id", "owner_id", "created_by", "customer_id", "account_id",
59
+ "tenant_id", "email", "username", "sub", "author_id", "creator_id"
60
+ }
61
+
62
+ @classmethod
63
+ def get_scalar_leaf_map(cls, data: Any, prefix: str = "$") -> Dict[str, Any]:
64
+ leaf_map: Dict[str, Any] = {}
65
+
66
+ if data is None:
67
+ return leaf_map
68
+
69
+ if isinstance(data, (str, int, float, bool)):
70
+ leaf_map[prefix] = data
71
+ return leaf_map
72
+
73
+ if isinstance(data, dict):
74
+ for k, v in data.items():
75
+ child_prefix = f"{prefix}.{k}"
76
+ leaf_map.update(cls.get_scalar_leaf_map(v, child_prefix))
77
+ return leaf_map
78
+
79
+ if isinstance(data, list):
80
+ for i, item in enumerate(data):
81
+ child_prefix = f"{prefix}[*]"
82
+ leaf_map.update(cls.get_scalar_leaf_map(item, child_prefix))
83
+ return leaf_map
84
+
85
+ return leaf_map
86
+
87
+ @classmethod
88
+ def extract_identity_values(cls, baseline: Any, seed: ResourceSeed) -> Dict[str, Any]:
89
+ identity_map: Dict[str, Any] = {}
90
+
91
+ # Include seed path parameter values
92
+ for param_k, param_v in seed.param_values.items():
93
+ identity_map[f"$.param.{param_k}"] = param_v
94
+
95
+ # Traverse baseline payload for ownership fields
96
+ leaf_map = cls.get_scalar_leaf_map(baseline)
97
+ for path, val in leaf_map.items():
98
+ field_name = path.split(".")[-1].split("[")[0].lower()
99
+ if field_name in cls.OWNERSHIP_FIELDS or is_id_field(field_name):
100
+ identity_map[path] = val
101
+
102
+ return identity_map
103
+
104
+
105
+ class BolaMatrixRunner:
106
+ """
107
+ Executes cross-token replay with User B credentials and classifies BOLA findings.
108
+ """
109
+
110
+ def __init__(self, user_b_headers: Dict[str, str], client: Optional[httpx.Client] = None):
111
+ self.user_b_headers = user_b_headers
112
+ self.client = client or httpx.Client(timeout=10.0, follow_redirects=True)
113
+
114
+ def _build_probe_url(self, seed: ResourceSeed) -> str:
115
+ url = seed.endpoint_template
116
+ for k, v in seed.param_values.items():
117
+ url = url.replace(f"{{{k}}}", str(v))
118
+ return f"{seed.base_url.rstrip('/')}{url}"
119
+
120
+ def _send_probe(self, method: str, url: str) -> Tuple[int, Any]:
121
+ try:
122
+ resp = self.client.request(method, url, headers=self.user_b_headers)
123
+ try:
124
+ data = resp.json()
125
+ except Exception:
126
+ data = resp.text
127
+ return resp.status_code, data
128
+ except Exception as e:
129
+ return 500, str(e)
130
+
131
+ def _classify(
132
+ self,
133
+ baseline_stripped: Any,
134
+ probe_stripped: Any,
135
+ identity_map: Dict[str, Any],
136
+ seed: ResourceSeed,
137
+ status_code: int,
138
+ ) -> Tuple[str, List[Dict[str, Any]], float]:
139
+ """
140
+ Classifies confidence (high, medium, low) and returns (confidence, evidence_list, overlap_score).
141
+ """
142
+ if status_code not in (200, 201, 204):
143
+ return "low", [], 0.0
144
+
145
+ is_err, _ = ErrorDetector.is_error_payload(status_code, probe_stripped)
146
+ if is_err:
147
+ return "low", [], 0.0
148
+
149
+ base_leaves = IdentityExtractor.get_scalar_leaf_map(baseline_stripped)
150
+ probe_leaves = IdentityExtractor.get_scalar_leaf_map(probe_stripped)
151
+
152
+ if not base_leaves or not probe_leaves:
153
+ return "low", [], 0.0
154
+
155
+ # Calculate Leaf Overlap
156
+ matching_leaves = 0
157
+ evidence_list = []
158
+
159
+ for path, val in base_leaves.items():
160
+ if path in probe_leaves and probe_leaves[path] == val:
161
+ matching_leaves += 1
162
+
163
+ overlap_score = matching_leaves / float(len(base_leaves))
164
+
165
+ # Signal 1: Resource ID Echoing
166
+ id_echo_signal = False
167
+ for param_k, param_v in seed.param_values.items():
168
+ param_v_str = str(param_v).lower()
169
+ for path, pval in probe_leaves.items():
170
+ field_name = path.split(".")[-1].split("[")[0]
171
+ if is_id_field(field_name) and str(pval).lower() == param_v_str:
172
+ id_echo_signal = True
173
+ evidence_list.append({
174
+ "type": "Resource ID Echo",
175
+ "field": path,
176
+ "value": pval,
177
+ "description": f"Target parameter value '{param_v}' for '{param_k}' echoed in User B response."
178
+ })
179
+ break
180
+
181
+ # Signal 2: Ownership Field Match
182
+ ownership_match_signal = False
183
+ for id_path, id_val in identity_map.items():
184
+ if id_path.startswith("$.param."):
185
+ continue
186
+ if id_path in probe_leaves and probe_leaves[id_path] == id_val:
187
+ ownership_match_signal = True
188
+ evidence_list.append({
189
+ "type": "Ownership Data Exposure",
190
+ "field": id_path,
191
+ "value": id_val,
192
+ "description": f"User A ownership field '{id_path}' returned to User B with identical value."
193
+ })
194
+
195
+ # General overlap evidence
196
+ if overlap_score >= 0.4:
197
+ evidence_list.append({
198
+ "type": "High Data Overlap",
199
+ "field": "$ (root)",
200
+ "value": f"{overlap_score * 100:.1f}%",
201
+ "description": f"User B response matches {overlap_score * 100:.1f}% of User A baseline fields."
202
+ })
203
+
204
+ # Classification Matrix Logic
205
+ if (id_echo_signal and ownership_match_signal) or \
206
+ (id_echo_signal and overlap_score >= 0.3) or \
207
+ (ownership_match_signal and overlap_score >= 0.6) or \
208
+ (overlap_score >= 0.85):
209
+ return "high", evidence_list, overlap_score
210
+
211
+ if (id_echo_signal and overlap_score >= 0.2) or \
212
+ ownership_match_signal or \
213
+ (overlap_score >= 0.4):
214
+ return "medium", evidence_list, overlap_score
215
+
216
+ return "low", evidence_list, overlap_score
217
+
218
+ def run(self, seed_result: SeedResult) -> List[Finding]:
219
+ findings: List[Finding] = []
220
+
221
+ for seed in seed_result.resources:
222
+ if not seed.param_values:
223
+ continue
224
+
225
+ probe_url = self._build_probe_url(seed)
226
+ status_code, probe_raw = self._send_probe(seed.method, probe_url)
227
+
228
+ # Error check
229
+ is_err, _ = ErrorDetector.is_error_payload(status_code, probe_raw)
230
+ if is_err:
231
+ continue
232
+
233
+ baseline_raw = seed.baseline_responses[0] if seed.baseline_responses else {}
234
+ baseline_norm = normalize_value(baseline_raw)
235
+ probe_norm = normalize_value(probe_raw)
236
+
237
+ # Scan probe response for exposed secrets and tokens
238
+ detected_secrets = SecretDetector.scan_payload(probe_raw)
239
+
240
+ # Strip volatile paths
241
+ baseline_stripped = strip_paths(baseline_norm, seed.volatile_paths)
242
+ probe_stripped = strip_paths(probe_norm, seed.volatile_paths)
243
+
244
+ # Extract identity values from User A baseline
245
+ identity_map = IdentityExtractor.extract_identity_values(baseline_norm, seed)
246
+
247
+ confidence, evidence, overlap = self._classify(
248
+ baseline_stripped, probe_stripped, identity_map, seed, status_code
249
+ )
250
+
251
+ if confidence in ("high", "medium") or detected_secrets:
252
+ findings.append(Finding(
253
+ seed=seed,
254
+ probe_url=probe_url,
255
+ probe_method=seed.method,
256
+ probe_status_code=status_code,
257
+ confidence=confidence if confidence in ("high", "medium") else "medium",
258
+ evidence_fields=evidence,
259
+ overlap_score=overlap,
260
+ baseline_representative=baseline_stripped,
261
+ probe_response=probe_stripped,
262
+ secret_findings=detected_secrets,
263
+ ))
264
+
265
+ return findings