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.
Files changed (69) hide show
  1. iriusrisk_cli/__init__.py +3 -0
  2. iriusrisk_cli/api/__init__.py +15 -0
  3. iriusrisk_cli/api/base_client.py +467 -0
  4. iriusrisk_cli/api/countermeasure_client.py +169 -0
  5. iriusrisk_cli/api/health_client.py +23 -0
  6. iriusrisk_cli/api/project_client.py +638 -0
  7. iriusrisk_cli/api/report_client.py +219 -0
  8. iriusrisk_cli/api/threat_client.py +169 -0
  9. iriusrisk_cli/api/version_client.py +235 -0
  10. iriusrisk_cli/api_client.py +181 -0
  11. iriusrisk_cli/cli_context.py +67 -0
  12. iriusrisk_cli/commands/__init__.py +1 -0
  13. iriusrisk_cli/commands/components.py +391 -0
  14. iriusrisk_cli/commands/config_cmd.py +298 -0
  15. iriusrisk_cli/commands/countermeasures.py +530 -0
  16. iriusrisk_cli/commands/init.py +183 -0
  17. iriusrisk_cli/commands/issue_trackers.py +338 -0
  18. iriusrisk_cli/commands/mcp.py +1578 -0
  19. iriusrisk_cli/commands/otm.py +296 -0
  20. iriusrisk_cli/commands/projects.py +576 -0
  21. iriusrisk_cli/commands/reports.py +202 -0
  22. iriusrisk_cli/commands/sync.py +959 -0
  23. iriusrisk_cli/commands/threats.py +509 -0
  24. iriusrisk_cli/commands/updates.py +192 -0
  25. iriusrisk_cli/commands/versions.py +341 -0
  26. iriusrisk_cli/config.py +459 -0
  27. iriusrisk_cli/container.py +190 -0
  28. iriusrisk_cli/exceptions.py +264 -0
  29. iriusrisk_cli/main.py +380 -0
  30. iriusrisk_cli/prompts/analyze_source_material.md +204 -0
  31. iriusrisk_cli/prompts/architecture_and_design_review.md +29 -0
  32. iriusrisk_cli/prompts/create_threat_model.md +643 -0
  33. iriusrisk_cli/prompts/initialize_iriusrisk_workflow.md +328 -0
  34. iriusrisk_cli/prompts/security_development_advisor.md +143 -0
  35. iriusrisk_cli/prompts/threats_and_countermeasures.md +146 -0
  36. iriusrisk_cli/repositories/__init__.py +15 -0
  37. iriusrisk_cli/repositories/base_repository.py +100 -0
  38. iriusrisk_cli/repositories/countermeasure_repository.py +399 -0
  39. iriusrisk_cli/repositories/project_repository.py +282 -0
  40. iriusrisk_cli/repositories/report_repository.py +315 -0
  41. iriusrisk_cli/repositories/threat_repository.py +359 -0
  42. iriusrisk_cli/repositories/version_repository.py +284 -0
  43. iriusrisk_cli/service_factory.py +154 -0
  44. iriusrisk_cli/services/__init__.py +4 -0
  45. iriusrisk_cli/services/countermeasure_service.py +305 -0
  46. iriusrisk_cli/services/health_service.py +34 -0
  47. iriusrisk_cli/services/project_service.py +421 -0
  48. iriusrisk_cli/services/report_service.py +245 -0
  49. iriusrisk_cli/services/threat_service.py +176 -0
  50. iriusrisk_cli/services/version_service.py +230 -0
  51. iriusrisk_cli/utils/__init__.py +1 -0
  52. iriusrisk_cli/utils/api_helpers.py +316 -0
  53. iriusrisk_cli/utils/error_handling.py +496 -0
  54. iriusrisk_cli/utils/filtering.py +185 -0
  55. iriusrisk_cli/utils/logging_config.py +461 -0
  56. iriusrisk_cli/utils/lookup.py +251 -0
  57. iriusrisk_cli/utils/mcp_logging.py +65 -0
  58. iriusrisk_cli/utils/output_formatters.py +367 -0
  59. iriusrisk_cli/utils/project.py +94 -0
  60. iriusrisk_cli/utils/project_discovery.py +140 -0
  61. iriusrisk_cli/utils/project_resolution.py +97 -0
  62. iriusrisk_cli/utils/table.py +468 -0
  63. iriusrisk_cli/utils/updates.py +307 -0
  64. iriusrisk_cli-0.1.0.dist-info/METADATA +504 -0
  65. iriusrisk_cli-0.1.0.dist-info/RECORD +69 -0
  66. iriusrisk_cli-0.1.0.dist-info/WHEEL +5 -0
  67. iriusrisk_cli-0.1.0.dist-info/entry_points.txt +2 -0
  68. iriusrisk_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  69. iriusrisk_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """IriusRisk CLI - A command line interface for IriusRisk API v2."""
2
+
3
+ __version__ = "0.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')