diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 89cec16b9a..7e540c5097 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Add `repository_dependency_scanning_enabled` check for GitHub provider. [(#7771)](https://github.com/prowler-cloud/prowler/pull/7771) - Add `repository_secret_scanning_enabled` check for GitHub provider. [(#7759)](https://github.com/prowler-cloud/prowler/pull/7759) - Add `repository_default_branch_requires_codeowners_review` check for GitHub provider. [(#7753)](https://github.com/prowler-cloud/prowler/pull/7753) +- Add `organization_members_inactive` check for GitHub provider. [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) ### Fixed - Fix `m365_powershell test_credentials` to use sanitized credentials. [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761) diff --git a/prowler/providers/github/services/organization/organization_members_inactive/__init__.py b/prowler/providers/github/services/organization/organization_members_inactive/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/organization/organization_members_inactive/organization_members_inactive.metadata.json b/prowler/providers/github/services/organization/organization_members_inactive/organization_members_inactive.metadata.json new file mode 100644 index 0000000000..d6297f44d8 --- /dev/null +++ b/prowler/providers/github/services/organization/organization_members_inactive/organization_members_inactive.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "organization_members_inactive", + "CheckTitle": "Ensure organization members are not inactive for extended periods", + "CheckType": [], + "ServiceName": "organization", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "GitHubOrganization", + "Description": "Monitor and review user accounts that have been inactive for a significant period. Inactive accounts increase the risk of unauthorized access, particularly if they have elevated privileges. By removing or deactivating these accounts, organizations can reduce their exposure to potential attacks.", + "Risk": "Inactive user accounts pose security risks as they may be compromised without detection, especially if they retain elevated privileges or access to sensitive resources.", + "RelatedUrl": "https://docs.github.com/en/organizations/managing-membership-in-your-organization/removing-a-member-from-your-organization", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Review inactive organization members and consider removing or deactivating accounts that have been inactive for extended periods. Establish a regular review process for user access and implement automated monitoring for inactive accounts.", + "Url": "https://docs.github.com/en/organizations/managing-membership-in-your-organization/removing-a-member-from-your-organization" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/organization/organization_members_inactive/organization_members_inactive.py b/prowler/providers/github/services/organization/organization_members_inactive/organization_members_inactive.py new file mode 100644 index 0000000000..5b1f7392ec --- /dev/null +++ b/prowler/providers/github/services/organization/organization_members_inactive/organization_members_inactive.py @@ -0,0 +1,60 @@ +from datetime import datetime, timedelta, timezone +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.organization.organization_client import ( + organization_client, +) + + +class organization_members_inactive(Check): + """Check if organization members have been inactive for extended periods. + + This class verifies whether organization members have recent activity within the last 30 days. + Inactive accounts increase the risk of unauthorized access, particularly if they have elevated privileges. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Organization Members Inactive check. + + Iterates over all organizations and checks if members have been inactive for extended periods. + + Returns: + List[CheckReportGithub]: A list of reports for each organization + """ + findings = [] + + # Max inactivity threshold is 30 days due to GitHub API limitation + inactivity_threshold = timedelta(days=30) + current_time = datetime.now(timezone.utc) + + for org in organization_client.organizations.values(): + report = CheckReportGithub(metadata=self.metadata(), resource=org) + + inactive_members = [] + + for member in org.members: + is_inactive = False + + if member.last_activity is None: + is_inactive = True + else: + time_since_activity = current_time - member.last_activity + if time_since_activity > inactivity_threshold: + is_inactive = True + + if is_inactive: + inactive_members.append(member.login) + + if inactive_members: + report.status = "FAIL" + report.status_extended = f"Organization {org.name} has {len(inactive_members)} inactive members: {', '.join(inactive_members[:5])}{'...' if len(inactive_members) > 5 else ''}" + else: + report.status = "PASS" + report.status_extended = ( + f"Organization {org.name} has no inactive members detected" + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/organization/organization_service.py b/prowler/providers/github/services/organization/organization_service.py index 3f8f8dca95..9ac4cea597 100644 --- a/prowler/providers/github/services/organization/organization_service.py +++ b/prowler/providers/github/services/organization/organization_service.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from pydantic import BaseModel @@ -24,10 +25,14 @@ class Organization(GithubService): logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + + members = self._get_organization_members_with_activity(client, org) + organizations[org.id] = Org( id=org.id, name=org.login, mfa_required=require_mfa, + members=members, ) except Exception as error: logger.error( @@ -35,6 +40,67 @@ class Organization(GithubService): ) return organizations + def _get_organization_members_with_activity(self, client, org): + """Get organization members with their last activity information.""" + members = [] + try: + for member in org.get_members(): + try: + last_activity = self._get_user_last_activity(client, member.login) + + members.append( + OrgMember( + id=member.id, + login=member.login, + last_activity=last_activity, + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + members.append( + OrgMember( + id=member.id, + login=member.login, + last_activity=None, + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return members + + def _get_user_last_activity(self, client, username): + """Get the last activity date for a user based on their recent events.""" + try: + user = client.get_user(username) + events = user.get_events() + + # Get the first (most recent) event + try: + latest_event = events[0] + return latest_event.created_at + except (IndexError, StopIteration): + # No events found - user has no recent activity + return None + + except Exception as error: + logger.error( + f"Error getting events for user {username}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + +class OrgMember(BaseModel): + """Model for Github Organization Member""" + + id: int + login: str + last_activity: Optional[datetime] = None + class Org(BaseModel): """Model for Github Organization""" @@ -42,3 +108,4 @@ class Org(BaseModel): id: int name: str mfa_required: Optional[bool] = False + members: list[OrgMember] = [] diff --git a/tests/providers/github/services/organization/organization_members_inactive/organization_members_inactive_test.py b/tests/providers/github/services/organization/organization_members_inactive/organization_members_inactive_test.py new file mode 100644 index 0000000000..aca73a3a68 --- /dev/null +++ b/tests/providers/github/services/organization/organization_members_inactive/organization_members_inactive_test.py @@ -0,0 +1,287 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.github.services.organization.organization_service import ( + Org, + OrgMember, +) +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_organization_members_inactive: + def test_no_organizations(self): + organization_client = mock.MagicMock + organization_client.organizations = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive import ( + organization_members_inactive, + ) + + check = organization_members_inactive() + result = check.execute() + assert len(result) == 0 + + def test_organization_with_no_members(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=True, + members=[], + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive import ( + organization_members_inactive, + ) + + check = organization_members_inactive() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == org_name + assert result[0].status == "PASS" + assert "no inactive members detected" in result[0].status_extended + + def test_organization_with_active_members(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=True, + members=[ + OrgMember( + id=123, + login="active_user", + last_activity=datetime.now(timezone.utc) - timedelta(days=5), + ) + ], + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive import ( + organization_members_inactive, + ) + + check = organization_members_inactive() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == org_name + assert result[0].status == "PASS" + assert "no inactive members detected" in result[0].status_extended + + def test_organization_with_inactive_members_no_activity(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=True, + members=[ + OrgMember( + id=123, + login="inactive_user", + last_activity=None, + ) + ], + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive import ( + organization_members_inactive, + ) + + check = organization_members_inactive() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == org_name + assert result[0].status == "FAIL" + assert "1 inactive members" in result[0].status_extended + assert "inactive_user" in result[0].status_extended + + def test_organization_with_inactive_members_old_activity(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=True, + members=[ + OrgMember( + id=123, + login="old_inactive_user", + last_activity=datetime.now(timezone.utc) - timedelta(days=35), + ) + ], + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive import ( + organization_members_inactive, + ) + + check = organization_members_inactive() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == org_name + assert result[0].status == "FAIL" + assert "1 inactive members" in result[0].status_extended + assert "old_inactive_user" in result[0].status_extended + + def test_organization_with_mixed_members(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=True, + members=[ + OrgMember( + id=123, + login="active_member", + last_activity=datetime.now(timezone.utc) - timedelta(days=5), + ), + OrgMember( + id=124, + login="inactive_user1", + last_activity=None, + ), + OrgMember( + id=125, + login="inactive_user2", + last_activity=datetime.now(timezone.utc) - timedelta(days=35), + ), + ], + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive import ( + organization_members_inactive, + ) + + check = organization_members_inactive() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == org_name + assert result[0].status == "FAIL" + assert "2 inactive members" in result[0].status_extended + assert "inactive_user1" in result[0].status_extended + assert "inactive_user2" in result[0].status_extended + assert "active_member" not in result[0].status_extended + + def test_organization_with_many_inactive_members(self): + organization_client = mock.MagicMock + org_name = "test-organization" + # Create many inactive members to test truncation + inactive_members = [] + for i in range(10): + inactive_members.append( + OrgMember( + id=100 + i, + login=f"inactive_user_{i}", + last_activity=None, + ) + ) + + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=True, + members=inactive_members, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_members_inactive.organization_members_inactive import ( + organization_members_inactive, + ) + + check = organization_members_inactive() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == org_name + assert result[0].status == "FAIL" + assert "10 inactive members" in result[0].status_extended + assert "..." in result[0].status_extended # Should truncate after 5 names diff --git a/tests/providers/github/services/organization/organization_service_test.py b/tests/providers/github/services/organization/organization_service_test.py index 7720841698..9229701b19 100644 --- a/tests/providers/github/services/organization/organization_service_test.py +++ b/tests/providers/github/services/organization/organization_service_test.py @@ -2,6 +2,7 @@ from unittest.mock import patch from prowler.providers.github.services.organization.organization_service import ( Org, + OrgMember, Organization, ) from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -13,6 +14,13 @@ def mock_list_organizations(_): id=1, name="test-organization", mfa_required=True, + members=[ + OrgMember( + id=123, + login="test-user", + last_activity=None, + ) + ], ), } @@ -35,3 +43,12 @@ class Test_Repository_Service: assert len(repository_service.organizations) == 1 assert repository_service.organizations[1].name == "test-organization" assert repository_service.organizations[1].mfa_required + + def test_list_organizations_with_members(self): + repository_service = Organization(set_mocked_github_provider()) + assert len(repository_service.organizations) == 1 + org = repository_service.organizations[1] + assert len(org.members) == 1 + assert org.members[0].login == "test-user" + assert org.members[0].id == 123 + assert org.members[0].last_activity is None