prismor 0.1.2__py3-none-any.whl → 1.1.1__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 +1 -1
- prismor/api.py +347 -22
- prismor/cli.py +354 -72
- prismor-1.1.1.dist-info/METADATA +744 -0
- prismor-1.1.1.dist-info/RECORD +9 -0
- {prismor-0.1.2.dist-info → prismor-1.1.1.dist-info}/WHEEL +1 -1
- prismor-0.1.2.dist-info/METADATA +0 -371
- prismor-0.1.2.dist-info/RECORD +0 -9
- {prismor-0.1.2.dist-info → prismor-1.1.1.dist-info}/entry_points.txt +0 -0
- {prismor-0.1.2.dist-info → prismor-1.1.1.dist-info}/licenses/LICENSE +0 -0
- {prismor-0.1.2.dist-info → prismor-1.1.1.dist-info}/top_level.txt +0 -0
prismor/__init__.py
CHANGED
prismor/api.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""API client for Prismor security scanning service."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
4
6
|
import requests
|
|
5
7
|
from typing import Optional, Dict, Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
class PrismorAPIError(Exception):
|
|
@@ -10,6 +13,186 @@ class PrismorAPIError(Exception):
|
|
|
10
13
|
pass
|
|
11
14
|
|
|
12
15
|
|
|
16
|
+
def retry_request(func, max_retries=3, backoff_factor=2):
|
|
17
|
+
"""Retry a request function with exponential backoff.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
func: Function to retry (should return requests.Response)
|
|
21
|
+
max_retries: Maximum number of retry attempts
|
|
22
|
+
backoff_factor: Multiplier for delay between retries
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Response from the function
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
Exception: If all retries fail
|
|
29
|
+
"""
|
|
30
|
+
last_exception = None
|
|
31
|
+
|
|
32
|
+
for attempt in range(max_retries):
|
|
33
|
+
try:
|
|
34
|
+
return func()
|
|
35
|
+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
|
|
36
|
+
last_exception = e
|
|
37
|
+
if attempt < max_retries - 1:
|
|
38
|
+
delay = backoff_factor ** attempt
|
|
39
|
+
time.sleep(delay)
|
|
40
|
+
continue
|
|
41
|
+
raise
|
|
42
|
+
except Exception as e:
|
|
43
|
+
# Don't retry on other exceptions
|
|
44
|
+
raise
|
|
45
|
+
|
|
46
|
+
# If we get here, all retries failed
|
|
47
|
+
raise last_exception
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_github_repo(repo_input: str) -> str:
|
|
51
|
+
"""Extract user/repo_name from various GitHub URL formats or return as-is if already in correct format.
|
|
52
|
+
|
|
53
|
+
This function handles multiple GitHub URL formats:
|
|
54
|
+
- user/repo_name (already in correct format)
|
|
55
|
+
- https://github.com/user/repo_name
|
|
56
|
+
- https://www.github.com/user/repo_name
|
|
57
|
+
- http://github.com/user/repo_name
|
|
58
|
+
- http://www.github.com/user/repo_name
|
|
59
|
+
- github.com/user/repo_name
|
|
60
|
+
- www.github.com/user/repo_name
|
|
61
|
+
- git@github.com:user/repo_name.git
|
|
62
|
+
- https://github.com/user/repo_name.git
|
|
63
|
+
- https://github.com/user/repo_name/
|
|
64
|
+
- https://github.com/user/repo_name#branch
|
|
65
|
+
- https://github.com/user/repo_name/tree/branch
|
|
66
|
+
- https://github.com/user/repo_name/blob/branch/file
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
repo_input: Repository input in any of the supported formats
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Repository in "user/repo_name" format
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
PrismorAPIError: If the input format is not recognized or invalid
|
|
76
|
+
"""
|
|
77
|
+
if not repo_input or not isinstance(repo_input, str):
|
|
78
|
+
raise PrismorAPIError("Repository input cannot be empty")
|
|
79
|
+
|
|
80
|
+
# Validate repository name characters (GitHub allows alphanumeric, hyphens, underscores, dots)
|
|
81
|
+
def validate_repo_part(part: str, part_name: str) -> None:
|
|
82
|
+
if not part:
|
|
83
|
+
raise PrismorAPIError(f"{part_name} cannot be empty")
|
|
84
|
+
if len(part) > 100:
|
|
85
|
+
raise PrismorAPIError(f"{part_name} is too long (max 100 characters)")
|
|
86
|
+
# GitHub allows alphanumeric, hyphens, underscores, dots, but not starting/ending with special chars
|
|
87
|
+
if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$', part):
|
|
88
|
+
raise PrismorAPIError(
|
|
89
|
+
f"Invalid {part_name}: '{part}'. Must contain only alphanumeric characters, "
|
|
90
|
+
"hyphens, underscores, or dots, and cannot start or end with special characters."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
repo_input = repo_input.strip()
|
|
94
|
+
|
|
95
|
+
# If it's already in user/repo format (no protocol, no domain), return as-is
|
|
96
|
+
if "/" in repo_input and not any(repo_input.startswith(prefix) for prefix in
|
|
97
|
+
["http://", "https://", "git@", "github.com", "www.github.com"]):
|
|
98
|
+
# Validate it has exactly one slash and both parts are non-empty
|
|
99
|
+
parts = repo_input.split("/")
|
|
100
|
+
if len(parts) == 2 and parts[0] and parts[1]:
|
|
101
|
+
validate_repo_part(parts[0], "Username")
|
|
102
|
+
validate_repo_part(parts[1], "Repository name")
|
|
103
|
+
return repo_input
|
|
104
|
+
else:
|
|
105
|
+
raise PrismorAPIError(f"Invalid repository format: {repo_input}. Expected 'user/repo_name'")
|
|
106
|
+
|
|
107
|
+
# Handle SSH format: git@github.com:user/repo.git
|
|
108
|
+
if repo_input.startswith("git@github.com:"):
|
|
109
|
+
repo_part = repo_input[15:] # Remove "git@github.com:"
|
|
110
|
+
# Remove .git suffix if present
|
|
111
|
+
if repo_part.endswith(".git"):
|
|
112
|
+
repo_part = repo_part[:-4]
|
|
113
|
+
if "/" in repo_part:
|
|
114
|
+
return repo_part
|
|
115
|
+
else:
|
|
116
|
+
raise PrismorAPIError(f"Invalid SSH repository format: {repo_input}")
|
|
117
|
+
|
|
118
|
+
# Handle HTTP/HTTPS URLs
|
|
119
|
+
if repo_input.startswith(("http://", "https://")):
|
|
120
|
+
try:
|
|
121
|
+
parsed = urlparse(repo_input)
|
|
122
|
+
hostname = parsed.hostname.lower()
|
|
123
|
+
|
|
124
|
+
# Check if it's a GitHub URL
|
|
125
|
+
if hostname in ["github.com", "www.github.com"]:
|
|
126
|
+
path = parsed.path.strip("/")
|
|
127
|
+
|
|
128
|
+
# Remove .git suffix if present
|
|
129
|
+
if path.endswith(".git"):
|
|
130
|
+
path = path[:-4]
|
|
131
|
+
|
|
132
|
+
# Split path and extract user/repo
|
|
133
|
+
path_parts = path.split("/")
|
|
134
|
+
if len(path_parts) >= 2:
|
|
135
|
+
user = path_parts[0]
|
|
136
|
+
repo = path_parts[1]
|
|
137
|
+
|
|
138
|
+
# Handle special GitHub paths like /tree/branch, /blob/branch/file
|
|
139
|
+
if len(path_parts) > 2 and path_parts[2] in ["tree", "blob"]:
|
|
140
|
+
# This is a branch/file reference, just take user/repo
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
if user and repo:
|
|
144
|
+
return f"{user}/{repo}"
|
|
145
|
+
else:
|
|
146
|
+
raise PrismorAPIError(f"Invalid GitHub URL format: {repo_input}")
|
|
147
|
+
else:
|
|
148
|
+
raise PrismorAPIError(f"Invalid GitHub URL format: {repo_input}")
|
|
149
|
+
else:
|
|
150
|
+
raise PrismorAPIError(f"Not a GitHub URL: {repo_input}")
|
|
151
|
+
|
|
152
|
+
except Exception as e:
|
|
153
|
+
raise PrismorAPIError(f"Failed to parse URL: {repo_input}. Error: {str(e)}")
|
|
154
|
+
|
|
155
|
+
# Handle bare domain formats: github.com/user/repo or www.github.com/user/repo
|
|
156
|
+
if repo_input.startswith(("github.com/", "www.github.com/")):
|
|
157
|
+
# Remove domain prefix
|
|
158
|
+
if repo_input.startswith("github.com/"):
|
|
159
|
+
repo_part = repo_input[11:] # Remove "github.com/"
|
|
160
|
+
else: # www.github.com/
|
|
161
|
+
repo_part = repo_input[15:] # Remove "www.github.com/"
|
|
162
|
+
|
|
163
|
+
# Remove .git suffix if present
|
|
164
|
+
if repo_part.endswith(".git"):
|
|
165
|
+
repo_part = repo_part[:-4]
|
|
166
|
+
|
|
167
|
+
# Remove trailing slash
|
|
168
|
+
repo_part = repo_part.rstrip("/")
|
|
169
|
+
|
|
170
|
+
# Split and validate
|
|
171
|
+
parts = repo_part.split("/")
|
|
172
|
+
if len(parts) >= 2:
|
|
173
|
+
user = parts[0]
|
|
174
|
+
repo = parts[1]
|
|
175
|
+
|
|
176
|
+
# Handle special GitHub paths like /tree/branch, /blob/branch/file
|
|
177
|
+
if len(parts) > 2 and parts[2] in ["tree", "blob"]:
|
|
178
|
+
# This is a branch/file reference, just take user/repo
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
if user and repo:
|
|
182
|
+
return f"{user}/{repo}"
|
|
183
|
+
else:
|
|
184
|
+
raise PrismorAPIError(f"Invalid repository format: {repo_input}")
|
|
185
|
+
else:
|
|
186
|
+
raise PrismorAPIError(f"Invalid repository format: {repo_input}")
|
|
187
|
+
|
|
188
|
+
# If we get here, the format is not recognized
|
|
189
|
+
raise PrismorAPIError(
|
|
190
|
+
f"Unrecognized repository format: {repo_input}. "
|
|
191
|
+
"Supported formats: 'user/repo', 'https://github.com/user/repo', "
|
|
192
|
+
"'git@github.com:user/repo.git', or 'github.com/user/repo'"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
13
196
|
class PrismorClient:
|
|
14
197
|
"""Client for interacting with Prismor API."""
|
|
15
198
|
|
|
@@ -23,7 +206,8 @@ class PrismorClient:
|
|
|
23
206
|
if not self.api_key:
|
|
24
207
|
raise PrismorAPIError(
|
|
25
208
|
"PRISMOR_API_KEY environment variable is not set. "
|
|
26
|
-
"Please
|
|
209
|
+
"Please specify your API key. You can generate one for free at https://www.prismor.dev/cli\n"
|
|
210
|
+
"Set it with: export PRISMOR_API_KEY=your_api_key"
|
|
27
211
|
)
|
|
28
212
|
|
|
29
213
|
# self.base_url = "http://localhost:3000"
|
|
@@ -76,27 +260,21 @@ class PrismorClient:
|
|
|
76
260
|
"""Normalize repository input to a full GitHub URL.
|
|
77
261
|
|
|
78
262
|
Args:
|
|
79
|
-
repo: Repository in
|
|
263
|
+
repo: Repository in various formats (username/repo, GitHub URL, etc.)
|
|
80
264
|
|
|
81
265
|
Returns:
|
|
82
266
|
Full GitHub repository URL
|
|
83
267
|
"""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
# Assume it's in username/repo format
|
|
88
|
-
if "/" in repo:
|
|
89
|
-
return f"https://github.com/{repo}"
|
|
268
|
+
# Use the comprehensive parser to extract user/repo_name
|
|
269
|
+
repo_name = parse_github_repo(repo)
|
|
90
270
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"Please use 'username/repo' or full GitHub URL"
|
|
94
|
-
)
|
|
271
|
+
# Convert to full GitHub URL
|
|
272
|
+
return f"https://github.com/{repo_name}"
|
|
95
273
|
|
|
96
274
|
def scan(
|
|
97
275
|
self,
|
|
98
276
|
repo: str,
|
|
99
|
-
|
|
277
|
+
scan: bool = False,
|
|
100
278
|
sbom: bool = False,
|
|
101
279
|
detect_secret: bool = False,
|
|
102
280
|
fullscan: bool = False,
|
|
@@ -106,7 +284,7 @@ class PrismorClient:
|
|
|
106
284
|
|
|
107
285
|
Args:
|
|
108
286
|
repo: Repository URL or username/repo format
|
|
109
|
-
|
|
287
|
+
scan: Enable vulnerability scanning
|
|
110
288
|
sbom: Enable SBOM generation
|
|
111
289
|
detect_secret: Enable secret detection
|
|
112
290
|
fullscan: Enable all scan types
|
|
@@ -125,7 +303,7 @@ class PrismorClient:
|
|
|
125
303
|
payload = {
|
|
126
304
|
"repo_url": repo_url,
|
|
127
305
|
"api_key": self.api_key,
|
|
128
|
-
"
|
|
306
|
+
"scan": scan or fullscan,
|
|
129
307
|
"sbom": sbom or fullscan,
|
|
130
308
|
"detect_secret": detect_secret or fullscan,
|
|
131
309
|
"fullscan": fullscan,
|
|
@@ -133,12 +311,18 @@ class PrismorClient:
|
|
|
133
311
|
}
|
|
134
312
|
|
|
135
313
|
try:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
314
|
+
# Note: Vulnerability scans now run asynchronously and can take up to 10 minutes
|
|
315
|
+
# The web API handles polling internally, so we just need a longer timeout
|
|
316
|
+
# Use retry logic for network resilience
|
|
317
|
+
def make_request():
|
|
318
|
+
return requests.post(
|
|
319
|
+
f"{self.base_url}/api/cli/scan",
|
|
320
|
+
json=payload,
|
|
321
|
+
headers={"Content-Type": "application/json"},
|
|
322
|
+
timeout=600 # 10 minute timeout to accommodate async vulnerability scans
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
response = retry_request(make_request, max_retries=3)
|
|
142
326
|
|
|
143
327
|
if response.status_code == 401:
|
|
144
328
|
error_data = response.json()
|
|
@@ -160,14 +344,155 @@ class PrismorClient:
|
|
|
160
344
|
result = response.json()
|
|
161
345
|
|
|
162
346
|
# Handle the new response format from CLI endpoint
|
|
347
|
+
if result.get("ok") and result.get("status") == "accepted" and "job_id" in result:
|
|
348
|
+
job_id = result["job_id"]
|
|
349
|
+
|
|
350
|
+
# Poll for completion
|
|
351
|
+
while True:
|
|
352
|
+
time.sleep(5) # Wait between polls
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
status_data = self.check_scan_status(job_id)
|
|
356
|
+
status = status_data.get("status")
|
|
357
|
+
|
|
358
|
+
if status == "completed":
|
|
359
|
+
# Return the results
|
|
360
|
+
if "results" in status_data:
|
|
361
|
+
return status_data["results"]
|
|
362
|
+
return status_data
|
|
363
|
+
|
|
364
|
+
if status == "failed":
|
|
365
|
+
error_msg = status_data.get("error", "Unknown error")
|
|
366
|
+
raise PrismorAPIError(f"Scan failed: {error_msg}")
|
|
367
|
+
|
|
368
|
+
except Exception as e:
|
|
369
|
+
# For now, let exceptions propagate to the caller which will stop the spinner
|
|
370
|
+
# In the future, we might want to implement retry logic for transient errors
|
|
371
|
+
raise e
|
|
372
|
+
|
|
163
373
|
if result.get("ok") and "results" in result:
|
|
164
374
|
return result["results"]
|
|
165
375
|
return result
|
|
166
376
|
|
|
167
377
|
except requests.exceptions.Timeout:
|
|
168
378
|
raise PrismorAPIError(
|
|
169
|
-
"Request timed out. The repository scan is taking longer than expected."
|
|
379
|
+
"Request timed out. The repository scan is taking longer than expected. "
|
|
380
|
+
"Large repositories may require more time. Please try again or check the dashboard for results."
|
|
381
|
+
)
|
|
382
|
+
except requests.exceptions.ConnectionError:
|
|
383
|
+
raise PrismorAPIError(
|
|
384
|
+
"Failed to connect to Prismor API. Please check your internet connection."
|
|
385
|
+
)
|
|
386
|
+
except requests.exceptions.RequestException as e:
|
|
387
|
+
raise PrismorAPIError(f"Request failed: {str(e)}")
|
|
388
|
+
|
|
389
|
+
def start_vulnerability_scan(
|
|
390
|
+
self,
|
|
391
|
+
repo: str,
|
|
392
|
+
branch: Optional[str] = None,
|
|
393
|
+
github_token: Optional[str] = None
|
|
394
|
+
) -> Dict[str, Any]:
|
|
395
|
+
"""Start a vulnerability scan and return immediately with a job_id.
|
|
396
|
+
|
|
397
|
+
This method directly calls the backend API to start an async scan.
|
|
398
|
+
Use check_scan_status() to poll for completion.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
repo: Repository URL or username/repo format
|
|
402
|
+
branch: Specific branch to scan (defaults to main)
|
|
403
|
+
github_token: Optional GitHub token. If not provided, will try to get from env var GITHUB_TOKEN
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Dictionary containing job_id and status information
|
|
407
|
+
|
|
408
|
+
Raises:
|
|
409
|
+
PrismorAPIError: If request fails
|
|
410
|
+
"""
|
|
411
|
+
# Directly call the backend API
|
|
412
|
+
backend_url = os.environ.get(
|
|
413
|
+
"PRISMOR_BACKEND_URL",
|
|
414
|
+
"https://2dlxuia6i5.execute-api.us-east-1.amazonaws.com/prod"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
repo_url = self.normalize_repo_url(repo)
|
|
418
|
+
|
|
419
|
+
# Get GitHub token from parameter, env var, or raise error
|
|
420
|
+
gh_token = github_token or os.environ.get("GITHUB_TOKEN")
|
|
421
|
+
|
|
422
|
+
if not gh_token:
|
|
423
|
+
raise PrismorAPIError(
|
|
424
|
+
"GitHub token required. Provide it as a parameter, set GITHUB_TOKEN environment variable, "
|
|
425
|
+
"or use 'prismor --scan <repo> --scan' which handles authentication automatically."
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
response = requests.post(
|
|
430
|
+
f"{backend_url}/scan",
|
|
431
|
+
json={
|
|
432
|
+
"repo_url": repo_url,
|
|
433
|
+
"token": gh_token,
|
|
434
|
+
"branch": branch or "main"
|
|
435
|
+
},
|
|
436
|
+
headers={"Content-Type": "application/json"},
|
|
437
|
+
timeout=30
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if response.status_code >= 400:
|
|
441
|
+
error_msg = response.json().get("error", "Unknown error")
|
|
442
|
+
raise PrismorAPIError(f"API error: {error_msg}")
|
|
443
|
+
|
|
444
|
+
response.raise_for_status()
|
|
445
|
+
return response.json()
|
|
446
|
+
|
|
447
|
+
except requests.exceptions.Timeout:
|
|
448
|
+
raise PrismorAPIError("Request timed out.")
|
|
449
|
+
except requests.exceptions.ConnectionError:
|
|
450
|
+
raise PrismorAPIError(
|
|
451
|
+
"Failed to connect to Prismor API. Please check your internet connection."
|
|
452
|
+
)
|
|
453
|
+
except requests.exceptions.RequestException as e:
|
|
454
|
+
raise PrismorAPIError(f"Request failed: {str(e)}")
|
|
455
|
+
|
|
456
|
+
def check_scan_status(self, job_id: str) -> Dict[str, Any]:
|
|
457
|
+
"""Check the status of a vulnerability scan job.
|
|
458
|
+
|
|
459
|
+
This method directly calls the backend API to check scan status.
|
|
460
|
+
Use this when you have a job_id from starting an async scan.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
job_id: The job ID returned from starting a scan
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Dictionary containing scan status and results if completed
|
|
467
|
+
|
|
468
|
+
Raises:
|
|
469
|
+
PrismorAPIError: If request fails
|
|
470
|
+
"""
|
|
471
|
+
# Directly call the backend API for status check
|
|
472
|
+
backend_url = os.environ.get(
|
|
473
|
+
"PRISMOR_BACKEND_URL",
|
|
474
|
+
"https://2dlxuia6i5.execute-api.us-east-1.amazonaws.com/prod"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
response = requests.get(
|
|
479
|
+
f"{backend_url}/scan/status/{job_id}",
|
|
480
|
+
headers={"Content-Type": "application/json"},
|
|
481
|
+
timeout=30
|
|
170
482
|
)
|
|
483
|
+
|
|
484
|
+
if response.status_code == 404:
|
|
485
|
+
raise PrismorAPIError(f"Scan job '{job_id}' not found.")
|
|
486
|
+
|
|
487
|
+
if response.status_code >= 400:
|
|
488
|
+
error_msg = response.json().get("error", "Unknown error")
|
|
489
|
+
raise PrismorAPIError(f"API error: {error_msg}")
|
|
490
|
+
|
|
491
|
+
response.raise_for_status()
|
|
492
|
+
return response.json()
|
|
493
|
+
|
|
494
|
+
except requests.exceptions.Timeout:
|
|
495
|
+
raise PrismorAPIError("Request timed out.")
|
|
171
496
|
except requests.exceptions.ConnectionError:
|
|
172
497
|
raise PrismorAPIError(
|
|
173
498
|
"Failed to connect to Prismor API. Please check your internet connection."
|