cloudwire 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.
- cloudwire/__init__.py +3 -0
- cloudwire/app/__init__.py +1 -0
- cloudwire/app/graph_store.py +88 -0
- cloudwire/app/main.py +453 -0
- cloudwire/app/models.py +83 -0
- cloudwire/app/scan_jobs.py +368 -0
- cloudwire/app/scanner.py +1149 -0
- cloudwire/cli.py +86 -0
- cloudwire/static/assets/index-CByMF4j6.js +40 -0
- cloudwire/static/assets/index-lik1Sxh5.css +1 -0
- cloudwire/static/index.html +13 -0
- cloudwire-0.1.0.dist-info/METADATA +186 -0
- cloudwire-0.1.0.dist-info/RECORD +16 -0
- cloudwire-0.1.0.dist-info/WHEEL +5 -0
- cloudwire-0.1.0.dist-info/entry_points.txt +2 -0
- cloudwire-0.1.0.dist-info/top_level.txt +1 -0
cloudwire/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CloudWire backend package."""
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from threading import Lock
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GraphStore:
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self.graph = nx.DiGraph()
|
|
13
|
+
self.metadata: Dict[str, Any] = {
|
|
14
|
+
"last_scan_at": None,
|
|
15
|
+
"region": None,
|
|
16
|
+
"scanned_services": [],
|
|
17
|
+
"warnings": [],
|
|
18
|
+
}
|
|
19
|
+
self._lock = Lock()
|
|
20
|
+
|
|
21
|
+
def reset(self, *, region: str, services: List[str]) -> None:
|
|
22
|
+
with self._lock:
|
|
23
|
+
self.graph = nx.DiGraph()
|
|
24
|
+
self.metadata = {
|
|
25
|
+
"last_scan_at": datetime.now(timezone.utc).isoformat(),
|
|
26
|
+
"region": region,
|
|
27
|
+
"scanned_services": services,
|
|
28
|
+
"warnings": [],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def add_warning(self, warning: str) -> None:
|
|
32
|
+
with self._lock:
|
|
33
|
+
self.metadata.setdefault("warnings", []).append(warning)
|
|
34
|
+
|
|
35
|
+
def update_metadata(self, **kwargs: Any) -> None:
|
|
36
|
+
with self._lock:
|
|
37
|
+
self.metadata.update(kwargs)
|
|
38
|
+
|
|
39
|
+
def add_node(self, node_id: str, **attrs: Any) -> None:
|
|
40
|
+
with self._lock:
|
|
41
|
+
current = self.graph.nodes[node_id] if self.graph.has_node(node_id) else {}
|
|
42
|
+
merged = {**current, **attrs}
|
|
43
|
+
merged["id"] = node_id
|
|
44
|
+
self.graph.add_node(node_id, **merged)
|
|
45
|
+
|
|
46
|
+
def add_edge(self, source: str, target: str, **attrs: Any) -> None:
|
|
47
|
+
with self._lock:
|
|
48
|
+
current = self.graph.get_edge_data(source, target, default={})
|
|
49
|
+
merged = {**current, **attrs}
|
|
50
|
+
self.graph.add_edge(source, target, **merged)
|
|
51
|
+
|
|
52
|
+
def _serialize_node(self, node_id: str, attrs: Dict[str, Any]) -> Dict[str, Any]:
|
|
53
|
+
payload = {"id": node_id}
|
|
54
|
+
payload.update(attrs)
|
|
55
|
+
return payload
|
|
56
|
+
|
|
57
|
+
def _serialize_edge(self, source: str, target: str, attrs: Dict[str, Any]) -> Dict[str, Any]:
|
|
58
|
+
payload = {"id": f"{source}__{target}", "source": source, "target": target}
|
|
59
|
+
payload.update(attrs)
|
|
60
|
+
return payload
|
|
61
|
+
|
|
62
|
+
def get_graph_payload(self) -> Dict[str, Any]:
|
|
63
|
+
with self._lock:
|
|
64
|
+
nodes = [self._serialize_node(node_id, attrs) for node_id, attrs in self.graph.nodes(data=True)]
|
|
65
|
+
edges = [
|
|
66
|
+
self._serialize_edge(source, target, attrs)
|
|
67
|
+
for source, target, attrs in self.graph.edges(data=True)
|
|
68
|
+
]
|
|
69
|
+
metadata = dict(self.metadata)
|
|
70
|
+
metadata["node_count"] = len(nodes)
|
|
71
|
+
metadata["edge_count"] = len(edges)
|
|
72
|
+
return {"nodes": nodes, "edges": edges, "metadata": metadata}
|
|
73
|
+
|
|
74
|
+
def get_resource_payload(self, resource_id: str) -> Dict[str, Any]:
|
|
75
|
+
with self._lock:
|
|
76
|
+
if not self.graph.has_node(resource_id):
|
|
77
|
+
raise KeyError(resource_id)
|
|
78
|
+
|
|
79
|
+
node = self._serialize_node(resource_id, dict(self.graph.nodes[resource_id]))
|
|
80
|
+
incoming = [
|
|
81
|
+
self._serialize_edge(source, resource_id, dict(attrs))
|
|
82
|
+
for source, _, attrs in self.graph.in_edges(resource_id, data=True)
|
|
83
|
+
]
|
|
84
|
+
outgoing = [
|
|
85
|
+
self._serialize_edge(resource_id, target, dict(attrs))
|
|
86
|
+
for _, target, attrs in self.graph.out_edges(resource_id, data=True)
|
|
87
|
+
]
|
|
88
|
+
return {"node": node, "incoming": incoming, "outgoing": outgoing}
|
cloudwire/app/main.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
from botocore.config import Config
|
|
10
|
+
from botocore.exceptions import (
|
|
11
|
+
BotoCoreError,
|
|
12
|
+
ClientError,
|
|
13
|
+
ConnectTimeoutError,
|
|
14
|
+
CredentialRetrievalError,
|
|
15
|
+
EndpointConnectionError,
|
|
16
|
+
NoCredentialsError,
|
|
17
|
+
PartialCredentialsError,
|
|
18
|
+
ReadTimeoutError,
|
|
19
|
+
)
|
|
20
|
+
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, status
|
|
21
|
+
from fastapi.exceptions import RequestValidationError
|
|
22
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
23
|
+
from fastapi.staticfiles import StaticFiles
|
|
24
|
+
|
|
25
|
+
from .models import (
|
|
26
|
+
APIErrorResponse,
|
|
27
|
+
GraphResponse,
|
|
28
|
+
ResourceResponse,
|
|
29
|
+
ScanJobCreateResponse,
|
|
30
|
+
ScanJobStatusResponse,
|
|
31
|
+
ScanRequest,
|
|
32
|
+
)
|
|
33
|
+
from .scan_jobs import ScanJobStore
|
|
34
|
+
from .scanner import AWSGraphScanner, ScanCancelledError, ScanExecutionOptions
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Static-file directory (cloudwire/static/ relative to this package)
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
_STATIC_DIR = Path(__file__).parent.parent / "static"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class APIError(Exception):
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
status_code: int,
|
|
49
|
+
code: str,
|
|
50
|
+
message: str,
|
|
51
|
+
details: Optional[Any] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.status_code = status_code
|
|
55
|
+
self.code = code
|
|
56
|
+
self.message = message
|
|
57
|
+
self.details = details
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _error_payload(code: str, message: str, details: Optional[Any] = None) -> Dict[str, Any]:
|
|
61
|
+
return {
|
|
62
|
+
"error": {
|
|
63
|
+
"code": code,
|
|
64
|
+
"message": message,
|
|
65
|
+
"details": details,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_services(services: List[str]) -> List[str]:
|
|
71
|
+
aliases = {
|
|
72
|
+
"api-gateway": "apigateway",
|
|
73
|
+
"apigw": "apigateway",
|
|
74
|
+
"event-bridge": "eventbridge",
|
|
75
|
+
"events": "eventbridge",
|
|
76
|
+
}
|
|
77
|
+
normalized = []
|
|
78
|
+
for service in services:
|
|
79
|
+
key = aliases.get(service.lower().strip(), service.lower().strip())
|
|
80
|
+
if key and key not in normalized:
|
|
81
|
+
normalized.append(key)
|
|
82
|
+
return normalized
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_option(value: Optional[bool], default: bool) -> bool:
|
|
86
|
+
return default if value is None else value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_scan_options(payload: ScanRequest) -> ScanExecutionOptions:
|
|
90
|
+
default_iam = payload.mode == "deep"
|
|
91
|
+
default_describes = payload.mode == "deep"
|
|
92
|
+
return ScanExecutionOptions(
|
|
93
|
+
mode=payload.mode,
|
|
94
|
+
include_iam_inference=_resolve_option(payload.include_iam_inference, default_iam),
|
|
95
|
+
include_resource_describes=_resolve_option(payload.include_resource_describes, default_describes),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cache_ttl_seconds(mode: str) -> int:
|
|
100
|
+
return 300 if mode == "quick" else 1800
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _friendly_exception_message(exc: Exception) -> str:
|
|
104
|
+
if isinstance(exc, (NoCredentialsError, PartialCredentialsError, CredentialRetrievalError)):
|
|
105
|
+
return "AWS credentials were not found. Set AWS credentials or run saml2aws login before scanning."
|
|
106
|
+
if isinstance(exc, (EndpointConnectionError, ConnectTimeoutError, ReadTimeoutError)):
|
|
107
|
+
return "Unable to reach the AWS API endpoint for the selected region."
|
|
108
|
+
if isinstance(exc, ClientError):
|
|
109
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
110
|
+
if code in {"ExpiredToken", "ExpiredTokenException", "RequestExpired"}:
|
|
111
|
+
return "Your AWS session has expired. Refresh credentials and try again."
|
|
112
|
+
if code in {"AccessDenied", "AccessDeniedException", "UnauthorizedOperation"}:
|
|
113
|
+
return "AWS access was denied for this operation. Verify the assumed role permissions."
|
|
114
|
+
message = exc.response.get("Error", {}).get("Message")
|
|
115
|
+
return message or f"AWS API request failed with {code or 'ClientError'}."
|
|
116
|
+
if isinstance(exc, BotoCoreError):
|
|
117
|
+
return "The AWS SDK failed to complete the request."
|
|
118
|
+
return str(exc) or "Unexpected server error."
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_account_id(region: str) -> str:
|
|
122
|
+
session = boto3.session.Session(region_name=region)
|
|
123
|
+
client = session.client(
|
|
124
|
+
"sts",
|
|
125
|
+
config=Config(
|
|
126
|
+
retries={"mode": "adaptive", "max_attempts": 10},
|
|
127
|
+
max_pool_connections=8,
|
|
128
|
+
connect_timeout=3,
|
|
129
|
+
read_timeout=10,
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
identity = client.get_caller_identity()
|
|
134
|
+
return str(identity.get("Account", "unknown"))
|
|
135
|
+
except (NoCredentialsError, PartialCredentialsError, CredentialRetrievalError) as exc:
|
|
136
|
+
raise APIError(
|
|
137
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
138
|
+
code="aws_credentials_missing",
|
|
139
|
+
message=_friendly_exception_message(exc),
|
|
140
|
+
) from exc
|
|
141
|
+
except ClientError as exc:
|
|
142
|
+
aws_code = exc.response.get("Error", {}).get("Code", "")
|
|
143
|
+
status_code = (
|
|
144
|
+
status.HTTP_403_FORBIDDEN
|
|
145
|
+
if aws_code in {"AccessDenied", "AccessDeniedException", "UnauthorizedOperation"}
|
|
146
|
+
else status.HTTP_401_UNAUTHORIZED
|
|
147
|
+
if aws_code in {"ExpiredToken", "ExpiredTokenException", "RequestExpired"}
|
|
148
|
+
else status.HTTP_502_BAD_GATEWAY
|
|
149
|
+
)
|
|
150
|
+
raise APIError(
|
|
151
|
+
status_code=status_code,
|
|
152
|
+
code="aws_account_lookup_failed",
|
|
153
|
+
message=_friendly_exception_message(exc),
|
|
154
|
+
details={"aws_error_code": aws_code or None, "region": region},
|
|
155
|
+
) from exc
|
|
156
|
+
except (EndpointConnectionError, ConnectTimeoutError, ReadTimeoutError) as exc:
|
|
157
|
+
raise APIError(
|
|
158
|
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
159
|
+
code="aws_endpoint_unreachable",
|
|
160
|
+
message=_friendly_exception_message(exc),
|
|
161
|
+
details={"region": region},
|
|
162
|
+
) from exc
|
|
163
|
+
except BotoCoreError as exc:
|
|
164
|
+
raise APIError(
|
|
165
|
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
166
|
+
code="aws_client_error",
|
|
167
|
+
message=_friendly_exception_message(exc),
|
|
168
|
+
details={"region": region},
|
|
169
|
+
) from exc
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
job_store = ScanJobStore(max_workers=4)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@asynccontextmanager
|
|
176
|
+
async def lifespan(_app: FastAPI):
|
|
177
|
+
yield
|
|
178
|
+
job_store.shutdown()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
app = FastAPI(title="CloudWire API", version="0.1.0", lifespan=lifespan)
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Exception handlers
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
@app.exception_handler(APIError)
|
|
188
|
+
async def api_error_handler(_: Request, exc: APIError) -> JSONResponse:
|
|
189
|
+
return JSONResponse(
|
|
190
|
+
status_code=exc.status_code,
|
|
191
|
+
content=_error_payload(exc.code, exc.message, exc.details),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@app.exception_handler(HTTPException)
|
|
196
|
+
async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse:
|
|
197
|
+
detail = exc.detail
|
|
198
|
+
if isinstance(detail, dict) and "error" in detail:
|
|
199
|
+
payload = detail
|
|
200
|
+
elif isinstance(detail, str):
|
|
201
|
+
payload = _error_payload("http_error", detail)
|
|
202
|
+
else:
|
|
203
|
+
payload = _error_payload("http_error", "Request failed.", detail)
|
|
204
|
+
return JSONResponse(status_code=exc.status_code, content=payload)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.exception_handler(RequestValidationError)
|
|
208
|
+
async def validation_exception_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
|
|
209
|
+
return JSONResponse(
|
|
210
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
211
|
+
content=_error_payload(
|
|
212
|
+
"validation_error",
|
|
213
|
+
"Request validation failed.",
|
|
214
|
+
exc.errors(),
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.exception_handler(Exception)
|
|
220
|
+
async def unexpected_exception_handler(_: Request, exc: Exception) -> JSONResponse:
|
|
221
|
+
logger.exception("Unhandled API exception", exc_info=exc)
|
|
222
|
+
return JSONResponse(
|
|
223
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
224
|
+
content=_error_payload("internal_error", "Unexpected server error."),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Scan runner (background thread)
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _run_scan_job(
|
|
233
|
+
*,
|
|
234
|
+
job_id: str,
|
|
235
|
+
region: str,
|
|
236
|
+
services: List[str],
|
|
237
|
+
account_id: str,
|
|
238
|
+
options: ScanExecutionOptions,
|
|
239
|
+
) -> None:
|
|
240
|
+
job_store.mark_running(job_id)
|
|
241
|
+
if job_store.is_cancel_requested(job_id):
|
|
242
|
+
job_store.mark_cancelled(job_id)
|
|
243
|
+
return
|
|
244
|
+
job = job_store.get_job(job_id)
|
|
245
|
+
scanner = AWSGraphScanner(job.graph_store, options=options)
|
|
246
|
+
|
|
247
|
+
def on_progress(event: str, service: str, services_done: int, services_total: int) -> None:
|
|
248
|
+
job_store.update_progress(
|
|
249
|
+
job_id,
|
|
250
|
+
event=event,
|
|
251
|
+
current_service=service,
|
|
252
|
+
services_done=services_done,
|
|
253
|
+
services_total=services_total,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
scanner.scan(
|
|
258
|
+
region=region,
|
|
259
|
+
services=services,
|
|
260
|
+
account_id=account_id,
|
|
261
|
+
progress_callback=on_progress,
|
|
262
|
+
should_cancel=lambda: job_store.is_cancel_requested(job_id),
|
|
263
|
+
)
|
|
264
|
+
if job_store.is_cancel_requested(job_id):
|
|
265
|
+
job_store.mark_cancelled(job_id)
|
|
266
|
+
return
|
|
267
|
+
job_store.mark_completed(job_id, ttl_seconds=_cache_ttl_seconds(options.mode))
|
|
268
|
+
except ScanCancelledError:
|
|
269
|
+
job.graph_store.add_warning("Scan cancelled by user request.")
|
|
270
|
+
job_store.mark_cancelled(job_id)
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
logger.exception("Scan job %s failed with unhandled exception", job_id)
|
|
273
|
+
message = _friendly_exception_message(exc)
|
|
274
|
+
job.graph_store.add_warning(f"scan failed: {message}")
|
|
275
|
+
job_store.mark_failed(job_id, message)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# API routes (all under /api prefix)
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
api = APIRouter(prefix="/api")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@api.get("/health")
|
|
286
|
+
def health() -> Dict[str, Any]:
|
|
287
|
+
return {"service": "cloudwire", "status": "ok"}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@api.get("/graph", response_model=GraphResponse, responses={500: {"model": APIErrorResponse}})
|
|
291
|
+
def get_graph() -> Dict[str, Any]:
|
|
292
|
+
return job_store.get_latest_graph_payload()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@api.get(
|
|
296
|
+
"/resource/{resource_id}",
|
|
297
|
+
response_model=ResourceResponse,
|
|
298
|
+
responses={404: {"model": APIErrorResponse}, 500: {"model": APIErrorResponse}},
|
|
299
|
+
)
|
|
300
|
+
def get_resource(resource_id: str, job_id: Optional[str] = Query(default=None)) -> Dict[str, Any]:
|
|
301
|
+
try:
|
|
302
|
+
return job_store.get_resource_payload(resource_id, job_id=job_id)
|
|
303
|
+
except KeyError as exc:
|
|
304
|
+
raise APIError(
|
|
305
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
306
|
+
code="resource_not_found",
|
|
307
|
+
message=f"Resource '{resource_id}' was not found in the selected graph.",
|
|
308
|
+
details={"resource_id": resource_id, "job_id": job_id},
|
|
309
|
+
) from exc
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@api.post(
|
|
313
|
+
"/scan",
|
|
314
|
+
response_model=ScanJobCreateResponse,
|
|
315
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
316
|
+
responses={
|
|
317
|
+
401: {"model": APIErrorResponse},
|
|
318
|
+
403: {"model": APIErrorResponse},
|
|
319
|
+
422: {"model": APIErrorResponse},
|
|
320
|
+
502: {"model": APIErrorResponse},
|
|
321
|
+
500: {"model": APIErrorResponse},
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
def create_scan_job(payload: ScanRequest) -> Dict[str, Any]:
|
|
325
|
+
services = _normalize_services(payload.services)
|
|
326
|
+
options = _resolve_scan_options(payload)
|
|
327
|
+
account_id = _resolve_account_id(payload.region)
|
|
328
|
+
|
|
329
|
+
cache_key = ScanJobStore.build_cache_key(
|
|
330
|
+
account_id=account_id,
|
|
331
|
+
region=payload.region,
|
|
332
|
+
services=services,
|
|
333
|
+
mode=options.mode,
|
|
334
|
+
include_iam_inference=options.include_iam_inference,
|
|
335
|
+
include_resource_describes=options.include_resource_describes,
|
|
336
|
+
)
|
|
337
|
+
reusable_job_id, cached = job_store.find_reusable_job(
|
|
338
|
+
cache_key=cache_key,
|
|
339
|
+
force_refresh=payload.force_refresh,
|
|
340
|
+
)
|
|
341
|
+
if reusable_job_id:
|
|
342
|
+
status_payload = job_store.get_status_payload(reusable_job_id)
|
|
343
|
+
return {
|
|
344
|
+
"job_id": reusable_job_id,
|
|
345
|
+
"status": status_payload["status"],
|
|
346
|
+
"cached": cached,
|
|
347
|
+
"status_url": f"/api/scan/{reusable_job_id}",
|
|
348
|
+
"graph_url": f"/api/scan/{reusable_job_id}/graph",
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
job = job_store.create_job(
|
|
352
|
+
cache_key=cache_key,
|
|
353
|
+
account_id=account_id,
|
|
354
|
+
region=payload.region,
|
|
355
|
+
services=services,
|
|
356
|
+
mode=options.mode,
|
|
357
|
+
include_iam_inference=options.include_iam_inference,
|
|
358
|
+
include_resource_describes=options.include_resource_describes,
|
|
359
|
+
)
|
|
360
|
+
job_store.submit_job(
|
|
361
|
+
job.id,
|
|
362
|
+
lambda: _run_scan_job(
|
|
363
|
+
job_id=job.id,
|
|
364
|
+
region=payload.region,
|
|
365
|
+
services=services,
|
|
366
|
+
account_id=account_id,
|
|
367
|
+
options=options,
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
return {
|
|
371
|
+
"job_id": job.id,
|
|
372
|
+
"status": job.status,
|
|
373
|
+
"cached": False,
|
|
374
|
+
"status_url": f"/api/scan/{job.id}",
|
|
375
|
+
"graph_url": f"/api/scan/{job.id}/graph",
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@api.get(
|
|
380
|
+
"/scan/{job_id}",
|
|
381
|
+
response_model=ScanJobStatusResponse,
|
|
382
|
+
responses={404: {"model": APIErrorResponse}, 500: {"model": APIErrorResponse}},
|
|
383
|
+
)
|
|
384
|
+
def get_scan_job(job_id: str) -> Dict[str, Any]:
|
|
385
|
+
try:
|
|
386
|
+
return job_store.get_status_payload(job_id)
|
|
387
|
+
except KeyError as exc:
|
|
388
|
+
raise APIError(
|
|
389
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
390
|
+
code="job_not_found",
|
|
391
|
+
message=f"Scan job '{job_id}' was not found.",
|
|
392
|
+
details={"job_id": job_id},
|
|
393
|
+
) from exc
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@api.get(
|
|
397
|
+
"/scan/{job_id}/graph",
|
|
398
|
+
response_model=GraphResponse,
|
|
399
|
+
responses={404: {"model": APIErrorResponse}, 500: {"model": APIErrorResponse}},
|
|
400
|
+
)
|
|
401
|
+
def get_scan_job_graph(job_id: str) -> Dict[str, Any]:
|
|
402
|
+
try:
|
|
403
|
+
return job_store.get_graph_payload(job_id)
|
|
404
|
+
except KeyError as exc:
|
|
405
|
+
raise APIError(
|
|
406
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
407
|
+
code="job_not_found",
|
|
408
|
+
message=f"Scan job '{job_id}' was not found.",
|
|
409
|
+
details={"job_id": job_id},
|
|
410
|
+
) from exc
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@api.post(
|
|
414
|
+
"/scan/{job_id}/stop",
|
|
415
|
+
response_model=ScanJobStatusResponse,
|
|
416
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
417
|
+
responses={404: {"model": APIErrorResponse}, 500: {"model": APIErrorResponse}},
|
|
418
|
+
)
|
|
419
|
+
def stop_scan_job(job_id: str) -> Dict[str, Any]:
|
|
420
|
+
try:
|
|
421
|
+
job_store.request_cancel(job_id)
|
|
422
|
+
return job_store.get_status_payload(job_id)
|
|
423
|
+
except KeyError as exc:
|
|
424
|
+
raise APIError(
|
|
425
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
426
|
+
code="job_not_found",
|
|
427
|
+
message=f"Scan job '{job_id}' was not found.",
|
|
428
|
+
details={"job_id": job_id},
|
|
429
|
+
) from exc
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
app.include_router(api)
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
# Static file serving — must be registered AFTER all API routes
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
if _STATIC_DIR.is_dir() and ((_STATIC_DIR / "assets").is_dir()):
|
|
439
|
+
app.mount("/assets", StaticFiles(directory=str(_STATIC_DIR / "assets")), name="assets")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
443
|
+
def spa_fallback(full_path: str) -> FileResponse:
|
|
444
|
+
index = _STATIC_DIR / "index.html"
|
|
445
|
+
if not index.is_file():
|
|
446
|
+
return JSONResponse(
|
|
447
|
+
status_code=503,
|
|
448
|
+
content=_error_payload(
|
|
449
|
+
"frontend_not_built",
|
|
450
|
+
"Frontend assets not found. Run `make build` to compile the UI.",
|
|
451
|
+
),
|
|
452
|
+
)
|
|
453
|
+
return FileResponse(str(index))
|
cloudwire/app/models.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, field_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
DEFAULT_SERVICES = ["apigateway", "lambda", "sqs", "eventbridge", "dynamodb"]
|
|
7
|
+
ScanMode = Literal["quick", "deep"]
|
|
8
|
+
JobStatus = Literal["queued", "running", "completed", "failed", "cancelled"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ScanRequest(BaseModel):
|
|
12
|
+
region: str = "us-east-1"
|
|
13
|
+
services: List[str] = Field(default_factory=lambda: DEFAULT_SERVICES.copy())
|
|
14
|
+
mode: ScanMode = "quick"
|
|
15
|
+
force_refresh: bool = False
|
|
16
|
+
include_iam_inference: Optional[bool] = None
|
|
17
|
+
include_resource_describes: Optional[bool] = None
|
|
18
|
+
|
|
19
|
+
@field_validator("region")
|
|
20
|
+
@classmethod
|
|
21
|
+
def validate_region(cls, value: str) -> str:
|
|
22
|
+
cleaned = value.strip()
|
|
23
|
+
if not cleaned:
|
|
24
|
+
raise ValueError("region must not be empty")
|
|
25
|
+
return cleaned
|
|
26
|
+
|
|
27
|
+
@field_validator("services")
|
|
28
|
+
@classmethod
|
|
29
|
+
def validate_services(cls, value: List[str]) -> List[str]:
|
|
30
|
+
cleaned = [service.strip() for service in value if service and service.strip()]
|
|
31
|
+
if not cleaned:
|
|
32
|
+
raise ValueError("at least one AWS service must be selected")
|
|
33
|
+
return cleaned
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GraphResponse(BaseModel):
|
|
37
|
+
nodes: List[Dict[str, Any]]
|
|
38
|
+
edges: List[Dict[str, Any]]
|
|
39
|
+
metadata: Dict[str, Any]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ResourceResponse(BaseModel):
|
|
43
|
+
node: Dict[str, Any]
|
|
44
|
+
incoming: List[Dict[str, Any]]
|
|
45
|
+
outgoing: List[Dict[str, Any]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ScanJobCreateResponse(BaseModel):
|
|
49
|
+
job_id: str
|
|
50
|
+
status: JobStatus
|
|
51
|
+
cached: bool
|
|
52
|
+
status_url: str
|
|
53
|
+
graph_url: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ScanJobStatusResponse(BaseModel):
|
|
57
|
+
job_id: str
|
|
58
|
+
status: JobStatus
|
|
59
|
+
cancellation_requested: bool = False
|
|
60
|
+
mode: ScanMode
|
|
61
|
+
region: str
|
|
62
|
+
services: List[str]
|
|
63
|
+
progress_percent: int
|
|
64
|
+
current_service: Optional[str] = None
|
|
65
|
+
services_done: int
|
|
66
|
+
services_total: int
|
|
67
|
+
node_count: int
|
|
68
|
+
edge_count: int
|
|
69
|
+
warnings: List[str]
|
|
70
|
+
created_at: str
|
|
71
|
+
started_at: Optional[str] = None
|
|
72
|
+
finished_at: Optional[str] = None
|
|
73
|
+
error: Optional[str] = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class APIErrorDetail(BaseModel):
|
|
77
|
+
code: str
|
|
78
|
+
message: str
|
|
79
|
+
details: Optional[Any] = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class APIErrorResponse(BaseModel):
|
|
83
|
+
error: APIErrorDetail
|