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
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
@@ -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
+ )