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,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
|