Compare commits

...

6 Commits

Author SHA1 Message Date
Andoni A.
16f301e05e chore: add warning in metadata about private activity 2025-05-26 14:17:16 +02:00
Andoni A.
2b4f4bea95 fix: dont return findings if we cannot check members list 2025-05-26 12:23:11 +02:00
Andoni A.
0b2e8759a4 chore: remove extra comment 2025-05-26 11:50:48 +02:00
Andoni A.
10907f8f91 chore: update CHANGELOG 2025-05-26 11:49:33 +02:00
Andoni A.
c23f6f1549 feat(organization): add new check organization_members_inactive 2025-05-26 11:48:42 +02:00
Andoni A.
d1792f10d7 fix: repository_inactive_not_archived tests 2025-05-26 11:30:01 +02:00
9 changed files with 468 additions and 3 deletions

View File

@@ -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. [(#7836)](https://github.com/prowler-cloud/prowler/pull/7836)
### Fixed
- Fix `m365_powershell test_credentials` to use sanitized credentials. [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761)

View File

@@ -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": "It may generate false positives if the member only has contribution in private repositories."
}

View File

@@ -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.
"""
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():
if org.members is not None:
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

View File

@@ -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,68 @@ 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}"
)
members = None
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 +109,4 @@ class Org(BaseModel):
id: int
name: str
mfa_required: Optional[bool] = False
members: list[OrgMember] = []

View File

@@ -20,9 +20,7 @@ class repository_inactive_not_archived(Check):
)
for repo in repository_client.repositories.values():
report = CheckReportGithub(
metadata=self.metadata(), resource=repo, repository=repo.name
)
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
if repo.archived:
report.status = "PASS"

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ class Test_repository_inactive_not_archived:
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/test-repo",
private=False,
default_branch=default_branch,
@@ -85,6 +86,7 @@ class Test_repository_inactive_not_archived:
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/test-repo",
private=False,
default_branch=default_branch,
@@ -128,6 +130,7 @@ class Test_repository_inactive_not_archived:
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/test-repo",
default_branch=default_branch,
private=False,
@@ -173,6 +176,7 @@ class Test_repository_inactive_not_archived:
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/test-repo",
private=False,
default_branch=default_branch,