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 +5 -0
- leakradar/auth.py +167 -0
- leakradar/bola_matrix.py +265 -0
- leakradar/canonicalizer.py +312 -0
- leakradar/cli.py +248 -0
- leakradar/markdown_poc.py +99 -0
- leakradar/pdf_report.py +281 -0
- leakradar/redactor.py +75 -0
- leakradar/secrets.py +110 -0
- leakradar/seeder.py +323 -0
- leakradar_cli-0.1.0.dist-info/LICENSE +15 -0
- leakradar_cli-0.1.0.dist-info/METADATA +93 -0
- leakradar_cli-0.1.0.dist-info/RECORD +16 -0
- leakradar_cli-0.1.0.dist-info/WHEEL +5 -0
- leakradar_cli-0.1.0.dist-info/entry_points.txt +2 -0
- leakradar_cli-0.1.0.dist-info/top_level.txt +1 -0
leakradar/__init__.py
ADDED
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()
|
leakradar/bola_matrix.py
ADDED
|
@@ -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
|