cyntrisec 0.1.7__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 (65) hide show
  1. cyntrisec/__init__.py +3 -0
  2. cyntrisec/__main__.py +6 -0
  3. cyntrisec/aws/__init__.py +6 -0
  4. cyntrisec/aws/collectors/__init__.py +17 -0
  5. cyntrisec/aws/collectors/ec2.py +30 -0
  6. cyntrisec/aws/collectors/iam.py +116 -0
  7. cyntrisec/aws/collectors/lambda_.py +45 -0
  8. cyntrisec/aws/collectors/network.py +70 -0
  9. cyntrisec/aws/collectors/rds.py +38 -0
  10. cyntrisec/aws/collectors/s3.py +68 -0
  11. cyntrisec/aws/collectors/usage.py +188 -0
  12. cyntrisec/aws/credentials.py +153 -0
  13. cyntrisec/aws/normalizers/__init__.py +17 -0
  14. cyntrisec/aws/normalizers/ec2.py +115 -0
  15. cyntrisec/aws/normalizers/iam.py +182 -0
  16. cyntrisec/aws/normalizers/lambda_.py +83 -0
  17. cyntrisec/aws/normalizers/network.py +225 -0
  18. cyntrisec/aws/normalizers/rds.py +130 -0
  19. cyntrisec/aws/normalizers/s3.py +184 -0
  20. cyntrisec/aws/relationship_builder.py +1359 -0
  21. cyntrisec/aws/scanner.py +303 -0
  22. cyntrisec/cli/__init__.py +5 -0
  23. cyntrisec/cli/analyze.py +747 -0
  24. cyntrisec/cli/ask.py +412 -0
  25. cyntrisec/cli/can.py +307 -0
  26. cyntrisec/cli/comply.py +226 -0
  27. cyntrisec/cli/cuts.py +231 -0
  28. cyntrisec/cli/diff.py +332 -0
  29. cyntrisec/cli/errors.py +105 -0
  30. cyntrisec/cli/explain.py +348 -0
  31. cyntrisec/cli/main.py +114 -0
  32. cyntrisec/cli/manifest.py +893 -0
  33. cyntrisec/cli/output.py +117 -0
  34. cyntrisec/cli/remediate.py +643 -0
  35. cyntrisec/cli/report.py +462 -0
  36. cyntrisec/cli/scan.py +207 -0
  37. cyntrisec/cli/schemas.py +391 -0
  38. cyntrisec/cli/serve.py +164 -0
  39. cyntrisec/cli/setup.py +260 -0
  40. cyntrisec/cli/validate.py +101 -0
  41. cyntrisec/cli/waste.py +323 -0
  42. cyntrisec/core/__init__.py +31 -0
  43. cyntrisec/core/business_config.py +110 -0
  44. cyntrisec/core/business_logic.py +131 -0
  45. cyntrisec/core/compliance.py +437 -0
  46. cyntrisec/core/cost_estimator.py +301 -0
  47. cyntrisec/core/cuts.py +360 -0
  48. cyntrisec/core/diff.py +361 -0
  49. cyntrisec/core/graph.py +202 -0
  50. cyntrisec/core/paths.py +830 -0
  51. cyntrisec/core/schema.py +317 -0
  52. cyntrisec/core/simulator.py +371 -0
  53. cyntrisec/core/waste.py +309 -0
  54. cyntrisec/mcp/__init__.py +5 -0
  55. cyntrisec/mcp/server.py +862 -0
  56. cyntrisec/storage/__init__.py +7 -0
  57. cyntrisec/storage/filesystem.py +344 -0
  58. cyntrisec/storage/memory.py +113 -0
  59. cyntrisec/storage/protocol.py +92 -0
  60. cyntrisec-0.1.7.dist-info/METADATA +672 -0
  61. cyntrisec-0.1.7.dist-info/RECORD +65 -0
  62. cyntrisec-0.1.7.dist-info/WHEEL +4 -0
  63. cyntrisec-0.1.7.dist-info/entry_points.txt +2 -0
  64. cyntrisec-0.1.7.dist-info/licenses/LICENSE +190 -0
  65. cyntrisec-0.1.7.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,7 @@
1
+ """Storage backends for scan results."""
2
+
3
+ from cyntrisec.storage.filesystem import FileSystemStorage
4
+ from cyntrisec.storage.memory import InMemoryStorage
5
+ from cyntrisec.storage.protocol import StorageBackend
6
+
7
+ __all__ = ["StorageBackend", "FileSystemStorage", "InMemoryStorage"]
@@ -0,0 +1,344 @@
1
+ """
2
+ Filesystem Storage - Persist scan results to JSON files.
3
+
4
+ Directory structure:
5
+ ~/.cyntrisec/scans/
6
+ ├── 2026-01-16_123456_123456789012/
7
+ │ ├── snapshot.json
8
+ │ ├── assets.json
9
+ │ ├── relationships.json
10
+ │ ├── findings.json
11
+ │ └── attack_paths.json
12
+ └── latest -> 2026-01-16_123456_123456789012
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+
22
+ from cyntrisec.core.schema import (
23
+ Asset,
24
+ AttackPath,
25
+ Finding,
26
+ Relationship,
27
+ Snapshot,
28
+ )
29
+ from cyntrisec.storage.protocol import StorageBackend
30
+
31
+
32
+ class FileSystemStorage(StorageBackend):
33
+ """
34
+ Persist scan results to JSON files.
35
+
36
+ Default location: ~/.cyntrisec/scans/
37
+ Each scan gets a timestamped directory.
38
+ A 'latest' symlink points to the most recent scan.
39
+ """
40
+
41
+ def __init__(self, base_dir: Path | str | None = None):
42
+ home_dir = Path(os.environ.get("HOME") or os.environ.get("USERPROFILE") or Path.home())
43
+ if base_dir is None:
44
+ self._base = home_dir / ".cyntrisec" / "scans"
45
+ else:
46
+ self._base = Path(base_dir) # Convert string to Path if needed
47
+ self._base.mkdir(parents=True, exist_ok=True)
48
+ self._current_dir: Path | None = None
49
+ self._current_id: str | None = None
50
+
51
+ def new_scan(self, account_id: str) -> str:
52
+ """Create a new scan directory."""
53
+ timestamp = datetime.utcnow().strftime("%Y-%m-%d_%H%M%S")
54
+ scan_id = f"{timestamp}_{account_id}"
55
+ self._validate_scan_id(scan_id)
56
+ self._current_id = scan_id
57
+ self._current_dir = self._base / scan_id
58
+ self._current_dir.mkdir(parents=True, exist_ok=True)
59
+
60
+ # Update 'latest' symlink
61
+ latest_link = self._base / "latest"
62
+ if latest_link.is_symlink():
63
+ latest_link.unlink()
64
+ elif latest_link.exists():
65
+ # It's a file or directory, remove it
66
+ if latest_link.is_dir():
67
+ import shutil
68
+
69
+ shutil.rmtree(latest_link)
70
+ else:
71
+ latest_link.unlink()
72
+
73
+ # Create symlink (Windows needs special handling)
74
+ try:
75
+ latest_link.symlink_to(self._current_dir.name)
76
+ except OSError:
77
+ # On Windows without dev mode, just write the name to a file
78
+ latest_link.write_text(self._current_dir.name, encoding="utf-8")
79
+
80
+ return scan_id
81
+
82
+ def _validate_scan_id(self, scan_id: str) -> str:
83
+ """
84
+ Validate that scan_id is a safe directory name.
85
+ Reject: empty, .., path separators.
86
+ """
87
+ scan_id = (scan_id or "").strip()
88
+ if not scan_id:
89
+ raise ValueError("Invalid scan id: empty")
90
+ if scan_id in {".", "..", "latest"}:
91
+ raise ValueError(f"Invalid scan id: {scan_id}")
92
+ if any(ord(ch) < 32 or ch == "\x7f" for ch in scan_id):
93
+ raise ValueError(f"Invalid scan id: {scan_id}")
94
+ if len(scan_id) > 200:
95
+ raise ValueError(f"Invalid scan id: too long ({len(scan_id)})")
96
+ if "\x00" in scan_id or any(x in scan_id for x in ("..", "/", "\\", ":")):
97
+ raise ValueError(f"Invalid scan id: {scan_id}")
98
+ return scan_id
99
+
100
+ def _safe_join_scan_dir(self, scan_id: str) -> Path:
101
+ """
102
+ Safely resolve scan directory ensuring it stays within base.
103
+ """
104
+ self._validate_scan_id(scan_id)
105
+ base = self._base.resolve()
106
+ # Resolve the candidate path
107
+ # Note: on Windows resolving a non-existent path might be tricky if we don't catch errors,
108
+ # but here we generally expect to create or read it.
109
+ # We construct it simply first.
110
+ candidate = (base / scan_id).resolve()
111
+
112
+ # Security check: must be inside base
113
+ # python 3.9+ has is_relative_to
114
+ if not candidate.is_relative_to(base) or candidate == base:
115
+ raise ValueError(f"Scan dir escapes base dir: {scan_id}")
116
+
117
+ return candidate
118
+
119
+ def _get_scan_dir(self, scan_id: str | None = None) -> Path:
120
+ """Get the directory for a scan ID."""
121
+ if scan_id:
122
+ return self._safe_join_scan_dir(scan_id)
123
+
124
+ if self._current_dir:
125
+ return self._current_dir
126
+
127
+ # Try to get latest
128
+ latest_link = self._base / "latest"
129
+ target_id = None
130
+
131
+ if latest_link.is_symlink():
132
+ target_id = os.readlink(latest_link)
133
+ elif latest_link.exists() and latest_link.is_file():
134
+ # Windows fallback: file contains directory name
135
+ target_id = latest_link.read_text().strip()
136
+
137
+ if target_id:
138
+ return self._safe_join_scan_dir(target_id)
139
+
140
+ raise ValueError("No scan specified and no latest scan found")
141
+
142
+ def _write_json(self, path: Path, data: any) -> None:
143
+ """Write data to JSON file."""
144
+ with open(path, "w", encoding="utf-8") as f:
145
+ json.dump(data, f, indent=2, default=str)
146
+
147
+ def _read_json(self, path: Path) -> any:
148
+ """Read data from JSON file."""
149
+ if not path.exists():
150
+ return None
151
+ with open(path, encoding="utf-8") as f:
152
+ return json.load(f)
153
+
154
+ def save_snapshot(self, snapshot: Snapshot) -> None:
155
+ scan_dir = self._get_scan_dir()
156
+ self._write_json(scan_dir / "snapshot.json", snapshot.model_dump(mode="json"))
157
+
158
+ def save_assets(self, assets: list[Asset]) -> None:
159
+ scan_dir = self._get_scan_dir()
160
+ # Sort by id for deterministic output
161
+ sorted_assets = sorted(assets, key=lambda a: str(a.id))
162
+ data = [a.model_dump(mode="json") for a in sorted_assets]
163
+ self._write_json(scan_dir / "assets.json", data)
164
+
165
+ def save_relationships(self, relationships: list[Relationship]) -> None:
166
+ scan_dir = self._get_scan_dir()
167
+ # Sort by id for deterministic output
168
+ sorted_rels = sorted(relationships, key=lambda r: str(r.id))
169
+ data = [r.model_dump(mode="json") for r in sorted_rels]
170
+ self._write_json(scan_dir / "relationships.json", data)
171
+
172
+ def save_findings(self, findings: list[Finding]) -> None:
173
+ scan_dir = self._get_scan_dir()
174
+ # Sort by id for deterministic output
175
+ sorted_findings = sorted(findings, key=lambda f: str(f.id))
176
+ data = [f.model_dump(mode="json") for f in sorted_findings]
177
+ self._write_json(scan_dir / "findings.json", data)
178
+
179
+ def save_attack_paths(self, paths: list[AttackPath]) -> None:
180
+ scan_dir = self._get_scan_dir()
181
+ # Sort by risk_score (desc), then id for deterministic output
182
+ sorted_paths = sorted(paths, key=lambda p: (-float(p.risk_score), str(p.id)))
183
+ data = [p.model_dump(mode="json") for p in sorted_paths]
184
+ self._write_json(scan_dir / "attack_paths.json", data)
185
+
186
+ def get_snapshot(self, scan_id: str | None = None) -> Snapshot | None:
187
+ resolved_id = self.resolve_scan_id(scan_id)
188
+ if resolved_id is None:
189
+ return None
190
+ try:
191
+ scan_dir = self._get_scan_dir(resolved_id)
192
+ except ValueError:
193
+ return None
194
+ data = self._read_json(scan_dir / "snapshot.json")
195
+ return Snapshot.model_validate(data) if data else None
196
+
197
+ def get_assets(self, scan_id: str | None = None) -> list[Asset]:
198
+ resolved_id = self.resolve_scan_id(scan_id)
199
+ if resolved_id is None:
200
+ return []
201
+ try:
202
+ scan_dir = self._get_scan_dir(resolved_id)
203
+ except ValueError:
204
+ return []
205
+ data = self._read_json(scan_dir / "assets.json")
206
+ return [Asset.model_validate(a) for a in (data or [])]
207
+
208
+ def get_relationships(self, scan_id: str | None = None) -> list[Relationship]:
209
+ resolved_id = self.resolve_scan_id(scan_id)
210
+ if resolved_id is None:
211
+ return []
212
+ try:
213
+ scan_dir = self._get_scan_dir(resolved_id)
214
+ except ValueError:
215
+ return []
216
+ data = self._read_json(scan_dir / "relationships.json")
217
+ return [Relationship.model_validate(r) for r in (data or [])]
218
+
219
+ def get_findings(self, scan_id: str | None = None) -> list[Finding]:
220
+ resolved_id = self.resolve_scan_id(scan_id)
221
+ if resolved_id is None:
222
+ return []
223
+ try:
224
+ scan_dir = self._get_scan_dir(resolved_id)
225
+ except ValueError:
226
+ return []
227
+ data = self._read_json(scan_dir / "findings.json")
228
+ return [Finding.model_validate(f) for f in (data or [])]
229
+
230
+ def get_attack_paths(self, scan_id: str | None = None) -> list[AttackPath]:
231
+ resolved_id = self.resolve_scan_id(scan_id)
232
+ if resolved_id is None:
233
+ return []
234
+ try:
235
+ scan_dir = self._get_scan_dir(resolved_id)
236
+ except ValueError:
237
+ return []
238
+ data = self._read_json(scan_dir / "attack_paths.json")
239
+ return [AttackPath.model_validate(p) for p in (data or [])]
240
+
241
+ def export_all(self, scan_id: str | None = None) -> dict:
242
+ """Export all scan data as a dictionary."""
243
+ snapshot = self.get_snapshot(scan_id)
244
+ return {
245
+ "snapshot": snapshot.model_dump(mode="json") if snapshot else None,
246
+ "assets": [a.model_dump(mode="json") for a in self.get_assets(scan_id)],
247
+ "relationships": [r.model_dump(mode="json") for r in self.get_relationships(scan_id)],
248
+ "findings": [f.model_dump(mode="json") for f in self.get_findings(scan_id)],
249
+ "attack_paths": [p.model_dump(mode="json") for p in self.get_attack_paths(scan_id)],
250
+ "metadata": {
251
+ "exported_at": datetime.utcnow().isoformat() + "Z",
252
+ "scan_id": scan_id or self._current_id,
253
+ },
254
+ }
255
+
256
+ def list_scans(self) -> list[str]:
257
+ """List all available scan directories."""
258
+ scans = []
259
+ for item in self._base.iterdir():
260
+ if item.is_dir() and item.name != "latest":
261
+ try:
262
+ scans.append(self._validate_scan_id(item.name))
263
+ except ValueError:
264
+ continue
265
+ return sorted(scans, reverse=True) # Most recent first
266
+
267
+ def resolve_scan_id(self, identifier: str | None) -> str | None:
268
+ """
269
+ Resolve an identifier to a scan_id (directory name).
270
+
271
+ Accepts:
272
+ - scan_id (directory name): returned as-is if valid (and safe!)
273
+ - snapshot UUID: looks up the scan directory containing that snapshot
274
+ - None: returns latest scan_id
275
+
276
+ Returns:
277
+ scan_id (directory name) or None if not found
278
+ """
279
+ if identifier is None:
280
+ # Return latest scan_id
281
+ latest_link = self._base / "latest"
282
+ target = None
283
+ if latest_link.is_symlink():
284
+ target = os.readlink(latest_link)
285
+ elif latest_link.exists() and latest_link.is_file():
286
+ # Windows fallback: file contains directory name
287
+ target = latest_link.read_text().strip()
288
+
289
+ if target:
290
+ try:
291
+ # Validate that the latest target is a safe ID
292
+ return self._validate_scan_id(target)
293
+ except ValueError:
294
+ # If latest is corrupt/malicious, ignore it and fall back to listing
295
+ pass
296
+
297
+ # No latest, try to get most recent scan
298
+ scans = self.list_scans()
299
+ return scans[0] if scans else None
300
+
301
+ # Check if it's already a valid scan directory
302
+ try:
303
+ # Use safe join to verify it is a valid scan directory under base.
304
+ scan_dir = self._safe_join_scan_dir(identifier)
305
+ if scan_dir.exists() and scan_dir.is_dir():
306
+ return identifier
307
+ except (ValueError, OSError):
308
+ # Not a simple scan dir, proceed to check if it's a UUID
309
+ pass
310
+
311
+ # Try to find by UUID - iterate through scans and check snapshot.id
312
+ for scan_id in self.list_scans():
313
+ # We trust list_scans() to return safe directory names from self._base
314
+ try:
315
+ scan_dir = self._safe_join_scan_dir(scan_id)
316
+ except ValueError:
317
+ continue
318
+ snapshot_path = scan_dir / "snapshot.json"
319
+ if snapshot_path.exists():
320
+ data = self._read_json(snapshot_path)
321
+ if data and str(data.get("id", "")) == identifier:
322
+ return scan_id
323
+
324
+ return None
325
+
326
+ def list_snapshots(self) -> list[Snapshot]:
327
+ """List all available snapshots, sorted by date (most recent first)."""
328
+ snapshots = []
329
+ for scan_id in self.list_scans():
330
+ # list_scans returns directory names, so they should be safe,
331
+ # but using get_snapshot calls resolve_scan_id again which is safe.
332
+ snapshot = self.get_snapshot(scan_id)
333
+ if snapshot:
334
+ snapshots.append(snapshot)
335
+ # Sort by started_at descending
336
+ return sorted(snapshots, key=lambda s: s.started_at, reverse=True)
337
+
338
+ def get_scan_path(self, scan_id: str | None = None) -> Path:
339
+ """Get the filesystem path for a scan directory."""
340
+ resolved_id = self.resolve_scan_id(scan_id)
341
+ if resolved_id is None:
342
+ raise ValueError("No scan specified and no latest scan found")
343
+ return self._get_scan_dir(resolved_id)
344
+
@@ -0,0 +1,113 @@
1
+ """
2
+ In-Memory Storage - Keep scan results in memory.
3
+
4
+ Useful for testing and single-run analysis without persistence.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+
11
+ from cyntrisec.core.schema import (
12
+ Asset,
13
+ AttackPath,
14
+ Finding,
15
+ Relationship,
16
+ Snapshot,
17
+ )
18
+ from cyntrisec.storage.protocol import StorageBackend
19
+
20
+
21
+ class InMemoryStorage(StorageBackend):
22
+ """
23
+ Keep scan results in memory.
24
+
25
+ Data is lost when the process exits.
26
+ Useful for testing and ephemeral analysis.
27
+ """
28
+
29
+ def __init__(self):
30
+ self._scans: dict[str, dict] = {}
31
+ self._current_id: str | None = None
32
+
33
+ def new_scan(self, account_id: str) -> str:
34
+ timestamp = datetime.utcnow().strftime("%Y-%m-%d_%H%M%S")
35
+ scan_id = f"{timestamp}_{account_id}"
36
+ self._current_id = scan_id
37
+ self._scans[scan_id] = {
38
+ "snapshot": None,
39
+ "assets": [],
40
+ "relationships": [],
41
+ "findings": [],
42
+ "attack_paths": [],
43
+ }
44
+ return scan_id
45
+
46
+ def _get_scan(self, scan_id: str | None = None) -> dict:
47
+ sid = scan_id or self._current_id
48
+ if not sid or sid not in self._scans:
49
+ raise ValueError(f"Scan not found: {sid}")
50
+ return self._scans[sid]
51
+
52
+ def save_snapshot(self, snapshot: Snapshot) -> None:
53
+ self._get_scan()["snapshot"] = snapshot
54
+
55
+ def save_assets(self, assets: list[Asset]) -> None:
56
+ self._get_scan()["assets"] = assets
57
+
58
+ def save_relationships(self, relationships: list[Relationship]) -> None:
59
+ self._get_scan()["relationships"] = relationships
60
+
61
+ def save_findings(self, findings: list[Finding]) -> None:
62
+ self._get_scan()["findings"] = findings
63
+
64
+ def save_attack_paths(self, paths: list[AttackPath]) -> None:
65
+ self._get_scan()["attack_paths"] = paths
66
+
67
+ def get_snapshot(self, scan_id: str | None = None) -> Snapshot | None:
68
+ try:
69
+ return self._get_scan(scan_id)["snapshot"]
70
+ except ValueError:
71
+ return None
72
+
73
+ def get_assets(self, scan_id: str | None = None) -> list[Asset]:
74
+ try:
75
+ return self._get_scan(scan_id)["assets"]
76
+ except ValueError:
77
+ return []
78
+
79
+ def get_relationships(self, scan_id: str | None = None) -> list[Relationship]:
80
+ try:
81
+ return self._get_scan(scan_id)["relationships"]
82
+ except ValueError:
83
+ return []
84
+
85
+ def get_findings(self, scan_id: str | None = None) -> list[Finding]:
86
+ try:
87
+ return self._get_scan(scan_id)["findings"]
88
+ except ValueError:
89
+ return []
90
+
91
+ def get_attack_paths(self, scan_id: str | None = None) -> list[AttackPath]:
92
+ try:
93
+ return self._get_scan(scan_id)["attack_paths"]
94
+ except ValueError:
95
+ return []
96
+
97
+ def export_all(self, scan_id: str | None = None) -> dict:
98
+ scan = self._get_scan(scan_id)
99
+ snapshot = scan["snapshot"]
100
+ return {
101
+ "snapshot": snapshot.model_dump(mode="json") if snapshot else None,
102
+ "assets": [a.model_dump(mode="json") for a in scan["assets"]],
103
+ "relationships": [r.model_dump(mode="json") for r in scan["relationships"]],
104
+ "findings": [f.model_dump(mode="json") for f in scan["findings"]],
105
+ "attack_paths": [p.model_dump(mode="json") for p in scan["attack_paths"]],
106
+ "metadata": {
107
+ "exported_at": datetime.utcnow().isoformat() + "Z",
108
+ "scan_id": scan_id or self._current_id,
109
+ },
110
+ }
111
+
112
+ def list_scans(self) -> list[str]:
113
+ return sorted(self._scans.keys(), reverse=True)
@@ -0,0 +1,92 @@
1
+ """
2
+ Storage Backend Protocol - Abstract interface for scan storage.
3
+
4
+ Implementations:
5
+ - FileSystemStorage: Persist to JSON files (default)
6
+ - InMemoryStorage: Keep in memory (for testing)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+
13
+ from cyntrisec.core.schema import (
14
+ Asset,
15
+ AttackPath,
16
+ Finding,
17
+ Relationship,
18
+ Snapshot,
19
+ )
20
+
21
+
22
+ class StorageBackend(ABC):
23
+ """
24
+ Abstract storage interface.
25
+
26
+ All methods are synchronous. No database required.
27
+ """
28
+
29
+ @abstractmethod
30
+ def new_scan(self, account_id: str) -> str:
31
+ """Initialize storage for a new scan. Returns scan directory/ID."""
32
+ ...
33
+
34
+ @abstractmethod
35
+ def save_snapshot(self, snapshot: Snapshot) -> None:
36
+ """Save snapshot metadata."""
37
+ ...
38
+
39
+ @abstractmethod
40
+ def save_assets(self, assets: list[Asset]) -> None:
41
+ """Save assets."""
42
+ ...
43
+
44
+ @abstractmethod
45
+ def save_relationships(self, relationships: list[Relationship]) -> None:
46
+ """Save relationships."""
47
+ ...
48
+
49
+ @abstractmethod
50
+ def save_findings(self, findings: list[Finding]) -> None:
51
+ """Save findings."""
52
+ ...
53
+
54
+ @abstractmethod
55
+ def save_attack_paths(self, paths: list[AttackPath]) -> None:
56
+ """Save attack paths."""
57
+ ...
58
+
59
+ @abstractmethod
60
+ def get_snapshot(self, scan_id: str | None = None) -> Snapshot | None:
61
+ """Get snapshot for a scan (or latest if not specified)."""
62
+ ...
63
+
64
+ @abstractmethod
65
+ def get_assets(self, scan_id: str | None = None) -> list[Asset]:
66
+ """Get all assets for a scan."""
67
+ ...
68
+
69
+ @abstractmethod
70
+ def get_relationships(self, scan_id: str | None = None) -> list[Relationship]:
71
+ """Get all relationships for a scan."""
72
+ ...
73
+
74
+ @abstractmethod
75
+ def get_findings(self, scan_id: str | None = None) -> list[Finding]:
76
+ """Get all findings for a scan."""
77
+ ...
78
+
79
+ @abstractmethod
80
+ def get_attack_paths(self, scan_id: str | None = None) -> list[AttackPath]:
81
+ """Get all attack paths for a scan."""
82
+ ...
83
+
84
+ @abstractmethod
85
+ def export_all(self, scan_id: str | None = None) -> dict:
86
+ """Export all data for a scan as a dictionary."""
87
+ ...
88
+
89
+ @abstractmethod
90
+ def list_scans(self) -> list[str]:
91
+ """List all available scan IDs."""
92
+ ...