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.
- cyntrisec/__init__.py +3 -0
- cyntrisec/__main__.py +6 -0
- cyntrisec/aws/__init__.py +6 -0
- cyntrisec/aws/collectors/__init__.py +17 -0
- cyntrisec/aws/collectors/ec2.py +30 -0
- cyntrisec/aws/collectors/iam.py +116 -0
- cyntrisec/aws/collectors/lambda_.py +45 -0
- cyntrisec/aws/collectors/network.py +70 -0
- cyntrisec/aws/collectors/rds.py +38 -0
- cyntrisec/aws/collectors/s3.py +68 -0
- cyntrisec/aws/collectors/usage.py +188 -0
- cyntrisec/aws/credentials.py +153 -0
- cyntrisec/aws/normalizers/__init__.py +17 -0
- cyntrisec/aws/normalizers/ec2.py +115 -0
- cyntrisec/aws/normalizers/iam.py +182 -0
- cyntrisec/aws/normalizers/lambda_.py +83 -0
- cyntrisec/aws/normalizers/network.py +225 -0
- cyntrisec/aws/normalizers/rds.py +130 -0
- cyntrisec/aws/normalizers/s3.py +184 -0
- cyntrisec/aws/relationship_builder.py +1359 -0
- cyntrisec/aws/scanner.py +303 -0
- cyntrisec/cli/__init__.py +5 -0
- cyntrisec/cli/analyze.py +747 -0
- cyntrisec/cli/ask.py +412 -0
- cyntrisec/cli/can.py +307 -0
- cyntrisec/cli/comply.py +226 -0
- cyntrisec/cli/cuts.py +231 -0
- cyntrisec/cli/diff.py +332 -0
- cyntrisec/cli/errors.py +105 -0
- cyntrisec/cli/explain.py +348 -0
- cyntrisec/cli/main.py +114 -0
- cyntrisec/cli/manifest.py +893 -0
- cyntrisec/cli/output.py +117 -0
- cyntrisec/cli/remediate.py +643 -0
- cyntrisec/cli/report.py +462 -0
- cyntrisec/cli/scan.py +207 -0
- cyntrisec/cli/schemas.py +391 -0
- cyntrisec/cli/serve.py +164 -0
- cyntrisec/cli/setup.py +260 -0
- cyntrisec/cli/validate.py +101 -0
- cyntrisec/cli/waste.py +323 -0
- cyntrisec/core/__init__.py +31 -0
- cyntrisec/core/business_config.py +110 -0
- cyntrisec/core/business_logic.py +131 -0
- cyntrisec/core/compliance.py +437 -0
- cyntrisec/core/cost_estimator.py +301 -0
- cyntrisec/core/cuts.py +360 -0
- cyntrisec/core/diff.py +361 -0
- cyntrisec/core/graph.py +202 -0
- cyntrisec/core/paths.py +830 -0
- cyntrisec/core/schema.py +317 -0
- cyntrisec/core/simulator.py +371 -0
- cyntrisec/core/waste.py +309 -0
- cyntrisec/mcp/__init__.py +5 -0
- cyntrisec/mcp/server.py +862 -0
- cyntrisec/storage/__init__.py +7 -0
- cyntrisec/storage/filesystem.py +344 -0
- cyntrisec/storage/memory.py +113 -0
- cyntrisec/storage/protocol.py +92 -0
- cyntrisec-0.1.7.dist-info/METADATA +672 -0
- cyntrisec-0.1.7.dist-info/RECORD +65 -0
- cyntrisec-0.1.7.dist-info/WHEEL +4 -0
- cyntrisec-0.1.7.dist-info/entry_points.txt +2 -0
- cyntrisec-0.1.7.dist-info/licenses/LICENSE +190 -0
- 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
|
+
...
|