mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-21 18:58:04 +00:00
feat(organizations): add OU metadata to outputs (#10283)
Co-authored-by: Raajhesh Kannaa Chidambaram <495042+raajheshkannaa@users.noreply.github.com> Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
committed by
GitHub
parent
125ba830f7
commit
39385567fc
@@ -11,6 +11,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Add `trusted_ips` configurable option to `opensearch_service_domains_not_publicly_accessible` check to reduce false positives on IP-restricted policies [(#8631)](https://github.com/prowler-cloud/prowler/pull/8631)
|
||||
- `guardduty_delegated_admin_enabled_all_regions` check for AWS provider [(#9867)](https://github.com/prowler-cloud/prowler/pull/9867)
|
||||
- OpenStack object storage service with 7 checks [(#10258)](https://github.com/prowler-cloud/prowler/pull/10258)
|
||||
- Add AWS Organizations OU metadata (OU ID, OU path) to ASFF, OCSF and CSV outputs [(#10283)](https://github.com/prowler-cloud/prowler/pull/10283)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ class ASFF(Output):
|
||||
ProductArn=f"arn:{finding.partition}:securityhub:{finding.region}::product/prowler/prowler",
|
||||
ProductFields=ProductFields(
|
||||
ProwlerResourceName=finding.resource_uid,
|
||||
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
|
||||
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
|
||||
),
|
||||
GeneratorId="prowler-" + finding.metadata.CheckID,
|
||||
AwsAccountId=finding.account_uid,
|
||||
@@ -242,6 +244,8 @@ class ProductFields(BaseModel):
|
||||
ProviderName: str = "Prowler"
|
||||
ProviderVersion: str = prowler_version
|
||||
ProwlerResourceName: str
|
||||
ProwlerAccountOrganizationalUnitId: Optional[str] = None
|
||||
ProwlerAccountOrganizationalUnitName: Optional[str] = None
|
||||
|
||||
|
||||
class Severity(BaseModel):
|
||||
|
||||
@@ -29,6 +29,8 @@ class CSV(Output):
|
||||
finding_dict["ACCOUNT_ORGANIZATION_NAME"] = (
|
||||
finding.account_organization_name
|
||||
)
|
||||
finding_dict["ACCOUNT_OU_UID"] = finding.account_ou_uid
|
||||
finding_dict["ACCOUNT_OU_NAME"] = finding.account_ou_name
|
||||
finding_dict["ACCOUNT_TAGS"] = unroll_dict(
|
||||
finding.account_tags, separator=":"
|
||||
)
|
||||
|
||||
@@ -39,6 +39,8 @@ class Finding(BaseModel):
|
||||
account_email: Optional[str] = None
|
||||
account_organization_uid: Optional[str] = None
|
||||
account_organization_name: Optional[str] = None
|
||||
account_ou_uid: Optional[str] = None
|
||||
account_ou_name: Optional[str] = None
|
||||
metadata: CheckMetadata
|
||||
account_tags: dict = Field(default_factory=dict)
|
||||
uid: str
|
||||
@@ -155,6 +157,12 @@ class Finding(BaseModel):
|
||||
output_data["account_tags"] = get_nested_attribute(
|
||||
provider, "organizations_metadata.account_tags"
|
||||
)
|
||||
output_data["account_ou_uid"] = get_nested_attribute(
|
||||
provider, "organizations_metadata.account_ou_id"
|
||||
)
|
||||
output_data["account_ou_name"] = get_nested_attribute(
|
||||
provider, "organizations_metadata.account_ou_name"
|
||||
)
|
||||
output_data["partition"] = get_nested_attribute(
|
||||
provider, "identity.partition"
|
||||
)
|
||||
|
||||
@@ -194,7 +194,8 @@ class OCSF(Output):
|
||||
org=Organization(
|
||||
uid=finding.account_organization_uid,
|
||||
name=finding.account_organization_name,
|
||||
# TODO: add the org unit id and name
|
||||
ou_uid=finding.account_ou_uid,
|
||||
ou_name=finding.account_ou_name,
|
||||
),
|
||||
provider=finding.provider,
|
||||
region=finding.region,
|
||||
|
||||
@@ -435,14 +435,16 @@ class AwsProvider(Provider):
|
||||
f"Getting AWS Organizations metadata for account {aws_account_id}"
|
||||
)
|
||||
|
||||
organizations_metadata, list_tags_for_resource = get_organizations_metadata(
|
||||
aws_account_id=aws_account_id,
|
||||
session=organizations_session,
|
||||
organizations_metadata, list_tags_for_resource, ou_metadata = (
|
||||
get_organizations_metadata(
|
||||
aws_account_id=aws_account_id,
|
||||
session=organizations_session,
|
||||
)
|
||||
)
|
||||
|
||||
if organizations_metadata:
|
||||
organizations_metadata = parse_organizations_metadata(
|
||||
organizations_metadata, list_tags_for_resource
|
||||
organizations_metadata, list_tags_for_resource, ou_metadata
|
||||
)
|
||||
logger.info(
|
||||
f"AWS Organizations metadata retrieved for account {aws_account_id}"
|
||||
|
||||
@@ -5,10 +5,44 @@ from prowler.providers.aws.lib.arn.models import ARN
|
||||
from prowler.providers.aws.models import AWSOrganizationsInfo
|
||||
|
||||
|
||||
def _get_ou_metadata(organizations_client, account_id):
|
||||
try:
|
||||
parents = organizations_client.list_parents(ChildId=account_id)["Parents"]
|
||||
if not parents:
|
||||
return {"ou_id": "", "ou_path": ""}
|
||||
|
||||
parent = parents[0]
|
||||
if parent["Type"] == "ROOT":
|
||||
return {"ou_id": "", "ou_path": ""}
|
||||
|
||||
direct_ou_id = parent["Id"]
|
||||
path_parts = []
|
||||
current_id = direct_ou_id
|
||||
|
||||
while True:
|
||||
ou_info = organizations_client.describe_organizational_unit(
|
||||
OrganizationalUnitId=current_id
|
||||
)
|
||||
path_parts.append(ou_info["OrganizationalUnit"]["Name"])
|
||||
|
||||
parents = organizations_client.list_parents(ChildId=current_id)["Parents"]
|
||||
if not parents or parents[0]["Type"] == "ROOT":
|
||||
break
|
||||
current_id = parents[0]["Id"]
|
||||
|
||||
path_parts.reverse()
|
||||
return {"ou_id": direct_ou_id, "ou_path": "/".join(path_parts)}
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def get_organizations_metadata(
|
||||
aws_account_id: str,
|
||||
session: session.Session,
|
||||
) -> tuple[dict, dict]:
|
||||
) -> tuple[dict, dict, dict]:
|
||||
try:
|
||||
organizations_client = session.client("organizations")
|
||||
|
||||
@@ -19,15 +53,19 @@ def get_organizations_metadata(
|
||||
ResourceId=aws_account_id
|
||||
)
|
||||
|
||||
return organizations_metadata, list_tags_for_resource
|
||||
ou_metadata = _get_ou_metadata(organizations_client, aws_account_id)
|
||||
|
||||
return organizations_metadata, list_tags_for_resource, ou_metadata
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return {}, {}
|
||||
return {}, {}, {}
|
||||
|
||||
|
||||
def parse_organizations_metadata(metadata: dict, tags: dict) -> AWSOrganizationsInfo:
|
||||
def parse_organizations_metadata(
|
||||
metadata: dict, tags: dict, ou_metadata: dict = None
|
||||
) -> AWSOrganizationsInfo:
|
||||
try:
|
||||
# Convert Tags dictionary to String
|
||||
account_details_tags = {}
|
||||
@@ -47,6 +85,8 @@ def parse_organizations_metadata(metadata: dict, tags: dict) -> AWSOrganizations
|
||||
organization_arn=aws_organization_arn,
|
||||
organization_id=aws_organization_id,
|
||||
account_tags=account_details_tags,
|
||||
account_ou_id=ou_metadata.get("ou_id", "") if ou_metadata else "",
|
||||
account_ou_name=ou_metadata.get("ou_path", "") if ou_metadata else "",
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
|
||||
@@ -19,6 +19,8 @@ class AWSOrganizationsInfo:
|
||||
organization_arn: str
|
||||
organization_id: str
|
||||
account_tags: list[str]
|
||||
account_ou_id: str = ""
|
||||
account_ou_name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -55,6 +55,8 @@ class TestASFF:
|
||||
ProductFields=ProductFields(
|
||||
ProviderVersion=prowler_version,
|
||||
ProwlerResourceName=finding.resource_uid,
|
||||
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
|
||||
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
|
||||
),
|
||||
GeneratorId="prowler-" + finding.metadata.CheckID,
|
||||
AwsAccountId=AWS_ACCOUNT_NUMBER,
|
||||
@@ -123,6 +125,8 @@ class TestASFF:
|
||||
ProductFields=ProductFields(
|
||||
ProviderVersion=prowler_version,
|
||||
ProwlerResourceName=finding.resource_uid,
|
||||
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
|
||||
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
|
||||
),
|
||||
GeneratorId="prowler-" + finding.metadata.CheckID,
|
||||
AwsAccountId=AWS_ACCOUNT_NUMBER,
|
||||
@@ -190,6 +194,8 @@ class TestASFF:
|
||||
ProductFields=ProductFields(
|
||||
ProviderVersion=prowler_version,
|
||||
ProwlerResourceName=finding.resource_uid,
|
||||
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
|
||||
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
|
||||
),
|
||||
GeneratorId="prowler-" + finding.metadata.CheckID,
|
||||
AwsAccountId=AWS_ACCOUNT_NUMBER,
|
||||
@@ -261,6 +267,8 @@ class TestASFF:
|
||||
ProductFields=ProductFields(
|
||||
ProviderVersion=prowler_version,
|
||||
ProwlerResourceName=finding.resource_uid,
|
||||
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
|
||||
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
|
||||
),
|
||||
GeneratorId="prowler-" + finding.metadata.CheckID,
|
||||
AwsAccountId=AWS_ACCOUNT_NUMBER,
|
||||
@@ -470,6 +478,8 @@ class TestASFF:
|
||||
ProductFields=ProductFields(
|
||||
ProviderVersion=prowler_version,
|
||||
ProwlerResourceName=finding.resource_uid,
|
||||
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
|
||||
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
|
||||
),
|
||||
GeneratorId="prowler-" + finding.metadata.CheckID,
|
||||
AwsAccountId=AWS_ACCOUNT_NUMBER,
|
||||
@@ -539,6 +549,8 @@ class TestASFF:
|
||||
"ProviderName": "Prowler",
|
||||
"ProviderVersion": prowler_version,
|
||||
"ProwlerResourceName": "test-arn",
|
||||
"ProwlerAccountOrganizationalUnitId": "ou-abc1-12345678",
|
||||
"ProwlerAccountOrganizationalUnitName": "Production/WebServices",
|
||||
},
|
||||
"GeneratorId": "prowler-service_test_check_id",
|
||||
"AwsAccountId": "123456789012",
|
||||
|
||||
@@ -59,6 +59,8 @@ class TestCSV:
|
||||
assert output_data["ACCOUNT_EMAIL"] == ""
|
||||
assert output_data["ACCOUNT_ORGANIZATION_UID"] == "test-organization-id"
|
||||
assert output_data["ACCOUNT_ORGANIZATION_NAME"] == "test-organization"
|
||||
assert output_data["ACCOUNT_OU_UID"] == "ou-abc1-12345678"
|
||||
assert output_data["ACCOUNT_OU_NAME"] == "Production/WebServices"
|
||||
assert isinstance(output_data["ACCOUNT_TAGS"], str)
|
||||
assert output_data["ACCOUNT_TAGS"] == "test-tag:test-value"
|
||||
assert output_data["FINDING_UID"] == "test-unique-finding"
|
||||
@@ -121,7 +123,7 @@ class TestCSV:
|
||||
output.batch_write_data_to_file()
|
||||
|
||||
mock_file.seek(0)
|
||||
expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS\r\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html\r\n"
|
||||
expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_OU_UID;ACCOUNT_OU_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS\r\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;ou-abc1-12345678;Production/WebServices;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html\r\n"
|
||||
content = mock_file.read()
|
||||
|
||||
assert content == expected_csv
|
||||
@@ -199,7 +201,7 @@ class TestCSV:
|
||||
with patch.object(temp_file, "close", return_value=None):
|
||||
csv.batch_write_data_to_file()
|
||||
|
||||
expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html\n"
|
||||
expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_OU_UID;ACCOUNT_OU_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;ou-abc1-12345678;Production/WebServices;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html\n"
|
||||
|
||||
temp_file.seek(0)
|
||||
|
||||
|
||||
@@ -151,6 +151,8 @@ class TestFinding:
|
||||
provider.organizations_metadata.organization_arn = "mock_account_org_uid"
|
||||
provider.organizations_metadata.organization_id = "mock_account_org_name"
|
||||
provider.organizations_metadata.account_tags = {"tag1": "value1"}
|
||||
provider.organizations_metadata.account_ou_id = "ou-test-12345678"
|
||||
provider.organizations_metadata.account_ou_name = "TestOU/SubOU"
|
||||
|
||||
# Mock check result
|
||||
check_output = MagicMock()
|
||||
@@ -204,6 +206,8 @@ class TestFinding:
|
||||
assert finding_output.account_email == "mock_account_email"
|
||||
assert finding_output.account_organization_uid == "mock_account_org_uid"
|
||||
assert finding_output.account_organization_name == "mock_account_org_name"
|
||||
assert finding_output.account_ou_uid == "ou-test-12345678"
|
||||
assert finding_output.account_ou_name == "TestOU/SubOU"
|
||||
assert finding_output.account_tags == {"tag1": "value1"}
|
||||
|
||||
# Metadata
|
||||
@@ -241,6 +245,45 @@ class TestFinding:
|
||||
assert finding_output.service_name == "service"
|
||||
assert finding_output.raw == {}
|
||||
|
||||
def test_generate_output_aws_without_organizations_metadata(self):
|
||||
# Simulates running without --organizations-role
|
||||
provider = MagicMock()
|
||||
provider.type = "aws"
|
||||
provider.identity.profile = "mock_auth"
|
||||
provider.identity.account = "mock_account_uid"
|
||||
provider.identity.partition = "aws"
|
||||
provider.organizations_metadata = None
|
||||
|
||||
check_output = MagicMock()
|
||||
check_output.resource_id = "test_resource_id"
|
||||
check_output.resource_arn = "test_resource_arn"
|
||||
check_output.resource_details = "test_resource_details"
|
||||
check_output.resource_tags = {}
|
||||
check_output.region = "us-east-1"
|
||||
check_output.partition = "aws"
|
||||
check_output.status = Status.PASS
|
||||
check_output.status_extended = "mock_status_extended"
|
||||
check_output.muted = False
|
||||
check_output.check_metadata = mock_check_metadata(provider="aws")
|
||||
check_output.resource = {}
|
||||
check_output.compliance = {}
|
||||
|
||||
output_options = MagicMock()
|
||||
output_options.unix_timestamp = False
|
||||
|
||||
finding_output = Finding.generate_output(provider, check_output, output_options)
|
||||
|
||||
assert isinstance(finding_output, Finding)
|
||||
assert finding_output.account_uid == "mock_account_uid"
|
||||
# get_nested_attribute returns empty string when the attribute chain
|
||||
# is None, so the Finding fields are "" not None
|
||||
assert finding_output.account_name == ""
|
||||
assert finding_output.account_email == ""
|
||||
assert finding_output.account_organization_uid == ""
|
||||
assert finding_output.account_organization_name == ""
|
||||
assert finding_output.account_ou_uid == ""
|
||||
assert finding_output.account_ou_name == ""
|
||||
|
||||
def test_generate_output_azure(self):
|
||||
# Mock provider
|
||||
provider = MagicMock()
|
||||
@@ -799,6 +842,8 @@ class TestFinding:
|
||||
provider.organizations_metadata.organization_arn = "mock_account_org_uid"
|
||||
provider.organizations_metadata.organization_id = "mock_account_org_name"
|
||||
provider.organizations_metadata.account_tags = {"tag1": "value1"}
|
||||
provider.organizations_metadata.account_ou_id = ""
|
||||
provider.organizations_metadata.account_ou_name = ""
|
||||
|
||||
# Mock check result
|
||||
check_output = MagicMock()
|
||||
|
||||
@@ -45,6 +45,8 @@ def generate_finding_output(
|
||||
check_title: str = "service_test_check_id",
|
||||
check_type: list[str] = ["test-type"],
|
||||
provider_uid: str = None,
|
||||
account_ou_uid: str = "ou-abc1-12345678",
|
||||
account_ou_name: str = "Production/WebServices",
|
||||
) -> Finding:
|
||||
return Finding(
|
||||
auth_method="profile: default",
|
||||
@@ -56,6 +58,8 @@ def generate_finding_output(
|
||||
account_organization_uid="test-organization-id",
|
||||
account_organization_name="test-organization",
|
||||
account_tags={"test-tag": "test-value"},
|
||||
account_ou_uid=account_ou_uid,
|
||||
account_ou_name=account_ou_name,
|
||||
uid="test-unique-finding",
|
||||
status=status,
|
||||
status_extended=status_extended,
|
||||
|
||||
@@ -264,6 +264,8 @@ class TestOCSF:
|
||||
"org": {
|
||||
"name": "test-organization",
|
||||
"uid": "test-organization-id",
|
||||
"ou_uid": "ou-abc1-12345678",
|
||||
"ou_name": "Production/WebServices",
|
||||
},
|
||||
"provider": "aws",
|
||||
"region": "eu-west-1",
|
||||
@@ -422,6 +424,8 @@ class TestOCSF:
|
||||
assert isinstance(cloud_organization, Organization)
|
||||
assert cloud_organization.uid == finding_output.account_organization_uid
|
||||
assert cloud_organization.name == finding_output.account_organization_name
|
||||
assert cloud_organization.ou_uid == finding_output.account_ou_uid
|
||||
assert cloud_organization.ou_name == finding_output.account_ou_name
|
||||
|
||||
def test_finding_output_kubernetes(self):
|
||||
finding_output = generate_finding_output(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
from moto import mock_aws
|
||||
|
||||
from prowler.providers.aws.lib.organizations.organizations import (
|
||||
_get_ou_metadata,
|
||||
get_organizations_metadata,
|
||||
parse_organizations_metadata,
|
||||
)
|
||||
@@ -27,8 +31,10 @@ class Test_AWS_Organizations:
|
||||
ResourceId=account_id, Tags=[{"Key": "key", "Value": "value"}]
|
||||
)
|
||||
|
||||
metadata, tags = get_organizations_metadata(account_id, boto3.Session())
|
||||
org = parse_organizations_metadata(metadata, tags)
|
||||
metadata, tags, ou_metadata = get_organizations_metadata(
|
||||
account_id, boto3.Session()
|
||||
)
|
||||
org = parse_organizations_metadata(metadata, tags, ou_metadata)
|
||||
|
||||
assert isinstance(org, AWSOrganizationsInfo)
|
||||
assert org.account_email == mockemail
|
||||
@@ -43,6 +49,8 @@ class Test_AWS_Organizations:
|
||||
)
|
||||
assert org.organization_id == org_id
|
||||
assert org.account_tags == {"key": "value"}
|
||||
assert org.account_ou_id == ""
|
||||
assert org.account_ou_name == ""
|
||||
|
||||
def test_parse_organizations_metadata(self):
|
||||
tags = {"Tags": [{"Key": "test-key", "Value": "test-value"}]}
|
||||
@@ -71,3 +79,244 @@ class Test_AWS_Organizations:
|
||||
)
|
||||
assert org.organization_arn == arn
|
||||
assert org.account_tags == {"test-key": "test-value"}
|
||||
assert org.account_ou_id == ""
|
||||
assert org.account_ou_name == ""
|
||||
|
||||
@mock_aws
|
||||
def test_organizations_with_ou(self):
|
||||
client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1)
|
||||
|
||||
client.create_organization(FeatureSet="ALL")
|
||||
account_id = client.create_account(
|
||||
AccountName="ou-account", Email="ou@example.org"
|
||||
)["CreateAccountStatus"]["AccountId"]
|
||||
|
||||
root_id = client.list_roots()["Roots"][0]["Id"]
|
||||
ou = client.create_organizational_unit(ParentId=root_id, Name="SecurityOU")[
|
||||
"OrganizationalUnit"
|
||||
]
|
||||
|
||||
client.move_account(
|
||||
AccountId=account_id,
|
||||
SourceParentId=root_id,
|
||||
DestinationParentId=ou["Id"],
|
||||
)
|
||||
|
||||
metadata, tags, ou_metadata = get_organizations_metadata(
|
||||
account_id, boto3.Session()
|
||||
)
|
||||
org = parse_organizations_metadata(metadata, tags, ou_metadata)
|
||||
|
||||
assert org.account_ou_id == ou["Id"]
|
||||
assert org.account_ou_name == "SecurityOU"
|
||||
|
||||
@mock_aws
|
||||
def test_organizations_with_nested_ou(self):
|
||||
client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1)
|
||||
|
||||
client.create_organization(FeatureSet="ALL")
|
||||
account_id = client.create_account(
|
||||
AccountName="nested-account", Email="nested@example.org"
|
||||
)["CreateAccountStatus"]["AccountId"]
|
||||
|
||||
root_id = client.list_roots()["Roots"][0]["Id"]
|
||||
parent_ou = client.create_organizational_unit(
|
||||
ParentId=root_id, Name="Infrastructure"
|
||||
)["OrganizationalUnit"]
|
||||
child_ou = client.create_organizational_unit(
|
||||
ParentId=parent_ou["Id"], Name="Security"
|
||||
)["OrganizationalUnit"]
|
||||
|
||||
client.move_account(
|
||||
AccountId=account_id,
|
||||
SourceParentId=root_id,
|
||||
DestinationParentId=child_ou["Id"],
|
||||
)
|
||||
|
||||
metadata, tags, ou_metadata = get_organizations_metadata(
|
||||
account_id, boto3.Session()
|
||||
)
|
||||
org = parse_organizations_metadata(metadata, tags, ou_metadata)
|
||||
|
||||
assert org.account_ou_id == child_ou["Id"]
|
||||
assert org.account_ou_name == "Infrastructure/Security"
|
||||
|
||||
def test_parse_organizations_metadata_with_ou(self):
|
||||
tags = {"Tags": []}
|
||||
metadata = {
|
||||
"Account": {
|
||||
"Id": AWS_ACCOUNT_NUMBER,
|
||||
"Arn": f"arn:aws:organizations::123456789012:account/o-abc123/{AWS_ACCOUNT_NUMBER}",
|
||||
"Email": "test@example.org",
|
||||
"Name": "test-account",
|
||||
"Status": "ACTIVE",
|
||||
}
|
||||
}
|
||||
ou_metadata = {"ou_id": "ou-xxxx-12345678", "ou_path": "Infra/Security"}
|
||||
|
||||
org = parse_organizations_metadata(metadata, tags, ou_metadata)
|
||||
|
||||
assert org.account_ou_id == "ou-xxxx-12345678"
|
||||
assert org.account_ou_name == "Infra/Security"
|
||||
|
||||
def test_get_ou_metadata_api_error_returns_empty_dict(self):
|
||||
client = MagicMock()
|
||||
client.list_parents.side_effect = ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "Access denied"}},
|
||||
"ListParents",
|
||||
)
|
||||
|
||||
result = _get_ou_metadata(client, "123456789012")
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_get_ou_metadata_describe_ou_error_returns_empty_dict(self):
|
||||
client = MagicMock()
|
||||
client.list_parents.return_value = {
|
||||
"Parents": [{"Id": "ou-xxxx-12345678", "Type": "ORGANIZATIONAL_UNIT"}]
|
||||
}
|
||||
client.describe_organizational_unit.side_effect = ClientError(
|
||||
{
|
||||
"Error": {
|
||||
"Code": "OrganizationalUnitNotFoundException",
|
||||
"Message": "OU not found",
|
||||
}
|
||||
},
|
||||
"DescribeOrganizationalUnit",
|
||||
)
|
||||
|
||||
result = _get_ou_metadata(client, "123456789012")
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_get_ou_metadata_deeply_nested_three_levels(self):
|
||||
client = MagicMock()
|
||||
# First call: account's parent is child OU
|
||||
# Second call: child OU's parent is mid OU
|
||||
# Third call: mid OU's parent is top OU
|
||||
# Fourth call: top OU's parent is ROOT
|
||||
client.list_parents.side_effect = [
|
||||
{"Parents": [{"Id": "ou-child", "Type": "ORGANIZATIONAL_UNIT"}]},
|
||||
{"Parents": [{"Id": "ou-mid", "Type": "ORGANIZATIONAL_UNIT"}]},
|
||||
{"Parents": [{"Id": "ou-top", "Type": "ORGANIZATIONAL_UNIT"}]},
|
||||
{"Parents": [{"Id": "r-root", "Type": "ROOT"}]},
|
||||
]
|
||||
client.describe_organizational_unit.side_effect = [
|
||||
{"OrganizationalUnit": {"Id": "ou-child", "Name": "NonProd"}},
|
||||
{"OrganizationalUnit": {"Id": "ou-mid", "Name": "Workloads"}},
|
||||
{"OrganizationalUnit": {"Id": "ou-top", "Name": "Root"}},
|
||||
]
|
||||
|
||||
result = _get_ou_metadata(client, "123456789012")
|
||||
|
||||
assert result == {"ou_id": "ou-child", "ou_path": "Root/Workloads/NonProd"}
|
||||
|
||||
@mock_aws
|
||||
def test_get_organizations_metadata_api_failure_returns_empty_tuples(self):
|
||||
# Use a non-existent account ID without creating an organization
|
||||
metadata, tags, ou_metadata = get_organizations_metadata(
|
||||
"999999999999", boto3.Session()
|
||||
)
|
||||
|
||||
assert metadata == {}
|
||||
assert tags == {}
|
||||
assert ou_metadata == {}
|
||||
|
||||
def test_parse_organizations_metadata_with_empty_ou_metadata(self):
|
||||
tags = {"Tags": []}
|
||||
metadata = {
|
||||
"Account": {
|
||||
"Id": AWS_ACCOUNT_NUMBER,
|
||||
"Arn": f"arn:aws:organizations::123456789012:account/o-abc123/{AWS_ACCOUNT_NUMBER}",
|
||||
"Email": "test@example.org",
|
||||
"Name": "test-account",
|
||||
"Status": "ACTIVE",
|
||||
}
|
||||
}
|
||||
# Simulates the error path where _get_ou_metadata returns {}
|
||||
ou_metadata = {}
|
||||
|
||||
org = parse_organizations_metadata(metadata, tags, ou_metadata)
|
||||
|
||||
assert org.account_ou_id == ""
|
||||
assert org.account_ou_name == ""
|
||||
|
||||
def test_parse_organizations_metadata_with_none_ou_metadata(self):
|
||||
tags = {"Tags": []}
|
||||
metadata = {
|
||||
"Account": {
|
||||
"Id": AWS_ACCOUNT_NUMBER,
|
||||
"Arn": f"arn:aws:organizations::123456789012:account/o-abc123/{AWS_ACCOUNT_NUMBER}",
|
||||
"Email": "test@example.org",
|
||||
"Name": "test-account",
|
||||
"Status": "ACTIVE",
|
||||
}
|
||||
}
|
||||
|
||||
org = parse_organizations_metadata(metadata, tags, None)
|
||||
|
||||
assert org.account_ou_id == ""
|
||||
assert org.account_ou_name == ""
|
||||
|
||||
@mock_aws
|
||||
def test_end_to_end_ou_metadata_flows_to_organizations_info(self):
|
||||
"""Integration test: exercises get_organizations_metadata →
|
||||
parse_organizations_metadata with a nested OU, verifying the full
|
||||
data flow that AwsProvider.get_organizations_info relies on."""
|
||||
client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1)
|
||||
|
||||
client.create_organization(FeatureSet="ALL")
|
||||
account_id = client.create_account(
|
||||
AccountName="e2e-account", Email="e2e@example.org"
|
||||
)["CreateAccountStatus"]["AccountId"]
|
||||
|
||||
root_id = client.list_roots()["Roots"][0]["Id"]
|
||||
top_ou = client.create_organizational_unit(ParentId=root_id, Name="Workloads")[
|
||||
"OrganizationalUnit"
|
||||
]
|
||||
child_ou = client.create_organizational_unit(
|
||||
ParentId=top_ou["Id"], Name="NonProd"
|
||||
)["OrganizationalUnit"]
|
||||
|
||||
client.move_account(
|
||||
AccountId=account_id,
|
||||
SourceParentId=root_id,
|
||||
DestinationParentId=child_ou["Id"],
|
||||
)
|
||||
client.tag_resource(
|
||||
ResourceId=account_id,
|
||||
Tags=[{"Key": "Environment", "Value": "dev"}],
|
||||
)
|
||||
|
||||
# Full flow: get → parse → AWSOrganizationsInfo
|
||||
metadata, tags, ou_metadata = get_organizations_metadata(
|
||||
account_id, boto3.Session()
|
||||
)
|
||||
org = parse_organizations_metadata(metadata, tags, ou_metadata)
|
||||
|
||||
assert isinstance(org, AWSOrganizationsInfo)
|
||||
assert org.account_name == "e2e-account"
|
||||
assert org.account_email == "e2e@example.org"
|
||||
assert org.account_tags == {"Environment": "dev"}
|
||||
assert org.account_ou_id == child_ou["Id"]
|
||||
assert org.account_ou_name == "Workloads/NonProd"
|
||||
|
||||
@mock_aws
|
||||
def test_end_to_end_account_under_root_has_empty_ou(self):
|
||||
"""Integration test: account directly under Root should produce
|
||||
empty OU fields, not errors."""
|
||||
client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1)
|
||||
|
||||
client.create_organization(FeatureSet="ALL")
|
||||
account_id = client.create_account(
|
||||
AccountName="root-account", Email="root@example.org"
|
||||
)["CreateAccountStatus"]["AccountId"]
|
||||
|
||||
metadata, tags, ou_metadata = get_organizations_metadata(
|
||||
account_id, boto3.Session()
|
||||
)
|
||||
org = parse_organizations_metadata(metadata, tags, ou_metadata)
|
||||
|
||||
assert isinstance(org, AWSOrganizationsInfo)
|
||||
assert org.account_ou_id == ""
|
||||
assert org.account_ou_name == ""
|
||||
|
||||
Reference in New Issue
Block a user