mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-31 21:27:28 +00:00
Compare commits
6 Commits
5.13.0
...
PRWLR-5556
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16f301e05e | ||
|
|
2b4f4bea95 | ||
|
|
0b2e8759a4 | ||
|
|
10907f8f91 | ||
|
|
c23f6f1549 | ||
|
|
d1792f10d7 |
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user