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 CHANGED
@@ -1,6 +1,6 @@
1
1
  """Prismor CLI - Security scanning tool for GitHub repositories."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "1.1.0"
4
4
  __author__ = "Prismor"
5
5
  __description__ = "A CLI tool for scanning GitHub repositories for vulnerabilities, secrets, and generating SBOMs"
6
6
 
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 set it with: export PRISMOR_API_KEY=your_api_key"
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 format 'username/repo' or full GitHub URL
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
- if repo.startswith("http://") or repo.startswith("https://"):
85
- return repo
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
- raise PrismorAPIError(
92
- f"Invalid repository format: {repo}. "
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
- vex: bool = False,
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
- vex: Enable vulnerability scanning
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
- "vex": vex or fullscan,
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
- response = requests.post(
137
- f"{self.base_url}/api/cli/scan",
138
- json=payload,
139
- headers={"Content-Type": "application/json"},
140
- timeout=300 # 5 minute timeout
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."