prismor-cli 1.3.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.
- prismor/__init__.py +6 -0
- prismor/api.py +770 -0
- prismor/cli.py +1305 -0
- prismor/cli_config.py +55 -0
- prismor/local_fix.py +338 -0
- prismor/sanitize.py +179 -0
- prismor_cli-1.3.0.dist-info/METADATA +919 -0
- prismor_cli-1.3.0.dist-info/RECORD +12 -0
- prismor_cli-1.3.0.dist-info/WHEEL +5 -0
- prismor_cli-1.3.0.dist-info/entry_points.txt +2 -0
- prismor_cli-1.3.0.dist-info/licenses/LICENSE +22 -0
- prismor_cli-1.3.0.dist-info/top_level.txt +1 -0
prismor/api.py
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
"""API client for Prismor security scanning service."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
import requests
|
|
7
|
+
from typing import Optional, Dict, Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _active_org_id() -> Optional[str]:
|
|
12
|
+
"""The org the user switched to via `prismor org switch`, if any."""
|
|
13
|
+
try:
|
|
14
|
+
from prismor import cli_config
|
|
15
|
+
return cli_config.active_org_id()
|
|
16
|
+
except Exception:
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
DEFAULT_SCAN_POLL_INTERVAL_SECONDS = max(int(os.environ.get("PRISMOR_SCAN_POLL_INTERVAL_SECONDS", "5")), 1)
|
|
20
|
+
DEFAULT_SCAN_MAX_WAIT_SECONDS = max(int(os.environ.get("PRISMOR_SCAN_MAX_WAIT_SECONDS", "1800")), 60)
|
|
21
|
+
DEFAULT_SCAN_STATUS_RETRY_LIMIT = max(int(os.environ.get("PRISMOR_SCAN_STATUS_RETRY_LIMIT", "5")), 0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PrismorAPIError(Exception):
|
|
25
|
+
"""Custom exception for Prismor API errors."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def retry_request(func, max_retries=3, backoff_factor=2):
|
|
30
|
+
"""Retry a request function with exponential backoff.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
func: Function to retry (should return requests.Response)
|
|
34
|
+
max_retries: Maximum number of retry attempts
|
|
35
|
+
backoff_factor: Multiplier for delay between retries
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Response from the function
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
Exception: If all retries fail
|
|
42
|
+
"""
|
|
43
|
+
last_exception = None
|
|
44
|
+
|
|
45
|
+
for attempt in range(max_retries):
|
|
46
|
+
try:
|
|
47
|
+
return func()
|
|
48
|
+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
|
|
49
|
+
last_exception = e
|
|
50
|
+
if attempt < max_retries - 1:
|
|
51
|
+
delay = backoff_factor ** attempt
|
|
52
|
+
time.sleep(delay)
|
|
53
|
+
continue
|
|
54
|
+
raise
|
|
55
|
+
except Exception as e:
|
|
56
|
+
# Don't retry on other exceptions
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
# If we get here, all retries failed
|
|
60
|
+
raise last_exception
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_github_repo(repo_input: str) -> str:
|
|
64
|
+
"""Extract user/repo_name from various GitHub URL formats or return as-is if already in correct format.
|
|
65
|
+
|
|
66
|
+
This function handles multiple GitHub URL formats:
|
|
67
|
+
- user/repo_name (already in correct format)
|
|
68
|
+
- https://github.com/user/repo_name
|
|
69
|
+
- https://www.github.com/user/repo_name
|
|
70
|
+
- http://github.com/user/repo_name
|
|
71
|
+
- http://www.github.com/user/repo_name
|
|
72
|
+
- github.com/user/repo_name
|
|
73
|
+
- www.github.com/user/repo_name
|
|
74
|
+
- git@github.com:user/repo_name.git
|
|
75
|
+
- https://github.com/user/repo_name.git
|
|
76
|
+
- https://github.com/user/repo_name/
|
|
77
|
+
- https://github.com/user/repo_name#branch
|
|
78
|
+
- https://github.com/user/repo_name/tree/branch
|
|
79
|
+
- https://github.com/user/repo_name/blob/branch/file
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
repo_input: Repository input in any of the supported formats
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Repository in "user/repo_name" format
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
PrismorAPIError: If the input format is not recognized or invalid
|
|
89
|
+
"""
|
|
90
|
+
if not repo_input or not isinstance(repo_input, str):
|
|
91
|
+
raise PrismorAPIError("Repository input cannot be empty")
|
|
92
|
+
|
|
93
|
+
# Validate repository name characters (GitHub allows alphanumeric, hyphens, underscores, dots)
|
|
94
|
+
def validate_repo_part(part: str, part_name: str) -> None:
|
|
95
|
+
if not part:
|
|
96
|
+
raise PrismorAPIError(f"{part_name} cannot be empty")
|
|
97
|
+
if len(part) > 100:
|
|
98
|
+
raise PrismorAPIError(f"{part_name} is too long (max 100 characters)")
|
|
99
|
+
# GitHub allows alphanumeric, hyphens, underscores, dots, but not starting/ending with special chars
|
|
100
|
+
if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$', part):
|
|
101
|
+
raise PrismorAPIError(
|
|
102
|
+
f"Invalid {part_name}: '{part}'. Must contain only alphanumeric characters, "
|
|
103
|
+
"hyphens, underscores, or dots, and cannot start or end with special characters."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
repo_input = repo_input.strip()
|
|
107
|
+
|
|
108
|
+
# If it's already in user/repo format (no protocol, no domain), return as-is
|
|
109
|
+
if "/" in repo_input and not any(repo_input.startswith(prefix) for prefix in
|
|
110
|
+
["http://", "https://", "git@", "github.com", "www.github.com"]):
|
|
111
|
+
# Validate it has exactly one slash and both parts are non-empty
|
|
112
|
+
parts = repo_input.split("/")
|
|
113
|
+
if len(parts) == 2 and parts[0] and parts[1]:
|
|
114
|
+
validate_repo_part(parts[0], "Username")
|
|
115
|
+
validate_repo_part(parts[1], "Repository name")
|
|
116
|
+
return repo_input
|
|
117
|
+
else:
|
|
118
|
+
raise PrismorAPIError(f"Invalid repository format: {repo_input}. Expected 'user/repo_name'")
|
|
119
|
+
|
|
120
|
+
# Handle SSH format: git@github.com:user/repo.git
|
|
121
|
+
if repo_input.startswith("git@github.com:"):
|
|
122
|
+
repo_part = repo_input[15:] # Remove "git@github.com:"
|
|
123
|
+
# Remove .git suffix if present
|
|
124
|
+
if repo_part.endswith(".git"):
|
|
125
|
+
repo_part = repo_part[:-4]
|
|
126
|
+
if "/" in repo_part:
|
|
127
|
+
return repo_part
|
|
128
|
+
else:
|
|
129
|
+
raise PrismorAPIError(f"Invalid SSH repository format: {repo_input}")
|
|
130
|
+
|
|
131
|
+
# Handle HTTP/HTTPS URLs
|
|
132
|
+
if repo_input.startswith(("http://", "https://")):
|
|
133
|
+
try:
|
|
134
|
+
parsed = urlparse(repo_input)
|
|
135
|
+
hostname = parsed.hostname.lower()
|
|
136
|
+
|
|
137
|
+
# Check if it's a GitHub URL
|
|
138
|
+
if hostname in ["github.com", "www.github.com"]:
|
|
139
|
+
path = parsed.path.strip("/")
|
|
140
|
+
|
|
141
|
+
# Remove .git suffix if present
|
|
142
|
+
if path.endswith(".git"):
|
|
143
|
+
path = path[:-4]
|
|
144
|
+
|
|
145
|
+
# Split path and extract user/repo
|
|
146
|
+
path_parts = path.split("/")
|
|
147
|
+
if len(path_parts) >= 2:
|
|
148
|
+
user = path_parts[0]
|
|
149
|
+
repo = path_parts[1]
|
|
150
|
+
|
|
151
|
+
# Handle special GitHub paths like /tree/branch, /blob/branch/file
|
|
152
|
+
if len(path_parts) > 2 and path_parts[2] in ["tree", "blob"]:
|
|
153
|
+
# This is a branch/file reference, just take user/repo
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
if user and repo:
|
|
157
|
+
return f"{user}/{repo}"
|
|
158
|
+
else:
|
|
159
|
+
raise PrismorAPIError(f"Invalid GitHub URL format: {repo_input}")
|
|
160
|
+
else:
|
|
161
|
+
raise PrismorAPIError(f"Invalid GitHub URL format: {repo_input}")
|
|
162
|
+
else:
|
|
163
|
+
raise PrismorAPIError(f"Not a GitHub URL: {repo_input}")
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
raise PrismorAPIError(f"Failed to parse URL: {repo_input}. Error: {str(e)}")
|
|
167
|
+
|
|
168
|
+
# Handle bare domain formats: github.com/user/repo or www.github.com/user/repo
|
|
169
|
+
if repo_input.startswith(("github.com/", "www.github.com/")):
|
|
170
|
+
# Remove domain prefix
|
|
171
|
+
if repo_input.startswith("github.com/"):
|
|
172
|
+
repo_part = repo_input[11:] # Remove "github.com/"
|
|
173
|
+
else: # www.github.com/
|
|
174
|
+
repo_part = repo_input[15:] # Remove "www.github.com/"
|
|
175
|
+
|
|
176
|
+
# Remove .git suffix if present
|
|
177
|
+
if repo_part.endswith(".git"):
|
|
178
|
+
repo_part = repo_part[:-4]
|
|
179
|
+
|
|
180
|
+
# Remove trailing slash
|
|
181
|
+
repo_part = repo_part.rstrip("/")
|
|
182
|
+
|
|
183
|
+
# Split and validate
|
|
184
|
+
parts = repo_part.split("/")
|
|
185
|
+
if len(parts) >= 2:
|
|
186
|
+
user = parts[0]
|
|
187
|
+
repo = parts[1]
|
|
188
|
+
|
|
189
|
+
# Handle special GitHub paths like /tree/branch, /blob/branch/file
|
|
190
|
+
if len(parts) > 2 and parts[2] in ["tree", "blob"]:
|
|
191
|
+
# This is a branch/file reference, just take user/repo
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
if user and repo:
|
|
195
|
+
return f"{user}/{repo}"
|
|
196
|
+
else:
|
|
197
|
+
raise PrismorAPIError(f"Invalid repository format: {repo_input}")
|
|
198
|
+
else:
|
|
199
|
+
raise PrismorAPIError(f"Invalid repository format: {repo_input}")
|
|
200
|
+
|
|
201
|
+
# If we get here, the format is not recognized
|
|
202
|
+
raise PrismorAPIError(
|
|
203
|
+
f"Unrecognized repository format: {repo_input}. "
|
|
204
|
+
"Supported formats: 'user/repo', 'https://github.com/user/repo', "
|
|
205
|
+
"'git@github.com:user/repo.git', or 'github.com/user/repo'"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class PrismorClient:
|
|
210
|
+
"""Client for interacting with Prismor API."""
|
|
211
|
+
|
|
212
|
+
def __init__(self, api_key: Optional[str] = None):
|
|
213
|
+
"""Initialize the Prismor API client.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
api_key: Prismor API key. If not provided, will look for PRISMOR_API_KEY env var.
|
|
217
|
+
"""
|
|
218
|
+
self.api_key = api_key or os.environ.get("PRISMOR_API_KEY")
|
|
219
|
+
if not self.api_key:
|
|
220
|
+
raise PrismorAPIError(
|
|
221
|
+
"PRISMOR_API_KEY environment variable is not set. "
|
|
222
|
+
"Please specify your API key. You can generate one for free at https://www.prismor.dev/cli\n"
|
|
223
|
+
"Set it with: export PRISMOR_API_KEY=your_api_key"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# self.base_url = "http://localhost:3000"
|
|
227
|
+
self.base_url = os.environ.get("PRISMOR_CLI_URL", "https://prismor.dev")
|
|
228
|
+
self.headers = {
|
|
229
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
230
|
+
"Content-Type": "application/json"
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def authenticate(self) -> Dict[str, Any]:
|
|
234
|
+
"""Authenticate with the Prismor API using the API key.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Dictionary containing user information and repositories
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
PrismorAPIError: If authentication fails
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
response = requests.post(
|
|
244
|
+
f"{self.base_url}/api/cli/auth",
|
|
245
|
+
json={"apiKey": self.api_key},
|
|
246
|
+
headers={"Content-Type": "application/json"},
|
|
247
|
+
timeout=30
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if response.status_code == 401:
|
|
251
|
+
raise PrismorAPIError("Invalid API key. Please check your PRISMOR_API_KEY.")
|
|
252
|
+
|
|
253
|
+
if response.status_code == 400:
|
|
254
|
+
raise PrismorAPIError("API key is required.")
|
|
255
|
+
|
|
256
|
+
if response.status_code >= 400:
|
|
257
|
+
error_msg = response.json().get("error", "Authentication failed")
|
|
258
|
+
raise PrismorAPIError(f"Authentication error: {error_msg}")
|
|
259
|
+
|
|
260
|
+
response.raise_for_status()
|
|
261
|
+
return response.json()
|
|
262
|
+
|
|
263
|
+
except requests.exceptions.Timeout:
|
|
264
|
+
raise PrismorAPIError("Authentication request timed out.")
|
|
265
|
+
except requests.exceptions.ConnectionError:
|
|
266
|
+
raise PrismorAPIError(
|
|
267
|
+
"Failed to connect to Prismor API. Please check your internet connection."
|
|
268
|
+
)
|
|
269
|
+
except requests.exceptions.RequestException as e:
|
|
270
|
+
raise PrismorAPIError(f"Authentication request failed: {str(e)}")
|
|
271
|
+
|
|
272
|
+
def normalize_repo_url(self, repo: str) -> str:
|
|
273
|
+
"""Normalize repository input to a full GitHub URL.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
repo: Repository in various formats (username/repo, GitHub URL, etc.)
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Full GitHub repository URL
|
|
280
|
+
"""
|
|
281
|
+
# Use the comprehensive parser to extract user/repo_name
|
|
282
|
+
repo_name = parse_github_repo(repo)
|
|
283
|
+
|
|
284
|
+
# Convert to full GitHub URL
|
|
285
|
+
return f"https://github.com/{repo_name}"
|
|
286
|
+
|
|
287
|
+
def list_orgs(self) -> Dict[str, Any]:
|
|
288
|
+
"""List the organizations this API key's user belongs to."""
|
|
289
|
+
resp = requests.post(
|
|
290
|
+
f"{self.base_url}/api/cli/orgs",
|
|
291
|
+
json={"api_key": self.api_key},
|
|
292
|
+
headers=self.headers,
|
|
293
|
+
timeout=30,
|
|
294
|
+
)
|
|
295
|
+
if resp.status_code == 401:
|
|
296
|
+
raise PrismorAPIError("Invalid API key. Please check your PRISMOR_API_KEY.")
|
|
297
|
+
resp.raise_for_status()
|
|
298
|
+
return resp.json()
|
|
299
|
+
|
|
300
|
+
# ── Org controls (policy-as-code, read-only listings) ──────────────────
|
|
301
|
+
def _org_param(self, org_id: Optional[str]) -> str:
|
|
302
|
+
if not org_id:
|
|
303
|
+
try:
|
|
304
|
+
from prismor import cli_config
|
|
305
|
+
org_id = cli_config.active_org_id()
|
|
306
|
+
except Exception:
|
|
307
|
+
org_id = None
|
|
308
|
+
return f"?orgId={org_id}" if org_id else ""
|
|
309
|
+
|
|
310
|
+
def _cli_admin_get(self, path: str, org_id: Optional[str]) -> Dict[str, Any]:
|
|
311
|
+
resp = requests.get(f"{self.base_url}{path}{self._org_param(org_id)}", headers=self.headers, timeout=30)
|
|
312
|
+
if not resp.ok:
|
|
313
|
+
try:
|
|
314
|
+
msg = resp.json().get("message") or resp.text
|
|
315
|
+
except Exception:
|
|
316
|
+
msg = resp.text
|
|
317
|
+
raise PrismorAPIError(msg or f"Request failed ({resp.status_code})")
|
|
318
|
+
return resp.json()
|
|
319
|
+
|
|
320
|
+
def get_org_policy(self, org_id: Optional[str] = None) -> Dict[str, Any]:
|
|
321
|
+
"""Fetch the active org policy (YAML + version)."""
|
|
322
|
+
return self._cli_admin_get("/api/cli/admin/policy", org_id)
|
|
323
|
+
|
|
324
|
+
def apply_org_policy(self, yaml_text: str, org_id: Optional[str] = None, dry_run: bool = False) -> Dict[str, Any]:
|
|
325
|
+
"""Apply (or dry-run validate) an org policy YAML. Requires an admin-scoped key."""
|
|
326
|
+
body: Dict[str, Any] = {"yaml": yaml_text, "dryRun": dry_run}
|
|
327
|
+
if not org_id:
|
|
328
|
+
try:
|
|
329
|
+
from prismor import cli_config
|
|
330
|
+
org_id = cli_config.active_org_id()
|
|
331
|
+
except Exception:
|
|
332
|
+
org_id = None
|
|
333
|
+
if org_id:
|
|
334
|
+
body["orgId"] = org_id
|
|
335
|
+
resp = requests.post(
|
|
336
|
+
f"{self.base_url}/api/cli/admin/policy",
|
|
337
|
+
json=body,
|
|
338
|
+
headers={**self.headers, "Content-Type": "application/json"},
|
|
339
|
+
timeout=30,
|
|
340
|
+
)
|
|
341
|
+
if not resp.ok:
|
|
342
|
+
try:
|
|
343
|
+
msg = resp.json().get("message") or resp.text
|
|
344
|
+
except Exception:
|
|
345
|
+
msg = resp.text
|
|
346
|
+
raise PrismorAPIError(msg or f"Apply failed ({resp.status_code})")
|
|
347
|
+
return resp.json()
|
|
348
|
+
|
|
349
|
+
def list_devices(self, org_id: Optional[str] = None) -> Dict[str, Any]:
|
|
350
|
+
return self._cli_admin_get("/api/cli/admin/devices", org_id)
|
|
351
|
+
|
|
352
|
+
def list_members(self, org_id: Optional[str] = None) -> Dict[str, Any]:
|
|
353
|
+
return self._cli_admin_get("/api/cli/admin/members", org_id)
|
|
354
|
+
|
|
355
|
+
def scan(
|
|
356
|
+
self,
|
|
357
|
+
repo: str,
|
|
358
|
+
scan: bool = False,
|
|
359
|
+
sbom: bool = False,
|
|
360
|
+
detect_secret: bool = False,
|
|
361
|
+
fullscan: bool = False,
|
|
362
|
+
branch: Optional[str] = None,
|
|
363
|
+
action_id: Optional[str] = None
|
|
364
|
+
) -> Dict[str, Any]:
|
|
365
|
+
"""Perform security scan on a GitHub repository.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
repo: Repository URL or username/repo format
|
|
369
|
+
scan: Enable vulnerability scanning
|
|
370
|
+
sbom: Enable SBOM generation
|
|
371
|
+
detect_secret: Enable secret detection
|
|
372
|
+
fullscan: Enable all scan types
|
|
373
|
+
branch: Specific branch to scan (defaults to main/master)
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Dictionary containing scan results
|
|
377
|
+
"""
|
|
378
|
+
repo_url = self.normalize_repo_url(repo)
|
|
379
|
+
|
|
380
|
+
# First authenticate to get user info
|
|
381
|
+
auth_response = self.authenticate()
|
|
382
|
+
user_info = auth_response.get("user", {})
|
|
383
|
+
|
|
384
|
+
# Prepare request payload for CLI scan
|
|
385
|
+
payload = {
|
|
386
|
+
"repo_url": repo_url,
|
|
387
|
+
"api_key": self.api_key,
|
|
388
|
+
"scan": scan or fullscan,
|
|
389
|
+
"sbom": sbom or fullscan,
|
|
390
|
+
"detect_secret": detect_secret or fullscan,
|
|
391
|
+
"fullscan": fullscan,
|
|
392
|
+
"branch": branch,
|
|
393
|
+
"action_id": action_id,
|
|
394
|
+
"github_token": os.environ.get("GITHUB_TOKEN"),
|
|
395
|
+
"org_id": _active_org_id(), # attribute to the active org (prismor org switch)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
# Note: Vulnerability scans now run asynchronously and can take up to 10 minutes
|
|
400
|
+
# The web API handles polling internally, so we just need a longer timeout
|
|
401
|
+
# Use retry logic for network resilience
|
|
402
|
+
def make_request():
|
|
403
|
+
return requests.post(
|
|
404
|
+
f"{self.base_url}/api/cli/scan",
|
|
405
|
+
json=payload,
|
|
406
|
+
headers={"Content-Type": "application/json"},
|
|
407
|
+
timeout=600 # 10 minute timeout to accommodate async vulnerability scans
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
response = retry_request(make_request, max_retries=3)
|
|
411
|
+
|
|
412
|
+
if response.status_code == 401:
|
|
413
|
+
error_data = response.json()
|
|
414
|
+
if error_data.get("action") == "integrate_github":
|
|
415
|
+
raise PrismorAPIError(
|
|
416
|
+
f"{error_data.get('message', 'GitHub integration required')}\n"
|
|
417
|
+
f"Please visit: {error_data.get('integration_url', 'https://prismor.dev/dashboard')}"
|
|
418
|
+
)
|
|
419
|
+
raise PrismorAPIError("Invalid API key. Please check your PRISMOR_API_KEY.")
|
|
420
|
+
|
|
421
|
+
if response.status_code == 404:
|
|
422
|
+
raise PrismorAPIError("CLI scan endpoint not found. Please check if CLI endpoints are available.")
|
|
423
|
+
|
|
424
|
+
if response.status_code >= 400:
|
|
425
|
+
error_msg = response.json().get("error", "Unknown error")
|
|
426
|
+
raise PrismorAPIError(f"API error: {error_msg}")
|
|
427
|
+
|
|
428
|
+
response.raise_for_status()
|
|
429
|
+
result = response.json()
|
|
430
|
+
|
|
431
|
+
# Handle the new response format from CLI endpoint
|
|
432
|
+
if result.get("ok") and result.get("status") == "accepted" and "job_id" in result:
|
|
433
|
+
job_id = result["job_id"]
|
|
434
|
+
poll_interval = DEFAULT_SCAN_POLL_INTERVAL_SECONDS
|
|
435
|
+
max_wait_seconds = DEFAULT_SCAN_MAX_WAIT_SECONDS
|
|
436
|
+
status_retry_limit = DEFAULT_SCAN_STATUS_RETRY_LIMIT
|
|
437
|
+
started_at = time.time()
|
|
438
|
+
transient_failures = 0
|
|
439
|
+
|
|
440
|
+
# Poll for completion with a hard timeout to avoid infinite hangs in CI.
|
|
441
|
+
while True:
|
|
442
|
+
elapsed = time.time() - started_at
|
|
443
|
+
if elapsed > max_wait_seconds:
|
|
444
|
+
raise PrismorAPIError(
|
|
445
|
+
f"Scan timed out after {max_wait_seconds} seconds. "
|
|
446
|
+
f"Job ID: {job_id}. Check status with: prismor scan-status {job_id}"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
time.sleep(poll_interval)
|
|
450
|
+
|
|
451
|
+
try:
|
|
452
|
+
status_data = self.check_scan_status(job_id)
|
|
453
|
+
transient_failures = 0
|
|
454
|
+
status = status_data.get("status")
|
|
455
|
+
|
|
456
|
+
if status in {"completed", "success"}:
|
|
457
|
+
# Return the results
|
|
458
|
+
if "results" in status_data:
|
|
459
|
+
return status_data["results"]
|
|
460
|
+
return status_data
|
|
461
|
+
|
|
462
|
+
if status in {"failed", "error", "cancelled"}:
|
|
463
|
+
error_msg = status_data.get("error") or status_data.get("message") or "Unknown error"
|
|
464
|
+
raise PrismorAPIError(f"Scan failed: {error_msg}")
|
|
465
|
+
|
|
466
|
+
except PrismorAPIError as e:
|
|
467
|
+
error_text = str(e).lower()
|
|
468
|
+
is_transient = (
|
|
469
|
+
"timed out" in error_text
|
|
470
|
+
or "failed to connect" in error_text
|
|
471
|
+
or "request failed" in error_text
|
|
472
|
+
or "502" in error_text
|
|
473
|
+
or "503" in error_text
|
|
474
|
+
or "504" in error_text
|
|
475
|
+
)
|
|
476
|
+
if is_transient and transient_failures < status_retry_limit:
|
|
477
|
+
transient_failures += 1
|
|
478
|
+
continue
|
|
479
|
+
raise
|
|
480
|
+
|
|
481
|
+
if result.get("ok") and "results" in result:
|
|
482
|
+
return result["results"]
|
|
483
|
+
return result
|
|
484
|
+
|
|
485
|
+
except requests.exceptions.Timeout:
|
|
486
|
+
raise PrismorAPIError(
|
|
487
|
+
"Request timed out. The repository scan is taking longer than expected. "
|
|
488
|
+
"Large repositories may require more time. Please try again or check the dashboard for results."
|
|
489
|
+
)
|
|
490
|
+
except requests.exceptions.ConnectionError:
|
|
491
|
+
raise PrismorAPIError(
|
|
492
|
+
"Failed to connect to Prismor API. Please check your internet connection."
|
|
493
|
+
)
|
|
494
|
+
except requests.exceptions.RequestException as e:
|
|
495
|
+
raise PrismorAPIError(f"Request failed: {str(e)}")
|
|
496
|
+
|
|
497
|
+
def start_vulnerability_scan(
|
|
498
|
+
self,
|
|
499
|
+
repo: str,
|
|
500
|
+
branch: Optional[str] = None,
|
|
501
|
+
github_token: Optional[str] = None
|
|
502
|
+
) -> Dict[str, Any]:
|
|
503
|
+
"""Start a vulnerability scan and return immediately with a job_id.
|
|
504
|
+
|
|
505
|
+
This method directly calls the backend API to start an async scan.
|
|
506
|
+
Use check_scan_status() to poll for completion.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
repo: Repository URL or username/repo format
|
|
510
|
+
branch: Specific branch to scan (defaults to main)
|
|
511
|
+
github_token: Optional GitHub token. If not provided, will try to get from env var GITHUB_TOKEN
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
Dictionary containing job_id and status information
|
|
515
|
+
|
|
516
|
+
Raises:
|
|
517
|
+
PrismorAPIError: If request fails
|
|
518
|
+
"""
|
|
519
|
+
# Directly call the backend API
|
|
520
|
+
backend_url = os.environ.get(
|
|
521
|
+
"PRISMOR_BACKEND_URL",
|
|
522
|
+
"https://2dlxuia6i5.execute-api.us-east-1.amazonaws.com/prod"
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
repo_url = self.normalize_repo_url(repo)
|
|
526
|
+
|
|
527
|
+
# Get GitHub token from parameter, env var, or raise error
|
|
528
|
+
gh_token = github_token or os.environ.get("GITHUB_TOKEN")
|
|
529
|
+
|
|
530
|
+
if not gh_token:
|
|
531
|
+
raise PrismorAPIError(
|
|
532
|
+
"GitHub token required. Provide it as --token, set the GITHUB_TOKEN environment variable, "
|
|
533
|
+
"or use 'prismor --repo <repo> --scan' which handles GitHub authentication automatically."
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
response = requests.post(
|
|
538
|
+
f"{backend_url}/scan",
|
|
539
|
+
json={
|
|
540
|
+
"repo_url": repo_url,
|
|
541
|
+
"token": gh_token,
|
|
542
|
+
"branch": branch or "main"
|
|
543
|
+
},
|
|
544
|
+
headers={"Content-Type": "application/json"},
|
|
545
|
+
timeout=30
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
if response.status_code >= 400:
|
|
549
|
+
error_msg = response.json().get("error", "Unknown error")
|
|
550
|
+
raise PrismorAPIError(f"API error: {error_msg}")
|
|
551
|
+
|
|
552
|
+
response.raise_for_status()
|
|
553
|
+
return response.json()
|
|
554
|
+
|
|
555
|
+
except requests.exceptions.Timeout:
|
|
556
|
+
raise PrismorAPIError("Request timed out.")
|
|
557
|
+
except requests.exceptions.ConnectionError:
|
|
558
|
+
raise PrismorAPIError(
|
|
559
|
+
"Failed to connect to Prismor API. Please check your internet connection."
|
|
560
|
+
)
|
|
561
|
+
except requests.exceptions.RequestException as e:
|
|
562
|
+
raise PrismorAPIError(f"Request failed: {str(e)}")
|
|
563
|
+
|
|
564
|
+
def check_scan_status(self, job_id: str) -> Dict[str, Any]:
|
|
565
|
+
"""Check the status of a vulnerability scan job.
|
|
566
|
+
|
|
567
|
+
This method directly calls the backend API to check scan status.
|
|
568
|
+
Use this when you have a job_id from starting an async scan.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
job_id: The job ID returned from starting a scan
|
|
572
|
+
|
|
573
|
+
Returns:
|
|
574
|
+
Dictionary containing scan status and results if completed
|
|
575
|
+
|
|
576
|
+
Raises:
|
|
577
|
+
PrismorAPIError: If request fails
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
# Call the Web API to check status (which checks the DB populate by callbacks)
|
|
581
|
+
# We do NOT call the backend directly because the async worker relies on callbacks to update state
|
|
582
|
+
|
|
583
|
+
try:
|
|
584
|
+
response = requests.get(
|
|
585
|
+
f"{self.base_url}/api/cli/scan/status/{job_id}",
|
|
586
|
+
headers={
|
|
587
|
+
"Content-Type": "application/json",
|
|
588
|
+
# Authenticate so the control plane returns the (org-scoped)
|
|
589
|
+
# scan results, not just the bare status. The key also
|
|
590
|
+
# scopes the job to the caller's org.
|
|
591
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
592
|
+
},
|
|
593
|
+
params={"api_key": self.api_key},
|
|
594
|
+
timeout=30
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
if response.status_code == 404:
|
|
598
|
+
raise PrismorAPIError(f"Scan job '{job_id}' not found.")
|
|
599
|
+
|
|
600
|
+
if response.status_code >= 400:
|
|
601
|
+
error_msg = response.json().get("error", "Unknown error")
|
|
602
|
+
raise PrismorAPIError(f"API error: {error_msg}")
|
|
603
|
+
|
|
604
|
+
response.raise_for_status()
|
|
605
|
+
return response.json()
|
|
606
|
+
|
|
607
|
+
except requests.exceptions.Timeout:
|
|
608
|
+
raise PrismorAPIError("Request timed out.")
|
|
609
|
+
except requests.exceptions.ConnectionError:
|
|
610
|
+
raise PrismorAPIError(
|
|
611
|
+
"Failed to connect to Prismor API. Please check your internet connection."
|
|
612
|
+
)
|
|
613
|
+
except requests.exceptions.RequestException as e:
|
|
614
|
+
raise PrismorAPIError(f"Request failed: {str(e)}")
|
|
615
|
+
|
|
616
|
+
def trigger_autofix(
|
|
617
|
+
self,
|
|
618
|
+
repo: str,
|
|
619
|
+
branch: Optional[str] = None,
|
|
620
|
+
instruction: Optional[str] = None
|
|
621
|
+
) -> Dict[str, Any]:
|
|
622
|
+
"""Trigger AI auto-fix for a repository and return a job_id immediately.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
repo: Repository URL or username/repo format
|
|
626
|
+
branch: Base branch to apply fixes on (defaults to main)
|
|
627
|
+
instruction: Custom fix instruction; if omitted a sensible default is used
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
Dictionary containing job_id and status
|
|
631
|
+
"""
|
|
632
|
+
repo_url = self.normalize_repo_url(repo)
|
|
633
|
+
|
|
634
|
+
payload: Dict[str, Any] = {
|
|
635
|
+
"api_key": self.api_key,
|
|
636
|
+
"repo_url": repo_url,
|
|
637
|
+
"branch": branch,
|
|
638
|
+
"org_id": _active_org_id(), # attribute to the active org (prismor org switch)
|
|
639
|
+
}
|
|
640
|
+
if instruction:
|
|
641
|
+
payload["instruction"] = instruction
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
response = requests.post(
|
|
645
|
+
f"{self.base_url}/api/cli/fix",
|
|
646
|
+
json=payload,
|
|
647
|
+
headers={"Content-Type": "application/json"},
|
|
648
|
+
timeout=30,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
if response.status_code == 401:
|
|
652
|
+
error_data = response.json()
|
|
653
|
+
raise PrismorAPIError(error_data.get("error", "Unauthorized"))
|
|
654
|
+
|
|
655
|
+
if response.status_code >= 400:
|
|
656
|
+
error_msg = response.json().get("error", "Unknown error")
|
|
657
|
+
raise PrismorAPIError(f"API error: {error_msg}")
|
|
658
|
+
|
|
659
|
+
response.raise_for_status()
|
|
660
|
+
return response.json()
|
|
661
|
+
|
|
662
|
+
except requests.exceptions.Timeout:
|
|
663
|
+
raise PrismorAPIError("Request timed out.")
|
|
664
|
+
except requests.exceptions.ConnectionError:
|
|
665
|
+
raise PrismorAPIError(
|
|
666
|
+
"Failed to connect to Prismor API. Please check your internet connection."
|
|
667
|
+
)
|
|
668
|
+
except requests.exceptions.RequestException as e:
|
|
669
|
+
raise PrismorAPIError(f"Request failed: {str(e)}")
|
|
670
|
+
|
|
671
|
+
def check_fix_status(self, job_id: str) -> Dict[str, Any]:
|
|
672
|
+
"""Check the status of an auto-fix job.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
job_id: The job ID returned by trigger_autofix()
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
Dictionary containing job status, pr_url, etc.
|
|
679
|
+
"""
|
|
680
|
+
try:
|
|
681
|
+
response = requests.get(
|
|
682
|
+
f"{self.base_url}/api/cli/fix/status/{job_id}",
|
|
683
|
+
params={"api_key": self.api_key},
|
|
684
|
+
headers={"Content-Type": "application/json"},
|
|
685
|
+
timeout=30,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
if response.status_code == 404:
|
|
689
|
+
raise PrismorAPIError(f"Fix job '{job_id}' not found.")
|
|
690
|
+
|
|
691
|
+
if response.status_code >= 400:
|
|
692
|
+
error_msg = response.json().get("error", "Unknown error")
|
|
693
|
+
raise PrismorAPIError(f"API error: {error_msg}")
|
|
694
|
+
|
|
695
|
+
response.raise_for_status()
|
|
696
|
+
return response.json()
|
|
697
|
+
|
|
698
|
+
except requests.exceptions.Timeout:
|
|
699
|
+
raise PrismorAPIError("Request timed out.")
|
|
700
|
+
except requests.exceptions.ConnectionError:
|
|
701
|
+
raise PrismorAPIError(
|
|
702
|
+
"Failed to connect to Prismor API. Please check your internet connection."
|
|
703
|
+
)
|
|
704
|
+
except requests.exceptions.RequestException as e:
|
|
705
|
+
raise PrismorAPIError(f"Request failed: {str(e)}")
|
|
706
|
+
|
|
707
|
+
def get_repositories(self) -> Dict[str, Any]:
|
|
708
|
+
"""Get user's repositories.
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
Dictionary containing user repositories
|
|
712
|
+
|
|
713
|
+
Raises:
|
|
714
|
+
PrismorAPIError: If request fails
|
|
715
|
+
"""
|
|
716
|
+
auth_response = self.authenticate()
|
|
717
|
+
user_info = auth_response.get("user", {})
|
|
718
|
+
return {
|
|
719
|
+
"repositories": user_info.get("repositories", []),
|
|
720
|
+
"user": {
|
|
721
|
+
"id": user_info.get("id"),
|
|
722
|
+
"email": user_info.get("email"),
|
|
723
|
+
"name": user_info.get("name")
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
def get_repository_by_name(self, repo_name: str) -> Dict[str, Any]:
|
|
728
|
+
"""Get repository ID by repository name.
|
|
729
|
+
|
|
730
|
+
Args:
|
|
731
|
+
repo_name: Repository name (e.g., "username/repo")
|
|
732
|
+
|
|
733
|
+
Returns:
|
|
734
|
+
Dictionary containing repository information including ID
|
|
735
|
+
|
|
736
|
+
Raises:
|
|
737
|
+
PrismorAPIError: If request fails
|
|
738
|
+
"""
|
|
739
|
+
try:
|
|
740
|
+
response = requests.post(
|
|
741
|
+
f"{self.base_url}/api/repositories/by-name",
|
|
742
|
+
json={
|
|
743
|
+
"apiKey": self.api_key,
|
|
744
|
+
"repoName": repo_name
|
|
745
|
+
},
|
|
746
|
+
headers={"Content-Type": "application/json"},
|
|
747
|
+
timeout=30
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
if response.status_code == 401:
|
|
751
|
+
raise PrismorAPIError("Invalid API key. Please check your PRISMOR_API_KEY.")
|
|
752
|
+
|
|
753
|
+
if response.status_code == 404:
|
|
754
|
+
raise PrismorAPIError(f"Repository '{repo_name}' not found.")
|
|
755
|
+
|
|
756
|
+
if response.status_code >= 400:
|
|
757
|
+
error_msg = response.json().get("error", "Unknown error")
|
|
758
|
+
raise PrismorAPIError(f"API error: {error_msg}")
|
|
759
|
+
|
|
760
|
+
response.raise_for_status()
|
|
761
|
+
return response.json()
|
|
762
|
+
|
|
763
|
+
except requests.exceptions.Timeout:
|
|
764
|
+
raise PrismorAPIError("Request timed out.")
|
|
765
|
+
except requests.exceptions.ConnectionError:
|
|
766
|
+
raise PrismorAPIError(
|
|
767
|
+
"Failed to connect to Prismor API. Please check your internet connection."
|
|
768
|
+
)
|
|
769
|
+
except requests.exceptions.RequestException as e:
|
|
770
|
+
raise PrismorAPIError(f"Request failed: {str(e)}")
|