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
cyntrisec/core/diff.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Snapshot Diff - Compare two scan snapshots to detect changes.
|
|
3
|
+
|
|
4
|
+
Identifies:
|
|
5
|
+
- New assets (added since previous scan)
|
|
6
|
+
- Removed assets (gone since previous scan)
|
|
7
|
+
- Changed relationships (new connections, removed connections)
|
|
8
|
+
- Security regressions (new attack paths, new findings)
|
|
9
|
+
- Security improvements (fixed attack paths, resolved findings)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
|
|
18
|
+
from cyntrisec.core.schema import Asset, AttackPath, Finding, Relationship
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChangeType(str, Enum):
|
|
22
|
+
"""Type of change detected."""
|
|
23
|
+
|
|
24
|
+
added = "added"
|
|
25
|
+
removed = "removed"
|
|
26
|
+
modified = "modified"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AssetChange:
|
|
31
|
+
"""A change to an asset between snapshots."""
|
|
32
|
+
|
|
33
|
+
change_type: ChangeType
|
|
34
|
+
asset: Asset
|
|
35
|
+
previous_asset: Asset | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def key(self) -> str:
|
|
39
|
+
"""Unique key for the asset (ARN or resource ID)."""
|
|
40
|
+
return self.asset.arn or self.asset.aws_resource_id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class RelationshipChange:
|
|
45
|
+
"""A change to a relationship between snapshots."""
|
|
46
|
+
|
|
47
|
+
change_type: ChangeType
|
|
48
|
+
relationship: Relationship
|
|
49
|
+
source_name: str = ""
|
|
50
|
+
target_name: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class PathChange:
|
|
55
|
+
"""A change to an attack path between snapshots."""
|
|
56
|
+
|
|
57
|
+
change_type: ChangeType
|
|
58
|
+
path: AttackPath
|
|
59
|
+
is_regression: bool = False # True if this is a NEW attack path (bad)
|
|
60
|
+
is_improvement: bool = False # True if this is a REMOVED attack path (good)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class FindingChange:
|
|
65
|
+
"""A change to a security finding between snapshots."""
|
|
66
|
+
|
|
67
|
+
change_type: ChangeType
|
|
68
|
+
finding: Finding
|
|
69
|
+
is_regression: bool = False
|
|
70
|
+
is_improvement: bool = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class DiffResult:
|
|
75
|
+
"""
|
|
76
|
+
Result of comparing two snapshots.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
old_snapshot_id: ID of the baseline snapshot
|
|
80
|
+
new_snapshot_id: ID of the current snapshot
|
|
81
|
+
asset_changes: New/removed/modified assets
|
|
82
|
+
relationship_changes: New/removed relationships
|
|
83
|
+
path_changes: New/removed attack paths
|
|
84
|
+
finding_changes: New/resolved findings
|
|
85
|
+
summary: High-level summary stats
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
old_snapshot_id: uuid.UUID
|
|
89
|
+
new_snapshot_id: uuid.UUID
|
|
90
|
+
asset_changes: list[AssetChange] = field(default_factory=list)
|
|
91
|
+
relationship_changes: list[RelationshipChange] = field(default_factory=list)
|
|
92
|
+
path_changes: list[PathChange] = field(default_factory=list)
|
|
93
|
+
finding_changes: list[FindingChange] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def summary(self) -> dict[str, int]:
|
|
97
|
+
"""Summary statistics of changes."""
|
|
98
|
+
return {
|
|
99
|
+
"assets_added": sum(1 for c in self.asset_changes if c.change_type == ChangeType.added),
|
|
100
|
+
"assets_removed": sum(
|
|
101
|
+
1 for c in self.asset_changes if c.change_type == ChangeType.removed
|
|
102
|
+
),
|
|
103
|
+
"relationships_added": sum(
|
|
104
|
+
1 for c in self.relationship_changes if c.change_type == ChangeType.added
|
|
105
|
+
),
|
|
106
|
+
"relationships_removed": sum(
|
|
107
|
+
1 for c in self.relationship_changes if c.change_type == ChangeType.removed
|
|
108
|
+
),
|
|
109
|
+
"paths_added": sum(1 for c in self.path_changes if c.change_type == ChangeType.added),
|
|
110
|
+
"paths_removed": sum(
|
|
111
|
+
1 for c in self.path_changes if c.change_type == ChangeType.removed
|
|
112
|
+
),
|
|
113
|
+
"findings_new": sum(
|
|
114
|
+
1 for c in self.finding_changes if c.change_type == ChangeType.added
|
|
115
|
+
),
|
|
116
|
+
"findings_resolved": sum(
|
|
117
|
+
1 for c in self.finding_changes if c.change_type == ChangeType.removed
|
|
118
|
+
),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def has_regressions(self) -> bool:
|
|
123
|
+
"""Check if there are security regressions."""
|
|
124
|
+
return any(c.is_regression for c in self.path_changes) or any(
|
|
125
|
+
c.is_regression for c in self.finding_changes
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def has_improvements(self) -> bool:
|
|
130
|
+
"""Check if there are security improvements."""
|
|
131
|
+
return any(c.is_improvement for c in self.path_changes) or any(
|
|
132
|
+
c.is_improvement for c in self.finding_changes
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SnapshotDiff:
|
|
137
|
+
"""
|
|
138
|
+
Compare two scan snapshots to detect changes.
|
|
139
|
+
|
|
140
|
+
Useful for:
|
|
141
|
+
- Configuration drift detection
|
|
142
|
+
- Security regression testing
|
|
143
|
+
- Change auditing
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def diff(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
old_assets: list[Asset],
|
|
150
|
+
old_relationships: list[Relationship],
|
|
151
|
+
old_paths: list[AttackPath],
|
|
152
|
+
old_findings: list[Finding],
|
|
153
|
+
new_assets: list[Asset],
|
|
154
|
+
new_relationships: list[Relationship],
|
|
155
|
+
new_paths: list[AttackPath],
|
|
156
|
+
new_findings: list[Finding],
|
|
157
|
+
old_snapshot_id: uuid.UUID,
|
|
158
|
+
new_snapshot_id: uuid.UUID,
|
|
159
|
+
) -> DiffResult:
|
|
160
|
+
"""
|
|
161
|
+
Compare two snapshots and return all changes.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
old_*: Data from baseline snapshot
|
|
165
|
+
new_*: Data from current snapshot
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
DiffResult with all detected changes
|
|
169
|
+
"""
|
|
170
|
+
result = DiffResult(
|
|
171
|
+
old_snapshot_id=old_snapshot_id,
|
|
172
|
+
new_snapshot_id=new_snapshot_id,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Diff assets by ARN/resource ID
|
|
176
|
+
result.asset_changes = self._diff_assets(old_assets, new_assets)
|
|
177
|
+
|
|
178
|
+
# Build asset name lookup for relationship display
|
|
179
|
+
asset_names = {a.id: a.name for a in new_assets}
|
|
180
|
+
asset_names.update({a.id: a.name for a in old_assets})
|
|
181
|
+
|
|
182
|
+
# Diff relationships by source+target+type
|
|
183
|
+
result.relationship_changes = self._diff_relationships(
|
|
184
|
+
old_relationships, new_relationships, asset_names
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Diff attack paths by source+target
|
|
188
|
+
result.path_changes = self._diff_paths(old_paths, new_paths)
|
|
189
|
+
|
|
190
|
+
# Diff findings by asset+type
|
|
191
|
+
result.finding_changes = self._diff_findings(old_findings, new_findings)
|
|
192
|
+
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
def _diff_assets(
|
|
196
|
+
self,
|
|
197
|
+
old: list[Asset],
|
|
198
|
+
new: list[Asset],
|
|
199
|
+
) -> list[AssetChange]:
|
|
200
|
+
"""Diff assets between snapshots."""
|
|
201
|
+
changes = []
|
|
202
|
+
|
|
203
|
+
# Key by ARN or resource ID
|
|
204
|
+
old_by_key = {(a.arn or a.aws_resource_id): a for a in old}
|
|
205
|
+
new_by_key = {(a.arn or a.aws_resource_id): a for a in new}
|
|
206
|
+
|
|
207
|
+
old_keys = set(old_by_key.keys())
|
|
208
|
+
new_keys = set(new_by_key.keys())
|
|
209
|
+
|
|
210
|
+
# Added assets
|
|
211
|
+
for key in new_keys - old_keys:
|
|
212
|
+
changes.append(
|
|
213
|
+
AssetChange(
|
|
214
|
+
change_type=ChangeType.added,
|
|
215
|
+
asset=new_by_key[key],
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Removed assets
|
|
220
|
+
for key in old_keys - new_keys:
|
|
221
|
+
changes.append(
|
|
222
|
+
AssetChange(
|
|
223
|
+
change_type=ChangeType.removed,
|
|
224
|
+
asset=old_by_key[key],
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return changes
|
|
229
|
+
|
|
230
|
+
def _diff_relationships(
|
|
231
|
+
self,
|
|
232
|
+
old: list[Relationship],
|
|
233
|
+
new: list[Relationship],
|
|
234
|
+
asset_names: dict[uuid.UUID, str],
|
|
235
|
+
) -> list[RelationshipChange]:
|
|
236
|
+
"""Diff relationships between snapshots."""
|
|
237
|
+
changes = []
|
|
238
|
+
|
|
239
|
+
# Key by source ARN + target ARN + type (since IDs change between snapshots)
|
|
240
|
+
def rel_key(r: Relationship) -> tuple[str, str, str]:
|
|
241
|
+
return (
|
|
242
|
+
asset_names.get(r.source_asset_id, str(r.source_asset_id)),
|
|
243
|
+
asset_names.get(r.target_asset_id, str(r.target_asset_id)),
|
|
244
|
+
r.relationship_type,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
old_by_key = {rel_key(r): r for r in old}
|
|
248
|
+
new_by_key = {rel_key(r): r for r in new}
|
|
249
|
+
|
|
250
|
+
old_keys = set(old_by_key.keys())
|
|
251
|
+
new_keys = set(new_by_key.keys())
|
|
252
|
+
|
|
253
|
+
# Added relationships
|
|
254
|
+
for key in new_keys - old_keys:
|
|
255
|
+
rel = new_by_key[key]
|
|
256
|
+
changes.append(
|
|
257
|
+
RelationshipChange(
|
|
258
|
+
change_type=ChangeType.added,
|
|
259
|
+
relationship=rel,
|
|
260
|
+
source_name=key[0],
|
|
261
|
+
target_name=key[1],
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Removed relationships
|
|
266
|
+
for key in old_keys - new_keys:
|
|
267
|
+
rel = old_by_key[key]
|
|
268
|
+
changes.append(
|
|
269
|
+
RelationshipChange(
|
|
270
|
+
change_type=ChangeType.removed,
|
|
271
|
+
relationship=rel,
|
|
272
|
+
source_name=key[0],
|
|
273
|
+
target_name=key[1],
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return changes
|
|
278
|
+
|
|
279
|
+
def _diff_paths(
|
|
280
|
+
self,
|
|
281
|
+
old: list[AttackPath],
|
|
282
|
+
new: list[AttackPath],
|
|
283
|
+
) -> list[PathChange]:
|
|
284
|
+
"""Diff attack paths between snapshots."""
|
|
285
|
+
changes = []
|
|
286
|
+
|
|
287
|
+
# Key by attack vector + source/target names (from proof)
|
|
288
|
+
def path_key(p: AttackPath) -> tuple[str, str, str]:
|
|
289
|
+
proof = p.proof or {}
|
|
290
|
+
steps = proof.get("steps", [])
|
|
291
|
+
source_name = steps[0].get("name", "") if steps else ""
|
|
292
|
+
target_name = steps[-1].get("name", "") if steps else ""
|
|
293
|
+
return (p.attack_vector, source_name, target_name)
|
|
294
|
+
|
|
295
|
+
old_by_key = {path_key(p): p for p in old}
|
|
296
|
+
new_by_key = {path_key(p): p for p in new}
|
|
297
|
+
|
|
298
|
+
old_keys = set(old_by_key.keys())
|
|
299
|
+
new_keys = set(new_by_key.keys())
|
|
300
|
+
|
|
301
|
+
# New attack paths (regressions!)
|
|
302
|
+
for key in new_keys - old_keys:
|
|
303
|
+
changes.append(
|
|
304
|
+
PathChange(
|
|
305
|
+
change_type=ChangeType.added,
|
|
306
|
+
path=new_by_key[key],
|
|
307
|
+
is_regression=True,
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Removed attack paths (improvements!)
|
|
312
|
+
for key in old_keys - new_keys:
|
|
313
|
+
changes.append(
|
|
314
|
+
PathChange(
|
|
315
|
+
change_type=ChangeType.removed,
|
|
316
|
+
path=old_by_key[key],
|
|
317
|
+
is_improvement=True,
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
return changes
|
|
322
|
+
|
|
323
|
+
def _diff_findings(
|
|
324
|
+
self,
|
|
325
|
+
old: list[Finding],
|
|
326
|
+
new: list[Finding],
|
|
327
|
+
) -> list[FindingChange]:
|
|
328
|
+
"""Diff findings between snapshots."""
|
|
329
|
+
changes = []
|
|
330
|
+
|
|
331
|
+
# Key by finding type + title (normalized)
|
|
332
|
+
def finding_key(f: Finding) -> tuple[str, str]:
|
|
333
|
+
return (f.finding_type, f.title.lower())
|
|
334
|
+
|
|
335
|
+
old_by_key = {finding_key(f): f for f in old}
|
|
336
|
+
new_by_key = {finding_key(f): f for f in new}
|
|
337
|
+
|
|
338
|
+
old_keys = set(old_by_key.keys())
|
|
339
|
+
new_keys = set(new_by_key.keys())
|
|
340
|
+
|
|
341
|
+
# New findings (regressions)
|
|
342
|
+
for key in new_keys - old_keys:
|
|
343
|
+
changes.append(
|
|
344
|
+
FindingChange(
|
|
345
|
+
change_type=ChangeType.added,
|
|
346
|
+
finding=new_by_key[key],
|
|
347
|
+
is_regression=True,
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Removed findings (improvements)
|
|
352
|
+
for key in old_keys - new_keys:
|
|
353
|
+
changes.append(
|
|
354
|
+
FindingChange(
|
|
355
|
+
change_type=ChangeType.removed,
|
|
356
|
+
finding=old_by_key[key],
|
|
357
|
+
is_improvement=True,
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
return changes
|
cyntrisec/core/graph.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Capability Graph - In-memory graph representation.
|
|
3
|
+
|
|
4
|
+
The graph models AWS infrastructure as:
|
|
5
|
+
- Nodes: Assets (resources, logical groupings)
|
|
6
|
+
- Edges: Relationships (capabilities, permissions, connectivity)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from cyntrisec.core.schema import INTERNET_ASSET_ID, Asset, EdgeKind, Relationship
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AwsGraph:
|
|
20
|
+
"""
|
|
21
|
+
In-memory capability graph for AWS infrastructure.
|
|
22
|
+
|
|
23
|
+
Provides efficient lookups for:
|
|
24
|
+
- Asset by ID
|
|
25
|
+
- Neighbors (outgoing edges)
|
|
26
|
+
- Predecessors (incoming edges)
|
|
27
|
+
- Assets by type
|
|
28
|
+
|
|
29
|
+
This is an immutable snapshot of the graph at scan time.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
assets_by_id: dict[uuid.UUID, Asset]
|
|
33
|
+
outgoing: dict[uuid.UUID, list[Relationship]]
|
|
34
|
+
incoming: dict[uuid.UUID, list[Relationship]]
|
|
35
|
+
|
|
36
|
+
def asset(self, asset_id: uuid.UUID) -> Asset | None:
|
|
37
|
+
"""Get an asset by ID."""
|
|
38
|
+
return self.assets_by_id.get(asset_id)
|
|
39
|
+
|
|
40
|
+
def neighbors(self, asset_id: uuid.UUID) -> list[uuid.UUID]:
|
|
41
|
+
"""Get IDs of all assets this asset can reach (outgoing edges)."""
|
|
42
|
+
return [rel.target_asset_id for rel in self.outgoing.get(asset_id, [])]
|
|
43
|
+
|
|
44
|
+
def predecessors(self, asset_id: uuid.UUID) -> list[uuid.UUID]:
|
|
45
|
+
"""Get IDs of all assets that can reach this asset (incoming edges)."""
|
|
46
|
+
return [rel.source_asset_id for rel in self.incoming.get(asset_id, [])]
|
|
47
|
+
|
|
48
|
+
def edges_from(self, asset_id: uuid.UUID) -> list[Relationship]:
|
|
49
|
+
"""Get all outgoing relationships from an asset."""
|
|
50
|
+
return list(self.outgoing.get(asset_id, []))
|
|
51
|
+
|
|
52
|
+
def edges_to(self, asset_id: uuid.UUID) -> list[Relationship]:
|
|
53
|
+
"""Get all incoming relationships to an asset."""
|
|
54
|
+
return list(self.incoming.get(asset_id, []))
|
|
55
|
+
|
|
56
|
+
def all_assets(self) -> list[Asset]:
|
|
57
|
+
"""Get all assets in the graph."""
|
|
58
|
+
return list(self.assets_by_id.values())
|
|
59
|
+
|
|
60
|
+
def all_relationships(self) -> list[Relationship]:
|
|
61
|
+
"""Get all relationships in the graph."""
|
|
62
|
+
all_rels = []
|
|
63
|
+
for rels in self.outgoing.values():
|
|
64
|
+
all_rels.extend(rels)
|
|
65
|
+
return all_rels
|
|
66
|
+
|
|
67
|
+
def asset_count(self) -> int:
|
|
68
|
+
"""Get the number of assets."""
|
|
69
|
+
return len(self.assets_by_id)
|
|
70
|
+
|
|
71
|
+
def relationship_count(self) -> int:
|
|
72
|
+
"""Get the number of relationships."""
|
|
73
|
+
return sum(len(rels) for rels in self.outgoing.values())
|
|
74
|
+
|
|
75
|
+
def assets_by_type(self, asset_type: str) -> list[Asset]:
|
|
76
|
+
"""Get all assets of a specific type."""
|
|
77
|
+
return [a for a in self.assets_by_id.values() if a.asset_type == asset_type]
|
|
78
|
+
|
|
79
|
+
def entry_points(self) -> list[Asset]:
|
|
80
|
+
"""
|
|
81
|
+
Get all internet-facing entry points.
|
|
82
|
+
|
|
83
|
+
Includes:
|
|
84
|
+
1. Assets reachable via CAN_REACH edges from the Internet (preferred)
|
|
85
|
+
2. Assets marked as internet_facing (fallback/legacy)
|
|
86
|
+
"""
|
|
87
|
+
entries: dict[uuid.UUID, Asset] = {}
|
|
88
|
+
|
|
89
|
+
# 1. CAN_REACH from Internet
|
|
90
|
+
if INTERNET_ASSET_ID in self.outgoing:
|
|
91
|
+
for rel in self.outgoing[INTERNET_ASSET_ID]:
|
|
92
|
+
if rel.relationship_type == "CAN_REACH":
|
|
93
|
+
asset = self.assets_by_id.get(rel.target_asset_id)
|
|
94
|
+
if asset:
|
|
95
|
+
entries[asset.id] = asset
|
|
96
|
+
|
|
97
|
+
# 2. Legacy/Attribute-based checks
|
|
98
|
+
for asset in self.assets_by_id.values():
|
|
99
|
+
if asset.id in entries:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if asset.is_internet_facing:
|
|
103
|
+
entries[asset.id] = asset
|
|
104
|
+
elif asset.asset_type in [
|
|
105
|
+
"ec2:elastic-ip",
|
|
106
|
+
"elbv2:load-balancer",
|
|
107
|
+
"elb:load-balancer",
|
|
108
|
+
"cloudfront:distribution",
|
|
109
|
+
"apigateway:rest-api",
|
|
110
|
+
]:
|
|
111
|
+
if asset.properties.get("scheme") == "internet-facing":
|
|
112
|
+
entries[asset.id] = asset
|
|
113
|
+
elif asset.properties.get("public_ip"):
|
|
114
|
+
entries[asset.id] = asset
|
|
115
|
+
|
|
116
|
+
return list(entries.values())
|
|
117
|
+
|
|
118
|
+
def sensitive_targets(self) -> list[Asset]:
|
|
119
|
+
"""
|
|
120
|
+
Get all sensitive target assets.
|
|
121
|
+
|
|
122
|
+
Targets are assets marked as sensitive or have specific types
|
|
123
|
+
(databases, secrets, admin roles).
|
|
124
|
+
"""
|
|
125
|
+
targets = []
|
|
126
|
+
for asset in self.assets_by_id.values():
|
|
127
|
+
if asset.is_sensitive_target:
|
|
128
|
+
targets.append(asset)
|
|
129
|
+
elif asset.asset_type in [
|
|
130
|
+
"rds:db-instance",
|
|
131
|
+
"dynamodb:table",
|
|
132
|
+
"secretsmanager:secret",
|
|
133
|
+
"ssm:parameter",
|
|
134
|
+
]:
|
|
135
|
+
targets.append(asset)
|
|
136
|
+
elif asset.asset_type == "iam:role":
|
|
137
|
+
name_lower = asset.name.lower()
|
|
138
|
+
if any(kw in name_lower for kw in ["admin", "root", "power"]):
|
|
139
|
+
targets.append(asset)
|
|
140
|
+
elif asset.asset_type == "s3:bucket":
|
|
141
|
+
name_lower = asset.name.lower()
|
|
142
|
+
if any(kw in name_lower for kw in ["secret", "credential", "backup"]):
|
|
143
|
+
targets.append(asset)
|
|
144
|
+
return targets
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class GraphBuilder:
|
|
148
|
+
"""
|
|
149
|
+
Builds an AwsGraph from assets and relationships.
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
builder = GraphBuilder()
|
|
153
|
+
graph = builder.build(assets=assets, relationships=relationships)
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def build(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
assets: Sequence[Asset],
|
|
160
|
+
relationships: Sequence[Relationship],
|
|
161
|
+
) -> AwsGraph:
|
|
162
|
+
"""
|
|
163
|
+
Build a graph from assets and relationships.
|
|
164
|
+
|
|
165
|
+
Only includes relationships where both endpoints exist
|
|
166
|
+
in the provided asset list.
|
|
167
|
+
"""
|
|
168
|
+
assets_by_id: dict[uuid.UUID, Asset] = {asset.id: asset for asset in assets}
|
|
169
|
+
outgoing: dict[uuid.UUID, list[Relationship]] = {}
|
|
170
|
+
incoming: dict[uuid.UUID, list[Relationship]] = {}
|
|
171
|
+
|
|
172
|
+
for rel in relationships:
|
|
173
|
+
# Skip relationships with missing endpoints
|
|
174
|
+
if rel.source_asset_id not in assets_by_id:
|
|
175
|
+
continue
|
|
176
|
+
if rel.target_asset_id not in assets_by_id:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
# Task 11.1: Edge Kind Inference for Legacy Data
|
|
180
|
+
# Create a copy if we need to infer edge_kind to avoid mutating input
|
|
181
|
+
if rel.edge_kind == EdgeKind.UNKNOWN:
|
|
182
|
+
rtype = rel.relationship_type.upper()
|
|
183
|
+
inferred_kind = EdgeKind.UNKNOWN
|
|
184
|
+
|
|
185
|
+
# Structural Edges
|
|
186
|
+
if rtype in ["CONTAINS", "USES", "ALLOWS_TRAFFIC_TO", "ATTACHED_TO", "TRUSTS"]:
|
|
187
|
+
inferred_kind = EdgeKind.STRUCTURAL
|
|
188
|
+
# Capability Edges
|
|
189
|
+
elif rtype.startswith("CAN_") or rtype.startswith("MAY_") or rtype in ["ROUTES_TO", "EXPOSES", "INVOKES", "CONNECTS_TO"]:
|
|
190
|
+
inferred_kind = EdgeKind.CAPABILITY
|
|
191
|
+
|
|
192
|
+
if inferred_kind != EdgeKind.UNKNOWN:
|
|
193
|
+
rel = rel.model_copy(update={"edge_kind": inferred_kind})
|
|
194
|
+
|
|
195
|
+
outgoing.setdefault(rel.source_asset_id, []).append(rel)
|
|
196
|
+
incoming.setdefault(rel.target_asset_id, []).append(rel)
|
|
197
|
+
|
|
198
|
+
return AwsGraph(
|
|
199
|
+
assets_by_id=assets_by_id,
|
|
200
|
+
outgoing=outgoing,
|
|
201
|
+
incoming=incoming,
|
|
202
|
+
)
|