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,153 @@
1
+ """
2
+ AWS Credential Provider - Handle role assumption and profiles.
3
+
4
+ Supports:
5
+ - AssumeRole with optional external ID
6
+ - AWS CLI profiles
7
+ - Default credential chain (env vars, instance metadata)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+
16
+ import boto3
17
+ from botocore.exceptions import ClientError
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class RoleCredentials:
24
+ """Temporary credentials from AssumeRole."""
25
+
26
+ access_key_id: str
27
+ secret_access_key: str
28
+ session_token: str
29
+ expiration: datetime
30
+ assumed_role_arn: str
31
+
32
+
33
+ class CredentialProvider:
34
+ """
35
+ AWS credential provider for CLI mode.
36
+
37
+ Example:
38
+ provider = CredentialProvider(profile="my-profile")
39
+ session = provider.assume_role(
40
+ role_arn="arn:aws:iam::123456789012:role/ReadOnly",
41
+ external_id="my-external-id"
42
+ )
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ profile: str | None = None,
49
+ region: str = "us-east-1",
50
+ ):
51
+ self._profile = profile
52
+ self._region = region
53
+ self._base_session: boto3.Session | None = None
54
+
55
+ def _get_base_session(self) -> boto3.Session:
56
+ """Get or create the base boto3 session."""
57
+ if self._base_session is None:
58
+ self._base_session = boto3.Session(
59
+ profile_name=self._profile,
60
+ region_name=self._region,
61
+ )
62
+ return self._base_session
63
+
64
+ def default_session(self) -> boto3.Session:
65
+ """
66
+ Return the base boto3 session (default credentials).
67
+
68
+ Uses the profile and region configured at initialization.
69
+ """
70
+ return self._get_base_session()
71
+
72
+ def assume_role(
73
+ self,
74
+ role_arn: str,
75
+ *,
76
+ external_id: str | None = None,
77
+ session_name: str = "cyntrisec-cli",
78
+ duration_seconds: int = 3600,
79
+ ) -> boto3.Session:
80
+ """
81
+ Assume an IAM role and return a configured boto3 session.
82
+
83
+ Args:
84
+ role_arn: ARN of the role to assume
85
+ external_id: External ID for the role (optional)
86
+ session_name: Name for the assumed role session
87
+ duration_seconds: How long the credentials are valid
88
+
89
+ Returns:
90
+ boto3.Session configured with temporary credentials
91
+ """
92
+ base = self._get_base_session()
93
+ sts = base.client("sts")
94
+
95
+ assume_kwargs = {
96
+ "RoleArn": role_arn,
97
+ "RoleSessionName": session_name,
98
+ "DurationSeconds": duration_seconds,
99
+ }
100
+ if external_id:
101
+ assume_kwargs["ExternalId"] = external_id
102
+
103
+ log.info("Assuming role: %s", role_arn)
104
+
105
+ try:
106
+ response = sts.assume_role(**assume_kwargs)
107
+ except ClientError as e:
108
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
109
+ if error_code == "AccessDenied":
110
+ raise PermissionError(
111
+ f"Access denied when assuming role {role_arn}. "
112
+ "Check that your credentials can assume this role."
113
+ ) from e
114
+ raise
115
+
116
+ creds = response["Credentials"]
117
+
118
+ return boto3.Session(
119
+ aws_access_key_id=creds["AccessKeyId"],
120
+ aws_secret_access_key=creds["SecretAccessKey"],
121
+ aws_session_token=creds["SessionToken"],
122
+ region_name=self._region,
123
+ )
124
+
125
+ def get_caller_identity(self) -> dict:
126
+ """Get the identity of the current credentials."""
127
+ session = self._get_base_session()
128
+ sts = session.client("sts")
129
+ return sts.get_caller_identity()
130
+
131
+ def validate_role(
132
+ self,
133
+ role_arn: str,
134
+ *,
135
+ external_id: str | None = None,
136
+ ) -> bool:
137
+ """
138
+ Validate that a role can be assumed.
139
+
140
+ Returns True if role assumption succeeds.
141
+ """
142
+ try:
143
+ session = self.assume_role(
144
+ role_arn,
145
+ external_id=external_id,
146
+ duration_seconds=900, # Minimum
147
+ )
148
+ # Verify we can make a call
149
+ session.client("sts").get_caller_identity()
150
+ return True
151
+ except Exception as e:
152
+ log.warning("Role validation failed for %s: %s", role_arn, e)
153
+ return False
@@ -0,0 +1,17 @@
1
+ """AWS resource normalizers - transform raw data to canonical schema."""
2
+
3
+ from cyntrisec.aws.normalizers.ec2 import Ec2Normalizer
4
+ from cyntrisec.aws.normalizers.iam import IamNormalizer
5
+ from cyntrisec.aws.normalizers.lambda_ import LambdaNormalizer
6
+ from cyntrisec.aws.normalizers.network import NetworkNormalizer
7
+ from cyntrisec.aws.normalizers.rds import RdsNormalizer
8
+ from cyntrisec.aws.normalizers.s3 import S3Normalizer
9
+
10
+ __all__ = [
11
+ "Ec2Normalizer",
12
+ "IamNormalizer",
13
+ "S3Normalizer",
14
+ "LambdaNormalizer",
15
+ "RdsNormalizer",
16
+ "NetworkNormalizer",
17
+ ]
@@ -0,0 +1,115 @@
1
+ """EC2 Normalizer - Transform EC2 data to canonical schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any
7
+
8
+ from cyntrisec.core.schema import Asset, Finding, FindingSeverity, Relationship
9
+
10
+
11
+ class Ec2Normalizer:
12
+ """Normalize EC2 data to canonical assets, relationships, and findings."""
13
+
14
+ def __init__(
15
+ self,
16
+ snapshot_id: uuid.UUID,
17
+ region: str,
18
+ account_id: str,
19
+ ):
20
+ self._snapshot_id = snapshot_id
21
+ self._region = region
22
+ self._account_id = account_id
23
+
24
+ def normalize(
25
+ self,
26
+ data: dict[str, Any],
27
+ ) -> tuple[list[Asset], list[Relationship], list[Finding]]:
28
+ """Normalize EC2 data."""
29
+ assets: list[Asset] = []
30
+ relationships: list[Relationship] = []
31
+ findings: list[Finding] = []
32
+
33
+ for instance in data.get("instances", []):
34
+ asset, rels, findings_list = self._normalize_instance(instance)
35
+ assets.append(asset)
36
+ relationships.extend(rels)
37
+ findings.extend(findings_list)
38
+
39
+ return assets, relationships, findings
40
+
41
+ def _normalize_instance(
42
+ self,
43
+ instance: dict[str, Any],
44
+ ) -> tuple[Asset, list[Relationship], list[Finding]]:
45
+ """Normalize a single EC2 instance."""
46
+ instance_id = instance["InstanceId"]
47
+ instance_type = instance.get("InstanceType", "unknown")
48
+ state = instance.get("State", {}).get("Name", "unknown")
49
+
50
+ # Get name from tags
51
+ name = instance_id
52
+ tags = {}
53
+ for tag in instance.get("Tags", []):
54
+ tags[tag["Key"]] = tag["Value"]
55
+ if tag["Key"] == "Name":
56
+ name = tag["Value"]
57
+
58
+ # Determine if internet-facing
59
+ public_ip = instance.get("PublicIpAddress")
60
+ is_internet_facing = bool(public_ip)
61
+
62
+ asset = Asset(
63
+ snapshot_id=self._snapshot_id,
64
+ asset_type="ec2:instance",
65
+ aws_region=self._region,
66
+ aws_resource_id=instance_id,
67
+ arn=f"arn:aws:ec2:{self._region}:{self._account_id}:instance/{instance_id}",
68
+ name=name,
69
+ properties={
70
+ "instance_type": instance_type,
71
+ "state": state,
72
+ "vpc_id": instance.get("VpcId"),
73
+ "subnet_id": instance.get("SubnetId"),
74
+ "public_ip": public_ip,
75
+ "private_ip": instance.get("PrivateIpAddress"),
76
+ "security_groups": [sg["GroupId"] for sg in instance.get("SecurityGroups", [])],
77
+ "iam_instance_profile": instance.get("IamInstanceProfile", {}).get("Arn"),
78
+ },
79
+ tags=tags,
80
+ is_internet_facing=is_internet_facing,
81
+ )
82
+
83
+ findings: list[Finding] = []
84
+
85
+ # Check for public IP
86
+ if public_ip:
87
+ findings.append(
88
+ Finding(
89
+ snapshot_id=self._snapshot_id,
90
+ asset_id=asset.id,
91
+ finding_type="ec2-public-ip",
92
+ severity=FindingSeverity.info,
93
+ title=f"EC2 instance {instance_id} has public IP",
94
+ description=f"Instance has public IP address {public_ip}",
95
+ evidence={"public_ip": public_ip},
96
+ )
97
+ )
98
+
99
+ # Check for missing IMDSv2
100
+ metadata_options = instance.get("MetadataOptions", {})
101
+ if metadata_options.get("HttpTokens") != "required":
102
+ findings.append(
103
+ Finding(
104
+ snapshot_id=self._snapshot_id,
105
+ asset_id=asset.id,
106
+ finding_type="ec2-imdsv1-enabled",
107
+ severity=FindingSeverity.medium,
108
+ title=f"EC2 instance {instance_id} allows IMDSv1",
109
+ description="Instance Metadata Service v1 is enabled, which is vulnerable to SSRF attacks",
110
+ remediation="Require IMDSv2 by setting HttpTokens to 'required'",
111
+ evidence={"metadata_options": metadata_options},
112
+ )
113
+ )
114
+
115
+ return asset, [], findings
@@ -0,0 +1,182 @@
1
+ """IAM Normalizer - Transform IAM data to canonical schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from typing import Any
8
+
9
+ from cyntrisec.core.schema import Asset, Finding, FindingSeverity, Relationship
10
+
11
+
12
+ class IamNormalizer:
13
+ """Normalize IAM data to canonical assets and relationships."""
14
+
15
+ def __init__(self, snapshot_id: uuid.UUID):
16
+ self._snapshot_id = snapshot_id
17
+ self._role_assets: dict[str, Asset] = {}
18
+
19
+ def normalize(
20
+ self,
21
+ data: dict[str, Any],
22
+ ) -> tuple[list[Asset], list[Relationship], list[Finding]]:
23
+ """Normalize IAM data."""
24
+ assets: list[Asset] = []
25
+ relationships: list[Relationship] = []
26
+ findings: list[Finding] = []
27
+
28
+ # Normalize users
29
+ for user in data.get("users", []):
30
+ asset, user_findings = self._normalize_user(user)
31
+ assets.append(asset)
32
+ findings.extend(user_findings)
33
+
34
+ # Normalize roles
35
+ for role in data.get("roles", []):
36
+ asset, rels, role_findings = self._normalize_role(role)
37
+ assets.append(asset)
38
+ self._role_assets[role["RoleName"]] = asset
39
+ relationships.extend(rels)
40
+ findings.extend(role_findings)
41
+
42
+ # Normalize instance profiles
43
+ for profile in data.get("instance_profiles", []):
44
+ asset = self._normalize_instance_profile(profile)
45
+ assets.append(asset)
46
+
47
+ return assets, relationships, findings
48
+
49
+ def _normalize_user(
50
+ self,
51
+ user: dict[str, Any],
52
+ ) -> tuple[Asset, list[Finding]]:
53
+ """Normalize an IAM user."""
54
+ user_name = user["UserName"]
55
+ user_arn = user["Arn"]
56
+
57
+ asset = Asset(
58
+ snapshot_id=self._snapshot_id,
59
+ asset_type="iam:user",
60
+ aws_resource_id=user_arn,
61
+ arn=user_arn,
62
+ name=user_name,
63
+ properties={
64
+ "user_id": user.get("UserId"),
65
+ "created_date": str(user.get("CreateDate")),
66
+ "password_last_used": str(user.get("PasswordLastUsed"))
67
+ if user.get("PasswordLastUsed")
68
+ else None,
69
+ },
70
+ )
71
+
72
+ findings: list[Finding] = []
73
+
74
+ # Check for root user
75
+ if user_name == "root":
76
+ findings.append(
77
+ Finding(
78
+ snapshot_id=self._snapshot_id,
79
+ asset_id=asset.id,
80
+ finding_type="iam-root-user",
81
+ severity=FindingSeverity.info,
82
+ title="Root user exists",
83
+ description="The AWS root user should only be used for account management tasks",
84
+ )
85
+ )
86
+
87
+ return asset, findings
88
+
89
+ def _normalize_role(
90
+ self,
91
+ role: dict[str, Any],
92
+ ) -> tuple[Asset, list[Relationship], list[Finding]]:
93
+ """Normalize an IAM role with trust relationships."""
94
+ role_name = role["RoleName"]
95
+ role_arn = role["Arn"]
96
+
97
+ # Check if this is a sensitive/admin role
98
+ is_sensitive = any(
99
+ kw in role_name.lower() for kw in ["admin", "root", "power", "full-access"]
100
+ )
101
+
102
+ attached_policies = role.get("AttachedPolicies", []) or []
103
+ inline_policies = role.get("InlinePolicies", []) or []
104
+ policy_documents = [
105
+ p.get("Document") for p in attached_policies if p.get("Document")
106
+ ] + [
107
+ p.get("Document") for p in inline_policies if p.get("Document")
108
+ ]
109
+
110
+ # Parse and store trust policy
111
+ trust_policy = role.get("AssumeRolePolicyDocument")
112
+ if trust_policy and isinstance(trust_policy, str):
113
+ trust_policy = json.loads(trust_policy)
114
+
115
+ asset = Asset(
116
+ snapshot_id=self._snapshot_id,
117
+ asset_type="iam:role",
118
+ aws_resource_id=role_arn,
119
+ arn=role_arn,
120
+ name=role_name,
121
+ properties={
122
+ "role_id": role.get("RoleId"),
123
+ "created_date": str(role.get("CreateDate")),
124
+ "max_session_duration": role.get("MaxSessionDuration"),
125
+ "description": role.get("Description"),
126
+ "attached_policies": attached_policies,
127
+ "inline_policies": inline_policies,
128
+ "policy_documents": policy_documents,
129
+ "trust_policy": trust_policy,
130
+ },
131
+ is_sensitive_target=is_sensitive,
132
+ )
133
+
134
+ relationships: list[Relationship] = []
135
+ findings: list[Finding] = []
136
+
137
+ # Check trust policy for security issues (already parsed above)
138
+ if trust_policy:
139
+ for statement in trust_policy.get("Statement", []):
140
+ if statement.get("Effect") != "Allow":
141
+ continue
142
+
143
+ principal = statement.get("Principal", {})
144
+
145
+ # Check for overly permissive trust
146
+ if principal == "*" or principal.get("AWS") == "*":
147
+ findings.append(
148
+ Finding(
149
+ snapshot_id=self._snapshot_id,
150
+ asset_id=asset.id,
151
+ finding_type="iam-role-trust-any-principal",
152
+ severity=FindingSeverity.critical,
153
+ title=f"IAM role {role_name} trusts any principal",
154
+ description="Role trust policy allows any AWS principal to assume it",
155
+ remediation="Restrict the Principal to specific AWS accounts or roles",
156
+ evidence={"trust_policy": trust_policy},
157
+ )
158
+ )
159
+
160
+ return asset, relationships, findings
161
+
162
+ def _normalize_instance_profile(self, profile: dict[str, Any]) -> Asset:
163
+ """Normalize an IAM instance profile."""
164
+ profile_name = profile.get("InstanceProfileName")
165
+ profile_arn = profile.get("Arn")
166
+ roles = profile.get("Roles", [])
167
+ role_arns = [r.get("Arn") for r in roles if r.get("Arn")]
168
+ role_arn = role_arns[0] if role_arns else None
169
+
170
+ return Asset(
171
+ snapshot_id=self._snapshot_id,
172
+ asset_type="iam:instance-profile",
173
+ aws_resource_id=profile_arn or profile_name,
174
+ arn=profile_arn,
175
+ name=profile_name or profile_arn or "instance-profile",
176
+ properties={
177
+ "instance_profile_id": profile.get("InstanceProfileId"),
178
+ "created_date": str(profile.get("CreateDate")),
179
+ "role_arn": role_arn,
180
+ "role_arns": role_arns,
181
+ },
182
+ )
@@ -0,0 +1,83 @@
1
+ """Lambda Normalizer - Transform Lambda data to canonical schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any
7
+
8
+ from cyntrisec.core.schema import Asset, Finding, FindingSeverity, Relationship
9
+
10
+
11
+ class LambdaNormalizer:
12
+ """Normalize Lambda data to canonical assets."""
13
+
14
+ def __init__(
15
+ self,
16
+ snapshot_id: uuid.UUID,
17
+ region: str,
18
+ account_id: str,
19
+ ):
20
+ self._snapshot_id = snapshot_id
21
+ self._region = region
22
+ self._account_id = account_id
23
+
24
+ def normalize(
25
+ self,
26
+ data: dict[str, Any],
27
+ ) -> tuple[list[Asset], list[Relationship], list[Finding]]:
28
+ """Normalize Lambda data."""
29
+ assets: list[Asset] = []
30
+ findings: list[Finding] = []
31
+
32
+ for func in data.get("functions", []):
33
+ asset, func_findings = self._normalize_function(func)
34
+ assets.append(asset)
35
+ findings.extend(func_findings)
36
+
37
+ return assets, [], findings
38
+
39
+ def _normalize_function(
40
+ self,
41
+ func: dict[str, Any],
42
+ ) -> tuple[Asset, list[Finding]]:
43
+ """Normalize a Lambda function."""
44
+ func_name = func["FunctionName"]
45
+ func_arn = func["FunctionArn"]
46
+
47
+ asset = Asset(
48
+ snapshot_id=self._snapshot_id,
49
+ asset_type="lambda:function",
50
+ aws_region=self._region,
51
+ aws_resource_id=func_arn,
52
+ arn=func_arn,
53
+ name=func_name,
54
+ properties={
55
+ "runtime": func.get("Runtime"),
56
+ "handler": func.get("Handler"),
57
+ "memory_size": func.get("MemorySize"),
58
+ "timeout": func.get("Timeout"),
59
+ "role": func.get("Role"),
60
+ "vpc_config": func.get("VpcConfig"),
61
+ "last_modified": func.get("LastModified"),
62
+ },
63
+ )
64
+
65
+ findings: list[Finding] = []
66
+
67
+ # Check for deprecated runtime
68
+ runtime = func.get("Runtime", "")
69
+ deprecated_runtimes = ["python2.7", "python3.6", "nodejs10.x", "nodejs12.x", "ruby2.5"]
70
+ if any(runtime.startswith(dr) for dr in deprecated_runtimes):
71
+ findings.append(
72
+ Finding(
73
+ snapshot_id=self._snapshot_id,
74
+ asset_id=asset.id,
75
+ finding_type="lambda-deprecated-runtime",
76
+ severity=FindingSeverity.medium,
77
+ title=f"Lambda function {func_name} uses deprecated runtime",
78
+ description=f"Function uses runtime {runtime} which is deprecated or EOL",
79
+ remediation="Upgrade to a supported runtime version",
80
+ )
81
+ )
82
+
83
+ return asset, findings