iriusrisk-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.
- iriusrisk_cli/__init__.py +3 -0
- iriusrisk_cli/api/__init__.py +15 -0
- iriusrisk_cli/api/base_client.py +467 -0
- iriusrisk_cli/api/countermeasure_client.py +169 -0
- iriusrisk_cli/api/health_client.py +23 -0
- iriusrisk_cli/api/project_client.py +638 -0
- iriusrisk_cli/api/report_client.py +219 -0
- iriusrisk_cli/api/threat_client.py +169 -0
- iriusrisk_cli/api/version_client.py +235 -0
- iriusrisk_cli/api_client.py +181 -0
- iriusrisk_cli/cli_context.py +67 -0
- iriusrisk_cli/commands/__init__.py +1 -0
- iriusrisk_cli/commands/components.py +391 -0
- iriusrisk_cli/commands/config_cmd.py +298 -0
- iriusrisk_cli/commands/countermeasures.py +530 -0
- iriusrisk_cli/commands/init.py +183 -0
- iriusrisk_cli/commands/issue_trackers.py +338 -0
- iriusrisk_cli/commands/mcp.py +1578 -0
- iriusrisk_cli/commands/otm.py +296 -0
- iriusrisk_cli/commands/projects.py +576 -0
- iriusrisk_cli/commands/reports.py +202 -0
- iriusrisk_cli/commands/sync.py +959 -0
- iriusrisk_cli/commands/threats.py +509 -0
- iriusrisk_cli/commands/updates.py +192 -0
- iriusrisk_cli/commands/versions.py +341 -0
- iriusrisk_cli/config.py +459 -0
- iriusrisk_cli/container.py +190 -0
- iriusrisk_cli/exceptions.py +264 -0
- iriusrisk_cli/main.py +380 -0
- iriusrisk_cli/prompts/analyze_source_material.md +204 -0
- iriusrisk_cli/prompts/architecture_and_design_review.md +29 -0
- iriusrisk_cli/prompts/create_threat_model.md +643 -0
- iriusrisk_cli/prompts/initialize_iriusrisk_workflow.md +328 -0
- iriusrisk_cli/prompts/security_development_advisor.md +143 -0
- iriusrisk_cli/prompts/threats_and_countermeasures.md +146 -0
- iriusrisk_cli/repositories/__init__.py +15 -0
- iriusrisk_cli/repositories/base_repository.py +100 -0
- iriusrisk_cli/repositories/countermeasure_repository.py +399 -0
- iriusrisk_cli/repositories/project_repository.py +282 -0
- iriusrisk_cli/repositories/report_repository.py +315 -0
- iriusrisk_cli/repositories/threat_repository.py +359 -0
- iriusrisk_cli/repositories/version_repository.py +284 -0
- iriusrisk_cli/service_factory.py +154 -0
- iriusrisk_cli/services/__init__.py +4 -0
- iriusrisk_cli/services/countermeasure_service.py +305 -0
- iriusrisk_cli/services/health_service.py +34 -0
- iriusrisk_cli/services/project_service.py +421 -0
- iriusrisk_cli/services/report_service.py +245 -0
- iriusrisk_cli/services/threat_service.py +176 -0
- iriusrisk_cli/services/version_service.py +230 -0
- iriusrisk_cli/utils/__init__.py +1 -0
- iriusrisk_cli/utils/api_helpers.py +316 -0
- iriusrisk_cli/utils/error_handling.py +496 -0
- iriusrisk_cli/utils/filtering.py +185 -0
- iriusrisk_cli/utils/logging_config.py +461 -0
- iriusrisk_cli/utils/lookup.py +251 -0
- iriusrisk_cli/utils/mcp_logging.py +65 -0
- iriusrisk_cli/utils/output_formatters.py +367 -0
- iriusrisk_cli/utils/project.py +94 -0
- iriusrisk_cli/utils/project_discovery.py +140 -0
- iriusrisk_cli/utils/project_resolution.py +97 -0
- iriusrisk_cli/utils/table.py +468 -0
- iriusrisk_cli/utils/updates.py +307 -0
- iriusrisk_cli-0.1.0.dist-info/METADATA +504 -0
- iriusrisk_cli-0.1.0.dist-info/RECORD +69 -0
- iriusrisk_cli-0.1.0.dist-info/WHEEL +5 -0
- iriusrisk_cli-0.1.0.dist-info/entry_points.txt +2 -0
- iriusrisk_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- iriusrisk_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""API client modules for IriusRisk API interactions."""
|
|
2
|
+
|
|
3
|
+
from .base_client import BaseApiClient
|
|
4
|
+
from .project_client import ProjectApiClient
|
|
5
|
+
from .threat_client import ThreatApiClient
|
|
6
|
+
from .countermeasure_client import CountermeasureApiClient
|
|
7
|
+
from .report_client import ReportApiClient
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'BaseApiClient',
|
|
11
|
+
'ProjectApiClient',
|
|
12
|
+
'ThreatApiClient',
|
|
13
|
+
'CountermeasureApiClient',
|
|
14
|
+
'ReportApiClient'
|
|
15
|
+
]
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Base API client with shared HTTP and authentication functionality."""
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Dict, Any, Optional
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from ..config import Config
|
|
13
|
+
from ..utils.logging_config import log_api_request
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseApiClient:
|
|
17
|
+
"""Base API client providing shared HTTP and authentication functionality."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: Optional[Config] = None):
|
|
20
|
+
"""Initialize the base API client with configuration.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
config: Configuration instance (creates new one if not provided)
|
|
24
|
+
"""
|
|
25
|
+
if config is None:
|
|
26
|
+
config = Config()
|
|
27
|
+
|
|
28
|
+
self._config = config
|
|
29
|
+
self.base_url = config.api_base_url
|
|
30
|
+
self.v1_base_url = config.api_v1_base_url
|
|
31
|
+
self.session = requests.Session()
|
|
32
|
+
self.session.headers.update({
|
|
33
|
+
'api-token': config.api_token,
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
'Accept': 'application/json, application/hal+json'
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
# Set up logging
|
|
39
|
+
self.logger = logging.getLogger(f"iriusrisk_cli.api.{self.__class__.__name__}")
|
|
40
|
+
|
|
41
|
+
# Set up response logging
|
|
42
|
+
self.log_responses = os.getenv('IRIUS_LOG_RESPONSES', '').lower() in ('true', '1', 'yes')
|
|
43
|
+
if self.log_responses:
|
|
44
|
+
self.log_dir = Path('captured_responses')
|
|
45
|
+
self.log_dir.mkdir(exist_ok=True)
|
|
46
|
+
self.logger.info(f"API Response logging enabled - saving to {self.log_dir}")
|
|
47
|
+
|
|
48
|
+
def _sanitize_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
|
|
49
|
+
"""Sanitize headers for logging by masking sensitive values.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
headers: Dictionary of headers to sanitize
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Dictionary with sensitive headers masked
|
|
56
|
+
"""
|
|
57
|
+
sensitive_headers = {'api-token', 'authorization', 'cookie', 'set-cookie'}
|
|
58
|
+
sanitized = {}
|
|
59
|
+
|
|
60
|
+
for key, value in headers.items():
|
|
61
|
+
if key.lower() in sensitive_headers:
|
|
62
|
+
sanitized[key] = '***MASKED***'
|
|
63
|
+
else:
|
|
64
|
+
sanitized[key] = value
|
|
65
|
+
|
|
66
|
+
return sanitized
|
|
67
|
+
|
|
68
|
+
def _should_retry(self, response: requests.Response, attempt: int, max_retries: int = 3) -> bool:
|
|
69
|
+
"""Determine if a request should be retried based on response.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
response: HTTP response object
|
|
73
|
+
attempt: Current attempt number (1-based)
|
|
74
|
+
max_retries: Maximum number of retries allowed
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True if request should be retried, False otherwise
|
|
78
|
+
"""
|
|
79
|
+
if attempt >= max_retries:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
# Retry on server errors (5xx) and rate limiting (429)
|
|
83
|
+
if response.status_code >= 500 or response.status_code == 429:
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
def _get_retry_delay(self, response: requests.Response, attempt: int) -> float:
|
|
89
|
+
"""Calculate delay before retry.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
response: HTTP response object
|
|
93
|
+
attempt: Current attempt number (1-based)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Delay in seconds
|
|
97
|
+
"""
|
|
98
|
+
# Check for Retry-After header
|
|
99
|
+
retry_after = response.headers.get('Retry-After')
|
|
100
|
+
if retry_after:
|
|
101
|
+
try:
|
|
102
|
+
return float(retry_after)
|
|
103
|
+
except ValueError:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
# Exponential backoff: 1s, 2s, 4s, etc.
|
|
107
|
+
return min(2 ** (attempt - 1), 60) # Cap at 60 seconds
|
|
108
|
+
|
|
109
|
+
def _make_request_with_retry(self, method: str, endpoint: str, base_url: Optional[str] = None, max_retries: int = 3, **kwargs) -> requests.Response:
|
|
110
|
+
"""Make a request with retry logic and logging.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
method: HTTP method
|
|
114
|
+
endpoint: API endpoint path
|
|
115
|
+
base_url: Base URL to use
|
|
116
|
+
max_retries: Maximum number of retries
|
|
117
|
+
**kwargs: Additional arguments for requests
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Response object
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
requests.RequestException: If all retries fail
|
|
124
|
+
"""
|
|
125
|
+
if base_url is None:
|
|
126
|
+
base_url = self.base_url
|
|
127
|
+
url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
|
128
|
+
|
|
129
|
+
last_exception = None
|
|
130
|
+
|
|
131
|
+
for attempt in range(1, max_retries + 1):
|
|
132
|
+
try:
|
|
133
|
+
# Add default timeout if not specified
|
|
134
|
+
if 'timeout' not in kwargs:
|
|
135
|
+
kwargs['timeout'] = 30
|
|
136
|
+
|
|
137
|
+
if attempt > 1:
|
|
138
|
+
self.logger.info(f"Retry attempt {attempt}/{max_retries} for {method} {endpoint}")
|
|
139
|
+
|
|
140
|
+
response = self.session.request(method, url, **kwargs)
|
|
141
|
+
|
|
142
|
+
# Check if we should retry
|
|
143
|
+
if not response.ok and self._should_retry(response, attempt, max_retries):
|
|
144
|
+
delay = self._get_retry_delay(response, attempt)
|
|
145
|
+
|
|
146
|
+
if response.status_code == 429:
|
|
147
|
+
self.logger.warning(f"Rate limited (429) on {method} {endpoint}, retrying in {delay}s")
|
|
148
|
+
elif response.status_code >= 500:
|
|
149
|
+
self.logger.warning(f"Server error ({response.status_code}) on {method} {endpoint}, retrying in {delay}s")
|
|
150
|
+
|
|
151
|
+
time.sleep(delay)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# Success or non-retryable error
|
|
155
|
+
return response
|
|
156
|
+
|
|
157
|
+
except requests.RequestException as e:
|
|
158
|
+
last_exception = e
|
|
159
|
+
if attempt < max_retries:
|
|
160
|
+
delay = self._get_retry_delay(None, attempt) if hasattr(e, 'response') and e.response else 2 ** (attempt - 1)
|
|
161
|
+
self.logger.warning(f"Request failed on attempt {attempt}/{max_retries}: {e}, retrying in {delay}s")
|
|
162
|
+
time.sleep(delay)
|
|
163
|
+
else:
|
|
164
|
+
self.logger.error(f"Request failed after {max_retries} attempts: {e}")
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
# This shouldn't be reached, but just in case
|
|
168
|
+
if last_exception:
|
|
169
|
+
raise last_exception
|
|
170
|
+
else:
|
|
171
|
+
raise requests.RequestException(f"Request failed after {max_retries} attempts")
|
|
172
|
+
|
|
173
|
+
def _log_response(self, method: str, url: str, request_kwargs: Dict[str, Any], response: requests.Response):
|
|
174
|
+
"""Log API request and response to file."""
|
|
175
|
+
if not self.log_responses:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
# Extract endpoint info
|
|
180
|
+
if '/api/v1' in url:
|
|
181
|
+
path = url.split('/api/v1')[1]
|
|
182
|
+
api_version = 'v1'
|
|
183
|
+
elif '/api/v2' in url:
|
|
184
|
+
path = url.split('/api/v2')[1]
|
|
185
|
+
api_version = 'v2'
|
|
186
|
+
else:
|
|
187
|
+
return # Skip non-API URLs
|
|
188
|
+
|
|
189
|
+
# Replace UUIDs with placeholders
|
|
190
|
+
uuid_pattern = r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
|
|
191
|
+
pattern = re.sub(uuid_pattern, '{id}', path, flags=re.IGNORECASE)
|
|
192
|
+
|
|
193
|
+
# Prepare response data
|
|
194
|
+
response_data = None
|
|
195
|
+
if response.text:
|
|
196
|
+
try:
|
|
197
|
+
response_data = response.json()
|
|
198
|
+
except json.JSONDecodeError:
|
|
199
|
+
response_data = response.text
|
|
200
|
+
|
|
201
|
+
# Prepare request data
|
|
202
|
+
request_data = None
|
|
203
|
+
if 'json' in request_kwargs:
|
|
204
|
+
request_data = request_kwargs['json']
|
|
205
|
+
elif 'data' in request_kwargs:
|
|
206
|
+
request_data = request_kwargs['data']
|
|
207
|
+
|
|
208
|
+
# Create capture record
|
|
209
|
+
capture_record = {
|
|
210
|
+
'timestamp': datetime.now().isoformat(),
|
|
211
|
+
'method': method,
|
|
212
|
+
'url': url,
|
|
213
|
+
'endpoint_pattern': pattern,
|
|
214
|
+
'api_version': api_version,
|
|
215
|
+
'request': {
|
|
216
|
+
'headers': dict(response.request.headers) if response.request else {},
|
|
217
|
+
'body': request_data
|
|
218
|
+
},
|
|
219
|
+
'response': {
|
|
220
|
+
'status_code': response.status_code,
|
|
221
|
+
'headers': dict(response.headers),
|
|
222
|
+
'body': response_data
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Generate filename
|
|
227
|
+
method_lower = method.lower()
|
|
228
|
+
pattern_clean = pattern.replace('/', '_').replace('{id}', 'id').strip('_')
|
|
229
|
+
if not pattern_clean:
|
|
230
|
+
pattern_clean = 'root'
|
|
231
|
+
|
|
232
|
+
timestamp = datetime.now().strftime('%H%M%S')
|
|
233
|
+
filename = f"{api_version}_{method_lower}_{pattern_clean}_{response.status_code}_{timestamp}.json"
|
|
234
|
+
|
|
235
|
+
# Save to file
|
|
236
|
+
filepath = self.log_dir / filename
|
|
237
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
238
|
+
json.dump(capture_record, f, indent=2, ensure_ascii=False)
|
|
239
|
+
|
|
240
|
+
self.logger.info(f"Response captured: {method} {pattern} -> {filename}")
|
|
241
|
+
|
|
242
|
+
except Exception as e:
|
|
243
|
+
self.logger.error(f"Error logging response: {e}")
|
|
244
|
+
|
|
245
|
+
def _make_request(self, method: str, endpoint: str, base_url: Optional[str] = None, **kwargs) -> Dict[str, Any]:
|
|
246
|
+
"""Make a request to the API.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
method: HTTP method (GET, POST, etc.)
|
|
250
|
+
endpoint: API endpoint path
|
|
251
|
+
base_url: Base URL to use (defaults to v2 API)
|
|
252
|
+
**kwargs: Additional arguments for requests
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
JSON response data
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
requests.RequestException: If the request fails
|
|
259
|
+
"""
|
|
260
|
+
if base_url is None:
|
|
261
|
+
base_url = self.base_url
|
|
262
|
+
url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
|
263
|
+
|
|
264
|
+
# Log request start
|
|
265
|
+
self.logger.debug(f"API request: {method} {endpoint}")
|
|
266
|
+
|
|
267
|
+
# Merge any additional headers with session headers
|
|
268
|
+
if 'headers' in kwargs:
|
|
269
|
+
headers = self.session.headers.copy()
|
|
270
|
+
headers.update(kwargs['headers'])
|
|
271
|
+
kwargs['headers'] = headers
|
|
272
|
+
|
|
273
|
+
# Log request details (sanitized)
|
|
274
|
+
if self.logger.isEnabledFor(logging.DEBUG):
|
|
275
|
+
sanitized_headers = self._sanitize_headers(kwargs.get('headers', self.session.headers))
|
|
276
|
+
self.logger.debug(f"Request headers: {sanitized_headers}")
|
|
277
|
+
|
|
278
|
+
# Log request body if present
|
|
279
|
+
if 'json' in kwargs:
|
|
280
|
+
self.logger.debug(f"Request body (JSON): {kwargs['json']}")
|
|
281
|
+
elif 'data' in kwargs:
|
|
282
|
+
self.logger.debug(f"Request body (data): {kwargs['data']}")
|
|
283
|
+
|
|
284
|
+
start_time = time.time()
|
|
285
|
+
response = None
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
# Add default timeout if not specified
|
|
289
|
+
if 'timeout' not in kwargs:
|
|
290
|
+
kwargs['timeout'] = 30
|
|
291
|
+
response = self.session.request(method, url, **kwargs)
|
|
292
|
+
response_time = time.time() - start_time
|
|
293
|
+
|
|
294
|
+
# Log successful response
|
|
295
|
+
log_api_request(
|
|
296
|
+
self.logger,
|
|
297
|
+
method=method,
|
|
298
|
+
url=url,
|
|
299
|
+
status_code=response.status_code,
|
|
300
|
+
response_time=response_time
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Log response details
|
|
304
|
+
if self.logger.isEnabledFor(logging.DEBUG):
|
|
305
|
+
content_length = len(response.content) if response.content else 0
|
|
306
|
+
self.logger.debug(f"Response: {response.status_code} ({response_time:.3f}s, {content_length} bytes)")
|
|
307
|
+
|
|
308
|
+
# Log response headers (sanitized)
|
|
309
|
+
sanitized_response_headers = self._sanitize_headers(dict(response.headers))
|
|
310
|
+
self.logger.debug(f"Response headers: {sanitized_response_headers}")
|
|
311
|
+
|
|
312
|
+
response.raise_for_status()
|
|
313
|
+
|
|
314
|
+
# Log the response if enabled
|
|
315
|
+
self._log_response(method, url, kwargs, response)
|
|
316
|
+
|
|
317
|
+
# Handle empty responses
|
|
318
|
+
if not response.text.strip():
|
|
319
|
+
return {}
|
|
320
|
+
|
|
321
|
+
return response.json()
|
|
322
|
+
|
|
323
|
+
except requests.RequestException as e:
|
|
324
|
+
response_time = time.time() - start_time
|
|
325
|
+
|
|
326
|
+
# Log failed request
|
|
327
|
+
status_code = response.status_code if response else None
|
|
328
|
+
log_api_request(
|
|
329
|
+
self.logger,
|
|
330
|
+
method=method,
|
|
331
|
+
url=url,
|
|
332
|
+
status_code=status_code,
|
|
333
|
+
response_time=response_time,
|
|
334
|
+
error=e
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Let the original exceptions bubble up unchanged for better error handling
|
|
338
|
+
raise
|
|
339
|
+
|
|
340
|
+
def _make_request_raw(self, method: str, endpoint: str, base_url: Optional[str] = None, **kwargs) -> str:
|
|
341
|
+
"""Make a request to the API and return raw text response.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
method: HTTP method (GET, POST, etc.)
|
|
345
|
+
endpoint: API endpoint path
|
|
346
|
+
base_url: Base URL to use (defaults to v2 API)
|
|
347
|
+
**kwargs: Additional arguments for requests
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Raw response text
|
|
351
|
+
|
|
352
|
+
Raises:
|
|
353
|
+
requests.RequestException: If the request fails
|
|
354
|
+
"""
|
|
355
|
+
if base_url is None:
|
|
356
|
+
base_url = self.base_url
|
|
357
|
+
url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
|
358
|
+
|
|
359
|
+
# Log request start
|
|
360
|
+
self.logger.debug(f"API request (raw): {method} {endpoint}")
|
|
361
|
+
start_time = time.time()
|
|
362
|
+
response = None
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
# Add default timeout if not specified
|
|
366
|
+
if 'timeout' not in kwargs:
|
|
367
|
+
kwargs['timeout'] = 30
|
|
368
|
+
response = self.session.request(method, url, **kwargs)
|
|
369
|
+
response_time = time.time() - start_time
|
|
370
|
+
|
|
371
|
+
# Log successful response
|
|
372
|
+
log_api_request(
|
|
373
|
+
self.logger,
|
|
374
|
+
method=method,
|
|
375
|
+
url=url,
|
|
376
|
+
status_code=response.status_code,
|
|
377
|
+
response_time=response_time
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
response.raise_for_status()
|
|
381
|
+
return response.text
|
|
382
|
+
|
|
383
|
+
except requests.RequestException as e:
|
|
384
|
+
response_time = time.time() - start_time
|
|
385
|
+
status_code = response.status_code if response else None
|
|
386
|
+
|
|
387
|
+
# Log failed request
|
|
388
|
+
log_api_request(
|
|
389
|
+
self.logger,
|
|
390
|
+
method=method,
|
|
391
|
+
url=url,
|
|
392
|
+
status_code=status_code,
|
|
393
|
+
response_time=response_time,
|
|
394
|
+
error=e
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Re-raise the original exception without modifying it
|
|
398
|
+
# This preserves the original error information for proper handling
|
|
399
|
+
# by the error handling layer which will provide user-friendly messages
|
|
400
|
+
raise
|
|
401
|
+
|
|
402
|
+
def _make_request_binary(self, method: str, endpoint: str, base_url: Optional[str] = None, **kwargs) -> bytes:
|
|
403
|
+
"""Make a request to the API and return binary response.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
method: HTTP method (GET, POST, etc.)
|
|
407
|
+
endpoint: API endpoint path
|
|
408
|
+
base_url: Base URL to use (defaults to v2 API)
|
|
409
|
+
**kwargs: Additional arguments for requests
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Binary response content
|
|
413
|
+
|
|
414
|
+
Raises:
|
|
415
|
+
requests.RequestException: If the request fails
|
|
416
|
+
"""
|
|
417
|
+
if base_url is None:
|
|
418
|
+
base_url = self.base_url
|
|
419
|
+
url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
|
420
|
+
|
|
421
|
+
# Log request start
|
|
422
|
+
self.logger.debug(f"API request (binary): {method} {endpoint}")
|
|
423
|
+
start_time = time.time()
|
|
424
|
+
response = None
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
# Add default timeout if not specified
|
|
428
|
+
if 'timeout' not in kwargs:
|
|
429
|
+
kwargs['timeout'] = 30
|
|
430
|
+
response = self.session.request(method, url, **kwargs)
|
|
431
|
+
response_time = time.time() - start_time
|
|
432
|
+
|
|
433
|
+
# Log successful response
|
|
434
|
+
log_api_request(
|
|
435
|
+
self.logger,
|
|
436
|
+
method=method,
|
|
437
|
+
url=url,
|
|
438
|
+
status_code=response.status_code,
|
|
439
|
+
response_time=response_time
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Log binary response size
|
|
443
|
+
if self.logger.isEnabledFor(logging.DEBUG):
|
|
444
|
+
content_length = len(response.content) if response.content else 0
|
|
445
|
+
self.logger.debug(f"Binary response: {response.status_code} ({response_time:.3f}s, {content_length} bytes)")
|
|
446
|
+
|
|
447
|
+
response.raise_for_status()
|
|
448
|
+
return response.content
|
|
449
|
+
|
|
450
|
+
except requests.RequestException as e:
|
|
451
|
+
response_time = time.time() - start_time
|
|
452
|
+
status_code = response.status_code if response else None
|
|
453
|
+
|
|
454
|
+
# Log failed request
|
|
455
|
+
log_api_request(
|
|
456
|
+
self.logger,
|
|
457
|
+
method=method,
|
|
458
|
+
url=url,
|
|
459
|
+
status_code=status_code,
|
|
460
|
+
response_time=response_time,
|
|
461
|
+
error=e
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Re-raise the original exception without modifying it
|
|
465
|
+
# This preserves the original error information for proper handling
|
|
466
|
+
# by the error handling layer which will provide user-friendly messages
|
|
467
|
+
raise
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Countermeasure-specific API client for IriusRisk API."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any, Optional
|
|
4
|
+
|
|
5
|
+
from .base_client import BaseApiClient
|
|
6
|
+
from ..config import Config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CountermeasureApiClient(BaseApiClient):
|
|
10
|
+
"""API client for countermeasure-specific operations."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, config: Optional[Config] = None):
|
|
13
|
+
"""Initialize the countermeasure API client.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
config: Configuration instance (creates new one if not provided)
|
|
17
|
+
"""
|
|
18
|
+
super().__init__(config)
|
|
19
|
+
|
|
20
|
+
def get_countermeasures(self,
|
|
21
|
+
project_id: str,
|
|
22
|
+
page: int = 0,
|
|
23
|
+
size: int = 20,
|
|
24
|
+
filter_expression: Optional[str] = None) -> Dict[str, Any]:
|
|
25
|
+
"""Get countermeasures for a specific project with optional filtering and pagination."""
|
|
26
|
+
# Log operation start
|
|
27
|
+
self.logger.info(f"Retrieving countermeasures for project {project_id} (page={page}, size={size})")
|
|
28
|
+
if filter_expression:
|
|
29
|
+
self.logger.debug(f"Using filter expression: {filter_expression}")
|
|
30
|
+
|
|
31
|
+
params = {
|
|
32
|
+
'page': page,
|
|
33
|
+
'size': size
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Use V2 API countermeasures query endpoint (POST with filter body)
|
|
37
|
+
filter_body = {
|
|
38
|
+
"filters": {
|
|
39
|
+
"all": {
|
|
40
|
+
"testResults": [],
|
|
41
|
+
"states": [],
|
|
42
|
+
"priorities": [],
|
|
43
|
+
"testExpiryDateStates": [],
|
|
44
|
+
"issueStates": [],
|
|
45
|
+
"owners": [],
|
|
46
|
+
"tags": [],
|
|
47
|
+
"customFieldValues": []
|
|
48
|
+
},
|
|
49
|
+
"any": {
|
|
50
|
+
"components": [],
|
|
51
|
+
"threats": [],
|
|
52
|
+
"useCases": []
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
result = self._make_request('POST', f'/projects/{project_id}/countermeasures/query', params=params, json=filter_body)
|
|
58
|
+
|
|
59
|
+
# Log results
|
|
60
|
+
if result and '_embedded' in result and 'countermeasures' in result['_embedded']:
|
|
61
|
+
countermeasure_count = len(result['_embedded']['countermeasures'])
|
|
62
|
+
total_elements = result.get('page', {}).get('totalElements', 'unknown')
|
|
63
|
+
self.logger.info(f"Retrieved {countermeasure_count} countermeasures (total: {total_elements})")
|
|
64
|
+
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
def get_countermeasure(self, project_id: str, countermeasure_id: str) -> Dict[str, Any]:
|
|
68
|
+
"""Get a specific countermeasure by ID within a project."""
|
|
69
|
+
# Use V2 API for countermeasures endpoint - individual countermeasures use direct path without project-id
|
|
70
|
+
return self._make_request('GET', f'/projects/countermeasures/{countermeasure_id}')
|
|
71
|
+
|
|
72
|
+
def update_countermeasure_state(self, countermeasure_id: str, state_transition: str, reason: Optional[str] = None, comment: Optional[str] = None) -> Dict[str, Any]:
|
|
73
|
+
"""Update the state of a countermeasure.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
countermeasure_id: Countermeasure ID (UUID)
|
|
77
|
+
state_transition: New state transition (e.g., 'required', 'recommended', 'implemented', 'rejected', 'not-applicable')
|
|
78
|
+
reason: Optional reason for the state change
|
|
79
|
+
comment: Optional detailed comment with implementation details
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Response data from the API
|
|
83
|
+
"""
|
|
84
|
+
# Log operation start
|
|
85
|
+
self.logger.info(f"Updating countermeasure {countermeasure_id} state to '{state_transition}'")
|
|
86
|
+
if reason:
|
|
87
|
+
self.logger.debug(f"Update reason: {reason}")
|
|
88
|
+
if comment:
|
|
89
|
+
self.logger.debug(f"Update comment provided (length: {len(comment)} chars)")
|
|
90
|
+
|
|
91
|
+
endpoint = f"/projects/countermeasures/{countermeasure_id}/state"
|
|
92
|
+
|
|
93
|
+
# Prepare request body - countermeasures only support stateTransition
|
|
94
|
+
body = {"stateTransition": state_transition}
|
|
95
|
+
|
|
96
|
+
# Note: Based on API testing, countermeasures don't support reason/comment fields
|
|
97
|
+
# The comments are stored in our local tracking but not sent to IriusRisk
|
|
98
|
+
# This is a limitation of the IriusRisk countermeasure API
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
result = self._make_request("PUT", endpoint, json=body)
|
|
102
|
+
self.logger.info(f"Successfully updated countermeasure {countermeasure_id} state to '{state_transition}'")
|
|
103
|
+
return result
|
|
104
|
+
except Exception as e:
|
|
105
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
106
|
+
if e.response.status_code == 404:
|
|
107
|
+
error_msg = f"Countermeasure '{countermeasure_id}' not found"
|
|
108
|
+
elif e.response.status_code == 400:
|
|
109
|
+
error_msg = f"Invalid state transition '{state_transition}' for countermeasure '{countermeasure_id}'"
|
|
110
|
+
else:
|
|
111
|
+
error_msg = f"HTTP {e.response.status_code}: {e.response.text}"
|
|
112
|
+
else:
|
|
113
|
+
error_msg = str(e)
|
|
114
|
+
raise Exception(f"Failed to update countermeasure state: {error_msg}")
|
|
115
|
+
|
|
116
|
+
def create_countermeasure_comment(self, countermeasure_id: str, comment: str) -> Dict[str, Any]:
|
|
117
|
+
"""Create a comment for a countermeasure.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
countermeasure_id: Countermeasure ID (UUID)
|
|
121
|
+
comment: Comment text (can include HTML)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Response data from the API
|
|
125
|
+
"""
|
|
126
|
+
endpoint = "/projects/countermeasures/comments"
|
|
127
|
+
|
|
128
|
+
body = {
|
|
129
|
+
"countermeasure": {
|
|
130
|
+
"id": countermeasure_id
|
|
131
|
+
},
|
|
132
|
+
"comment": comment
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
return self._make_request("POST", endpoint, json=body)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
139
|
+
if e.response.status_code == 404:
|
|
140
|
+
error_msg = f"Countermeasure '{countermeasure_id}' not found"
|
|
141
|
+
else:
|
|
142
|
+
error_msg = f"HTTP {e.response.status_code}: {e.response.text}"
|
|
143
|
+
else:
|
|
144
|
+
error_msg = str(e)
|
|
145
|
+
raise Exception(f"Failed to create countermeasure comment: {error_msg}")
|
|
146
|
+
|
|
147
|
+
def create_countermeasure_issue(self, project_id: str, countermeasure_id: str, issue_tracker_id: Optional[str] = None) -> Dict[str, Any]:
|
|
148
|
+
"""Create an issue in the configured issue tracker for a countermeasure.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
project_id: Project UUID
|
|
152
|
+
countermeasure_id: Countermeasure UUID
|
|
153
|
+
issue_tracker_id: Optional issue tracker ID to use
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Issue creation response (may be empty if successful)
|
|
157
|
+
"""
|
|
158
|
+
if issue_tracker_id:
|
|
159
|
+
# Use bulk API to specify issue tracker (async operation)
|
|
160
|
+
data = {
|
|
161
|
+
'countermeasureIds': [countermeasure_id],
|
|
162
|
+
'issueTrackerProfileId': issue_tracker_id
|
|
163
|
+
}
|
|
164
|
+
headers = {'X-Irius-Async': 'true'} # This is an async operation
|
|
165
|
+
return self._make_request('POST', f'/projects/{project_id}/countermeasures/create-issues/bulk',
|
|
166
|
+
json=data, headers=headers)
|
|
167
|
+
else:
|
|
168
|
+
# Use single countermeasure API (uses project's default issue tracker)
|
|
169
|
+
return self._make_request('POST', f'/projects/countermeasures/{countermeasure_id}/create-issue', json={})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Health API client for IriusRisk CLI."""
|
|
2
|
+
|
|
3
|
+
from .base_client import BaseApiClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HealthApiClient(BaseApiClient):
|
|
7
|
+
"""API client for health-related endpoints."""
|
|
8
|
+
|
|
9
|
+
def get_health(self):
|
|
10
|
+
"""Get health status from IriusRisk.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
dict: Health status response
|
|
14
|
+
"""
|
|
15
|
+
return self._make_request('GET', '/health')
|
|
16
|
+
|
|
17
|
+
def get_info(self):
|
|
18
|
+
"""Get instance info from IriusRisk.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
dict: Instance info response
|
|
22
|
+
"""
|
|
23
|
+
return self._make_request('GET', '/info')
|