feat(repositoy): add new check repository_inactive_not_archived (#7786)

Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
This commit is contained in:
Andoni Alonso
2025-05-26 10:39:09 +02:00
committed by GitHub
parent 50bcd828e9
commit eaec683eb9
25 changed files with 407 additions and 9 deletions
+14
View File
@@ -109,6 +109,15 @@ The following list includes all the Microsoft 365 checks with configurable varia
| `exchange_organization_mailtips_enabled` | `recommended_mailtips_large_audience_threshold` | Integer |
## GitHub
### Configurable Checks
The following list includes all the GitHub checks with configurable variables that can be changed in the configuration yaml file:
| Check Name | Value | Type |
|--------------------------------------------|---------------------------------------------|---------|
| `repository_inactive_not_archived` | `inactive_not_archived_days_threshold` | Integer |
## Config YAML File Structure
???+ note
@@ -525,5 +534,10 @@ m365:
# m365.exchange_organization_mailtips_enabled
recommended_mailtips_large_audience_threshold: 25 # maximum number of recipients
# GitHub Configuration
github:
# github.repository_inactive_not_archived
inactive_not_archived_days_threshold: 180
```
+1
View File
@@ -15,6 +15,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Add CIS 4.0 compliance framework for GCP. [(7785)](https://github.com/prowler-cloud/prowler/pull/7785)
- Add `repository_has_codeowners_file` check for GitHub provider. [(#7752)](https://github.com/prowler-cloud/prowler/pull/7752)
- Add `repository_default_branch_requires_signed_commits` check for GitHub provider. [(#7777)](https://github.com/prowler-cloud/prowler/pull/7777)
- Add `repository_inactive_not_archived` check for GitHub provider. [(#7786)](https://github.com/prowler-cloud/prowler/pull/7786)
- 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)
+5
View File
@@ -515,3 +515,8 @@ m365:
# m365.exchange_mailbox_properties_auditing_enabled
# Maximum number of days to keep audit logs
audit_log_age: 90
# GitHub Configuration
github:
# github.repository_inactive_not_archived --> CIS recommends 180 days (6 months)
inactive_not_archived_days_threshold: 180
@@ -0,0 +1,30 @@
{
"Provider": "github",
"CheckID": "repository_inactive_not_archived",
"CheckTitle": "Check for inactive repositories that are not archived",
"CheckType": [],
"ServiceName": "repository",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "GitHubRepository",
"Description": "Ensure that repositories with no activity are reviewed and considered for archival. Inactive repositories may have outdated dependencies or security configurations that could pose security risks.",
"Risk": "Inactive repositories that are not archived may contain outdated dependencies, unpatched vulnerabilities, or misconfigured security settings. These repositories increase the attack surface and could be targeted by malicious actors.",
"RelatedUrl": "https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
},
"Recommendation": {
"Text": "Review inactive repositories and either: 1) Archive them if they are no longer needed, 2) Update their dependencies and security configurations if they are still required, or 3) Delete them if they contain no valuable information.",
"Url": "https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories"
}
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,45 @@
from datetime import datetime, timezone
from typing import List
from prowler.lib.check.models import Check, CheckReportGithub
from prowler.providers.github.services.repository.repository_client import (
repository_client,
)
class repository_inactive_not_archived(Check):
"""Check if unarchived repositories have been inactive for more than 6 months."""
def execute(self) -> List[CheckReportGithub]:
findings = []
now = datetime.now(timezone.utc)
days_threshold = repository_client.audit_config.get(
"inactive_not_archived_days_threshold", 180
)
for repo in repository_client.repositories.values():
report = CheckReportGithub(
metadata=self.metadata(), resource=repo, repository=repo.name
)
if repo.archived:
report.status = "PASS"
report.status_extended = f"Repository {repo.name} is properly archived."
findings.append(report)
continue
latest_activity = repo.pushed_at
days_inactive = (now - latest_activity).days
if days_inactive >= days_threshold:
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} has been inactive for {days_inactive} days and is not archived (threshold: {days_threshold} days)."
else:
report.status = "PASS"
report.status_extended = f"Repository {repo.name} has been active within the last {days_threshold} days ({days_inactive} days ago)."
findings.append(report)
return findings
@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
@@ -163,6 +164,8 @@ class Repository(GithubService):
full_name=repo.full_name,
default_branch=repo.default_branch,
private=repo.private,
archived=repo.archived,
pushed_at=repo.pushed_at,
securitymd=securitymd_exists,
require_pull_request=require_pr,
approval_count=approval_cnt,
@@ -197,6 +200,8 @@ class Repo(BaseModel):
default_branch_protection: Optional[bool]
default_branch: str
private: bool
archived: bool
pushed_at: datetime
securitymd: Optional[bool]
require_pull_request: Optional[bool]
required_linear_history: Optional[bool]
@@ -59,7 +59,9 @@ class TestGitHubProvider:
account_id=ACCOUNT_ID,
account_url=ACCOUNT_URL,
)
assert provider._audit_config == {}
assert provider._audit_config == {
"inactive_not_archived_days_threshold": 180,
}
assert provider._fixer_config == fixer_config
def test_github_provider_OAuth(self):
@@ -99,7 +101,9 @@ class TestGitHubProvider:
account_id=ACCOUNT_ID,
account_url=ACCOUNT_URL,
)
assert provider._audit_config == {}
assert provider._audit_config == {
"inactive_not_archived_days_threshold": 180,
}
assert provider._fixer_config == fixer_config
def test_github_provider_App(self):
@@ -133,5 +137,7 @@ class TestGitHubProvider:
assert provider._type == "github"
assert provider.session == GithubSession(token="", id=APP_ID, key=APP_KEY)
assert provider.identity == GithubAppIdentityInfo(app_id=APP_ID)
assert provider._audit_config == {}
assert provider._audit_config == {
"inactive_not_archived_days_threshold": 180,
}
assert provider._fixer_config == fixer_config
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -39,6 +40,8 @@ class Test_repository_branch_delete_on_merge_enabled_test:
private=False,
securitymd=False,
delete_branch_on_merge=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -79,6 +82,8 @@ class Test_repository_branch_delete_on_merge_enabled_test:
private=False,
securitymd=True,
delete_branch_on_merge=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -31,15 +32,18 @@ class Test_repository_default_branch_deletion_disabled_test:
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
now = datetime.now(timezone.utc)
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
full_name="account-name/repo1",
default_branch=default_branch,
default_branch_deletion=True,
private=False,
securitymd=False,
archived=False,
pushed_at=now,
default_branch_deletion=True,
),
}
@@ -61,7 +65,7 @@ class Test_repository_default_branch_deletion_disabled_test:
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == "repo1"
assert result[0].resource_name == repo_name
assert result[0].status == "FAIL"
assert (
result[0].status_extended
@@ -72,15 +76,18 @@ class Test_repository_default_branch_deletion_disabled_test:
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
now = datetime.now(timezone.utc)
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
full_name="account-name/repo1",
private=False,
default_branch=default_branch,
private=False,
archived=False,
pushed_at=now,
default_branch_deletion=False,
securitymd=True,
),
}
@@ -102,7 +109,7 @@ class Test_repository_default_branch_deletion_disabled_test:
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == "repo1"
assert result[0].resource_name == repo_name
assert result[0].status == "PASS"
assert (
result[0].status_extended
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -40,6 +41,8 @@ class Test_repository_default_branch_disallows_force_push_test:
allow_force_pushes=True,
private=False,
securitymd=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -81,6 +84,8 @@ class Test_repository_default_branch_disallows_force_push_test:
default_branch=default_branch,
allow_force_pushes=False,
securitymd=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -40,6 +41,8 @@ class Test_repository_default_branch_protection_applies_to_admins_test:
private=False,
securitymd=False,
enforce_admins=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -81,6 +84,8 @@ class Test_repository_default_branch_protection_applies_to_admins_test:
default_branch=default_branch,
enforce_admins=True,
securitymd=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -40,6 +41,8 @@ class Test_repository_default_branch_protection_enabled_test:
private=False,
default_branch_protection=False,
securitymd=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -81,6 +84,8 @@ class Test_repository_default_branch_protection_enabled_test:
default_branch=default_branch,
default_branch_protection=True,
securitymd=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -41,6 +42,8 @@ class Test_repository_default_branch_requires_codeowners_review:
require_pull_request=False,
approval_count=0,
require_code_owner_reviews=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -83,6 +86,8 @@ class Test_repository_default_branch_requires_codeowners_review:
require_pull_request=False,
approval_count=0,
require_code_owner_reviews=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -38,6 +39,8 @@ class Test_repository_default_branch_requires_conversation_resolution_test:
full_name="account-name/repo1",
default_branch=default_branch,
conversation_resolution=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
private=False,
securitymd=False,
),
@@ -80,6 +83,8 @@ class Test_repository_default_branch_requires_conversation_resolution_test:
private=False,
default_branch=default_branch,
conversation_resolution=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -40,6 +41,8 @@ class Test_repository_default_branch_requires_linear_history_test:
required_linear_history=False,
private=False,
securitymd=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -81,6 +84,8 @@ class Test_repository_default_branch_requires_linear_history_test:
default_branch=default_branch,
required_linear_history=True,
securitymd=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -41,6 +42,8 @@ class Test_repository_default_branch_requires_multiple_approvals:
securitymd=False,
require_pull_request=False,
approval_count=0,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -83,6 +86,8 @@ class Test_repository_default_branch_requires_multiple_approvals:
securitymd=False,
require_pull_request=True,
approval_count=0,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -125,6 +130,8 @@ class Test_repository_default_branch_requires_multiple_approvals:
securitymd=True,
require_pull_request=True,
approval_count=2,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -40,6 +41,8 @@ class Test_repository_default_branch_requires_signed_commits:
default_branch=default_branch,
require_signed_commits=False,
securitymd=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -81,6 +84,8 @@ class Test_repository_default_branch_requires_signed_commits:
default_branch=default_branch,
require_signed_commits=True,
securitymd=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -38,6 +39,8 @@ class Test_repository_default_branch_status_checks_required_test:
full_name="account-name/repo1",
default_branch=default_branch,
status_checks=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
private=False,
securitymd=False,
),
@@ -80,6 +83,8 @@ class Test_repository_default_branch_status_checks_required_test:
private=False,
default_branch=default_branch,
status_checks=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -37,6 +38,8 @@ class Test_repository_dependency_scanning_enabled:
full_name="account-name/repo1",
default_branch="main",
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
require_pull_request=False,
approval_count=0,
@@ -80,6 +83,8 @@ class Test_repository_dependency_scanning_enabled:
full_name="account-name/repo2",
default_branch="main",
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
require_pull_request=False,
approval_count=0,
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -41,6 +42,8 @@ class Test_repository_has_codeowners_file:
require_pull_request=False,
approval_count=0,
codeowners_exists=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -83,6 +86,8 @@ class Test_repository_has_codeowners_file:
require_pull_request=False,
approval_count=0,
codeowners_exists=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -0,0 +1,206 @@
from datetime import datetime, timedelta, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
class Test_repository_inactive_not_archived:
def test_no_repositories(self):
repository_client = mock.MagicMock
repository_client.repositories = {}
repository_client.audit_config = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import (
repository_inactive_not_archived,
)
check = repository_inactive_not_archived()
result = check.execute()
assert len(result) == 0
def test_repository_active_not_archived(self):
repository_client = mock.MagicMock
repo_name = "test-repo"
default_branch = "main"
now = datetime.now(timezone.utc)
recent_activity = now - timedelta(days=30) # 30 days ago
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
full_name="account-name/test-repo",
private=False,
default_branch=default_branch,
archived=False,
pushed_at=recent_activity,
),
}
repository_client.audit_config = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import (
repository_inactive_not_archived,
)
check = repository_inactive_not_archived()
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == repo_name
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Repository {repo_name} has been active within the last 180 days (30 days ago)."
)
def test_repository_inactive_not_archived(self):
repository_client = mock.MagicMock
repo_name = "test-repo"
default_branch = "main"
now = datetime.now(timezone.utc)
old_activity = now - timedelta(days=200) # 200 days ago
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
full_name="account-name/test-repo",
private=False,
default_branch=default_branch,
archived=False,
pushed_at=old_activity,
),
}
repository_client.audit_config = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import (
repository_inactive_not_archived,
)
check = repository_inactive_not_archived()
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == repo_name
assert result[0].status == "FAIL"
assert "has been inactive for 200 days" in result[0].status_extended
assert "and is not archived" in result[0].status_extended
def test_repository_inactive_but_archived(self):
repository_client = mock.MagicMock
repo_name = "test-repo"
default_branch = "main"
now = datetime.now(timezone.utc)
old_activity = now - timedelta(days=200) # 200 days ago
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
full_name="account-name/test-repo",
default_branch=default_branch,
private=False,
archived=True,
pushed_at=old_activity,
),
}
repository_client.audit_config = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import (
repository_inactive_not_archived,
)
check = repository_inactive_not_archived()
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == repo_name
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Repository {repo_name} is properly archived."
)
def test_custom_days_threshold(self):
repository_client = mock.MagicMock
repo_name = "test-repo"
default_branch = "main"
now = datetime.now(timezone.utc)
old_activity = now - timedelta(days=50)
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
full_name="account-name/test-repo",
private=False,
default_branch=default_branch,
archived=False,
pushed_at=old_activity,
),
}
repository_client.audit_config = {"inactive_not_archived_days_threshold": 40}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import (
repository_inactive_not_archived,
)
check = repository_inactive_not_archived()
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == repo_name
assert result[0].status == "FAIL"
assert "has been inactive for 50 days" in result[0].status_extended
assert "and is not archived" in result[0].status_extended
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -40,6 +41,8 @@ class Test_repository_public_has_securitymd_file_test:
securitymd=False,
require_pull_request=False,
approval_count=0,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -81,6 +84,8 @@ class Test_repository_public_has_securitymd_file_test:
securitymd=True,
require_pull_request=False,
approval_count=0,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
@@ -41,6 +42,8 @@ class Test_repository_secret_scanning_enabled:
require_pull_request=False,
approval_count=0,
secret_scanning_enabled=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -83,6 +86,8 @@ class Test_repository_secret_scanning_enabled:
require_pull_request=False,
approval_count=0,
secret_scanning_enabled=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
),
}
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from prowler.providers.github.services.repository.repository_service import (
@@ -26,6 +27,9 @@ def mock_list_repositories(_):
codeowners_exists=True,
require_code_owner_reviews=True,
secret_scanning_enabled=True,
require_signed_commits=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
enforce_admins=True,
delete_branch_on_merge=True,
conversation_resolution=True,
@@ -66,6 +70,9 @@ class Test_Repository_Service:
assert repository_service.repositories[1].codeowners_exists is True
assert repository_service.repositories[1].require_code_owner_reviews is True
assert repository_service.repositories[1].secret_scanning_enabled is True
assert repository_service.repositories[1].require_signed_commits is True
assert repository_service.repositories[1].archived is False
assert repository_service.repositories[1].pushed_at is not None
class Test_Repository_FileExists: