mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
Merge branch 'master' into PRWLR-7117-implement-new-findings-latest-endpoint
This commit is contained in:
@@ -12,6 +12,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
|
||||
- name: Set short git commit SHA
|
||||
id: vars
|
||||
|
||||
@@ -167,6 +167,21 @@ jobs:
|
||||
run: |
|
||||
poetry run pytest -n auto --cov=./prowler/providers/kubernetes --cov-report=xml:kubernetes_coverage.xml tests/providers/kubernetes
|
||||
|
||||
# Test GitHub
|
||||
- name: GitHub - Check if any file has changed
|
||||
id: github-changed-files
|
||||
uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5
|
||||
with:
|
||||
files: |
|
||||
./prowler/providers/github/**
|
||||
./tests/providers/github/**
|
||||
.poetry.lock
|
||||
|
||||
- name: GitHub - Test
|
||||
if: steps.github-changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
poetry run pytest -n auto --cov=./prowler/providers/github --cov-report=xml:github_coverage.xml tests/providers/github
|
||||
|
||||
# Test NHN
|
||||
- name: NHN - Check if any file has changed
|
||||
id: nhn-changed-files
|
||||
@@ -216,4 +231,4 @@ jobs:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
flags: prowler
|
||||
files: ./aws_coverage.xml,./azure_coverage.xml,./gcp_coverage.xml,./kubernetes_coverage.xml,./nhn_coverage.xml,./m365_coverage.xml,./lib_coverage.xml,./config_coverage.xml
|
||||
files: ./aws_coverage.xml,./azure_coverage.xml,./gcp_coverage.xml,./kubernetes_coverage.xml,./github_coverage.xml,./nhn_coverage.xml,./m365_coverage.xml,./lib_coverage.xml,./config_coverage.xml
|
||||
|
||||
@@ -90,6 +90,7 @@ prowler dashboard
|
||||
| GCP | 79 | 13 | 7 | 3 |
|
||||
| Azure | 140 | 18 | 8 | 3 |
|
||||
| Kubernetes | 83 | 7 | 4 | 7 |
|
||||
| GitHub | 2 | 1 | 1 | 0 |
|
||||
| M365 | 44 | 2 | 2 | 0 |
|
||||
| NHN (Unofficial) | 6 | 2 | 1 | 0 |
|
||||
|
||||
|
||||
Generated
+8
-8
@@ -2237,14 +2237,14 @@ tornado = ["tornado (>=0.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
|
||||
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
|
||||
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
||||
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2277,19 +2277,19 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
description = "A minimal low-level HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be"},
|
||||
{file = "httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad"},
|
||||
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
|
||||
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = "*"
|
||||
h11 = ">=0.13,<0.15"
|
||||
h11 = ">=0.16"
|
||||
|
||||
[package.extras]
|
||||
asyncio = ["anyio (>=4.0,<5.0)"]
|
||||
|
||||
@@ -2,7 +2,9 @@ import glob
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
|
||||
import jwt
|
||||
@@ -2318,35 +2320,34 @@ class TestScanViewSet:
|
||||
assert response.status_code == 404
|
||||
assert response.json()["errors"]["detail"] == "The scan has no reports."
|
||||
|
||||
def test_report_local_file(
|
||||
self, authenticated_client, scans_fixture, tmp_path, monkeypatch
|
||||
):
|
||||
"""
|
||||
When output_location is a local file path, the view should read the file from disk
|
||||
and return it with proper headers.
|
||||
"""
|
||||
def test_report_local_file(self, authenticated_client, scans_fixture, monkeypatch):
|
||||
scan = scans_fixture[0]
|
||||
file_content = b"local zip file content"
|
||||
file_path = tmp_path / "report.zip"
|
||||
file_path.write_bytes(file_content)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
base_tmp = tmp_path / "report_local_file"
|
||||
base_tmp.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
scan.output_location = str(file_path)
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.save()
|
||||
file_content = b"local zip file content"
|
||||
file_path = base_tmp / "report.zip"
|
||||
file_path.write_bytes(file_content)
|
||||
|
||||
monkeypatch.setattr(
|
||||
glob,
|
||||
"glob",
|
||||
lambda pattern: [str(file_path)] if pattern == str(file_path) else [],
|
||||
)
|
||||
scan.output_location = str(file_path)
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.save()
|
||||
|
||||
url = reverse("scan-report", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response.content == file_content
|
||||
content_disposition = response.get("Content-Disposition")
|
||||
assert content_disposition.startswith('attachment; filename="')
|
||||
assert f'filename="{file_path.name}"' in content_disposition
|
||||
monkeypatch.setattr(
|
||||
glob,
|
||||
"glob",
|
||||
lambda pattern: [str(file_path)] if pattern == str(file_path) else [],
|
||||
)
|
||||
|
||||
url = reverse("scan-report", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response.content == file_content
|
||||
content_disposition = response.get("Content-Disposition")
|
||||
assert content_disposition.startswith('attachment; filename="')
|
||||
assert f'filename="{file_path.name}"' in content_disposition
|
||||
|
||||
def test_compliance_invalid_framework(self, authenticated_client, scans_fixture):
|
||||
scan = scans_fixture[0]
|
||||
@@ -2481,31 +2482,36 @@ class TestScanViewSet:
|
||||
)
|
||||
|
||||
def test_compliance_local_file(
|
||||
self, authenticated_client, scans_fixture, tmp_path, monkeypatch
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
base = tmp_path / "reports"
|
||||
comp_dir = base / "compliance"
|
||||
comp_dir.mkdir(parents=True)
|
||||
fname = comp_dir / "scan_cis.csv"
|
||||
fname.write_bytes(b"ignored")
|
||||
|
||||
scan.output_location = str(base / "scan.zip")
|
||||
scan.save()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
base = tmp_path / "reports"
|
||||
comp_dir = base / "compliance"
|
||||
comp_dir.mkdir(parents=True, exist_ok=True)
|
||||
fname = comp_dir / "scan_cis.csv"
|
||||
fname.write_bytes(b"ignored")
|
||||
|
||||
monkeypatch.setattr(
|
||||
glob,
|
||||
"glob",
|
||||
lambda p: [str(fname)] if p.endswith("*_cis_1.4_aws.csv") else [],
|
||||
)
|
||||
scan.output_location = str(base / "scan.zip")
|
||||
scan.save()
|
||||
|
||||
url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"})
|
||||
resp = authenticated_client.get(url)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
cd = resp["Content-Disposition"]
|
||||
assert cd.startswith('attachment; filename="')
|
||||
assert cd.endswith(f'filename="{fname.name}"')
|
||||
monkeypatch.setattr(
|
||||
glob,
|
||||
"glob",
|
||||
lambda p: [str(fname)] if p.endswith("*_cis_1.4_aws.csv") else [],
|
||||
)
|
||||
|
||||
url = reverse(
|
||||
"scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}
|
||||
)
|
||||
resp = authenticated_client.get(url)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
cd = resp["Content-Disposition"]
|
||||
assert cd.startswith('attachment; filename="')
|
||||
assert cd.endswith(f'filename="{fname.name}"')
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.TaskSerializer")
|
||||
|
||||
@@ -2630,33 +2630,48 @@ class OverviewViewSet(BaseRLSViewSet):
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
resource_count_queryset = (
|
||||
Resource.all_objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=OuterRef("scan__provider_id"),
|
||||
)
|
||||
.order_by()
|
||||
.values("provider_id")
|
||||
.annotate(cnt=Count("id"))
|
||||
.values("cnt")
|
||||
)
|
||||
|
||||
overview_queryset = (
|
||||
findings_aggregated = (
|
||||
ScanSummary.all_objects.filter(
|
||||
tenant_id=tenant_id, scan_id__in=latest_scan_ids
|
||||
)
|
||||
.values(provider=F("scan__provider__provider"))
|
||||
.values(
|
||||
"scan__provider_id",
|
||||
provider=F("scan__provider__provider"),
|
||||
)
|
||||
.annotate(
|
||||
findings_passed=Coalesce(Sum("_pass"), 0),
|
||||
findings_failed=Coalesce(Sum("fail"), 0),
|
||||
findings_muted=Coalesce(Sum("muted"), 0),
|
||||
total_findings=Coalesce(Sum("total"), 0),
|
||||
total_resources=Coalesce(Subquery(resource_count_queryset), 0),
|
||||
)
|
||||
)
|
||||
|
||||
serializer = OverviewProviderSerializer(overview_queryset, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
resources_aggregated = (
|
||||
Resource.all_objects.filter(tenant_id=tenant_id)
|
||||
.values("provider_id")
|
||||
.annotate(total_resources=Count("id"))
|
||||
)
|
||||
resource_map = {
|
||||
row["provider_id"]: row["total_resources"] for row in resources_aggregated
|
||||
}
|
||||
|
||||
overview = []
|
||||
for row in findings_aggregated:
|
||||
overview.append(
|
||||
{
|
||||
"provider": row["provider"],
|
||||
"total_resources": resource_map.get(row["scan__provider_id"], 0),
|
||||
"total_findings": row["total_findings"],
|
||||
"findings_passed": row["findings_passed"],
|
||||
"findings_failed": row["findings_failed"],
|
||||
"findings_muted": row["findings_muted"],
|
||||
}
|
||||
)
|
||||
|
||||
return Response(
|
||||
OverviewProviderSerializer(overview, many=True).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"], url_name="findings")
|
||||
def findings(self, request):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -14,8 +15,9 @@ from tasks.jobs.export import (
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestOutputs:
|
||||
def test_compress_output_files_creates_zip(self, tmp_path):
|
||||
output_dir = tmp_path / "output"
|
||||
def test_compress_output_files_creates_zip(self, tmpdir):
|
||||
base_tmp = Path(str(tmpdir.mkdir("compress_output")))
|
||||
output_dir = base_tmp / "output"
|
||||
output_dir.mkdir()
|
||||
file_path = output_dir / "result.csv"
|
||||
file_path.write_text("data")
|
||||
@@ -54,16 +56,16 @@ class TestOutputs:
|
||||
|
||||
@patch("tasks.jobs.export.get_s3_client")
|
||||
@patch("tasks.jobs.export.base")
|
||||
def test_upload_to_s3_success(self, mock_base, mock_get_client, tmp_path):
|
||||
def test_upload_to_s3_success(self, mock_base, mock_get_client, tmpdir):
|
||||
mock_base.DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET = "test-bucket"
|
||||
|
||||
zip_path = tmp_path / "outputs.zip"
|
||||
base_tmp = Path(str(tmpdir.mkdir("upload_success")))
|
||||
zip_path = base_tmp / "outputs.zip"
|
||||
zip_path.write_bytes(b"dummy")
|
||||
|
||||
compliance_dir = tmp_path / "compliance"
|
||||
compliance_dir = base_tmp / "compliance"
|
||||
compliance_dir.mkdir()
|
||||
compliance_file = compliance_dir / "report.csv"
|
||||
compliance_file.write_text("ok")
|
||||
(compliance_dir / "report.csv").write_text("ok")
|
||||
|
||||
client_mock = MagicMock()
|
||||
mock_get_client.return_value = client_mock
|
||||
@@ -72,7 +74,6 @@ class TestOutputs:
|
||||
|
||||
expected_uri = "s3://test-bucket/tenant-id/scan-id/outputs.zip"
|
||||
assert result == expected_uri
|
||||
|
||||
assert client_mock.upload_file.call_count == 2
|
||||
|
||||
@patch("tasks.jobs.export.get_s3_client")
|
||||
@@ -84,12 +85,14 @@ class TestOutputs:
|
||||
|
||||
@patch("tasks.jobs.export.get_s3_client")
|
||||
@patch("tasks.jobs.export.base")
|
||||
def test_upload_to_s3_skips_non_files(self, mock_base, mock_get_client, tmp_path):
|
||||
def test_upload_to_s3_skips_non_files(self, mock_base, mock_get_client, tmpdir):
|
||||
mock_base.DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET = "test-bucket"
|
||||
zip_path = tmp_path / "results.zip"
|
||||
base_tmp = Path(str(tmpdir.mkdir("upload_skips_non_files")))
|
||||
|
||||
zip_path = base_tmp / "results.zip"
|
||||
zip_path.write_bytes(b"zip")
|
||||
|
||||
compliance_dir = tmp_path / "compliance"
|
||||
compliance_dir = base_tmp / "compliance"
|
||||
compliance_dir.mkdir()
|
||||
(compliance_dir / "subdir").mkdir()
|
||||
|
||||
@@ -100,7 +103,6 @@ class TestOutputs:
|
||||
|
||||
expected_uri = "s3://test-bucket/tenant/scan/results.zip"
|
||||
assert result == expected_uri
|
||||
|
||||
client_mock.upload_file.assert_called_once()
|
||||
|
||||
@patch(
|
||||
@@ -110,23 +112,26 @@ class TestOutputs:
|
||||
@patch("tasks.jobs.export.base")
|
||||
@patch("tasks.jobs.export.logger.error")
|
||||
def test_upload_to_s3_failure_logs_error(
|
||||
self, mock_logger, mock_base, mock_get_client, tmp_path
|
||||
self, mock_logger, mock_base, mock_get_client, tmpdir
|
||||
):
|
||||
mock_base.DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET = "bucket"
|
||||
zip_path = tmp_path / "zipfile.zip"
|
||||
|
||||
base_tmp = Path(str(tmpdir.mkdir("upload_failure_logs")))
|
||||
zip_path = base_tmp / "zipfile.zip"
|
||||
zip_path.write_bytes(b"zip")
|
||||
|
||||
compliance_dir = tmp_path / "compliance"
|
||||
compliance_dir = base_tmp / "compliance"
|
||||
compliance_dir.mkdir()
|
||||
(compliance_dir / "report.csv").write_text("csv")
|
||||
|
||||
_upload_to_s3("tenant", str(zip_path), "scan")
|
||||
mock_logger.assert_called()
|
||||
|
||||
def test_generate_output_directory_creates_paths(self, tmp_path):
|
||||
def test_generate_output_directory_creates_paths(self, tmpdir):
|
||||
from prowler.config.config import output_file_timestamp
|
||||
|
||||
base_dir = str(tmp_path)
|
||||
base_tmp = Path(str(tmpdir.mkdir("generate_output")))
|
||||
base_dir = str(base_tmp)
|
||||
tenant_id = "t1"
|
||||
scan_id = "s1"
|
||||
provider = "aws"
|
||||
|
||||
@@ -466,3 +466,19 @@ The required modules are:
|
||||
|
||||
- [ExchangeOnlineManagement](https://www.powershellgallery.com/packages/ExchangeOnlineManagement/3.6.0): Minimum version 3.6.0. Required for several checks across Exchange, Defender, and Purview.
|
||||
- [MicrosoftTeams](https://www.powershellgallery.com/packages/MicrosoftTeams/6.6.0): Minimum version 6.6.0. Required for all Teams checks.
|
||||
|
||||
## GitHub
|
||||
### Authentication
|
||||
|
||||
Prowler supports multiple methods to [authenticate with GitHub](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api). These include:
|
||||
|
||||
- **Personal Access Token (PAT)**
|
||||
- **OAuth App Token**
|
||||
- **GitHub App Credentials**
|
||||
|
||||
This flexibility allows you to scan and analyze your GitHub account, including repositories, organizations, and applications, using the method that best suits your use case.
|
||||
|
||||
The provided credentials must have the appropriate permissions to perform all the required checks.
|
||||
|
||||
???+ note
|
||||
GitHub App Credentials support less checks than other authentication methods.
|
||||
|
||||
@@ -565,6 +565,7 @@ kubectl logs prowler-XXXXX --namespace prowler-ns
|
||||
???+ note
|
||||
By default, `prowler` will scan all namespaces in your active Kubernetes context. Use the flag `--context` to specify the context to be scanned and `--namespaces` to specify the namespaces to be scanned.
|
||||
|
||||
|
||||
#### Microsoft 365
|
||||
|
||||
With M365 you need to specify which auth method is going to be used:
|
||||
@@ -587,5 +588,29 @@ prowler m365 --browser-auth --tenant-id "XXXXXXXX"
|
||||
|
||||
See more details about M365 Authentication in [Requirements](getting-started/requirements.md#microsoft-365)
|
||||
|
||||
#### GitHub
|
||||
|
||||
Prowler allows you to scan your GitHub account, including your repositories, organizations or applications.
|
||||
|
||||
There are several supported login methods:
|
||||
|
||||
```console
|
||||
# Personal Access Token (PAT):
|
||||
prowler github --personal-access-token pat
|
||||
|
||||
# OAuth App Token:
|
||||
prowler github --oauth-app-token oauth_token
|
||||
|
||||
# GitHub App Credentials:
|
||||
prowler github --github-app-id app_id --github-app-key app_key
|
||||
```
|
||||
|
||||
???+ note
|
||||
If no login method is explicitly provided, Prowler will automatically attempt to authenticate using environment variables in the following order of precedence:
|
||||
|
||||
1. `GITHUB_PERSONAL_ACCESS_TOKEN`
|
||||
2. `OAUTH_APP_TOKEN`
|
||||
3. `GITHUB_APP_ID` and `GITHUB_APP_KEY`
|
||||
|
||||
## Prowler v2 Documentation
|
||||
For **Prowler v2 Documentation**, please check it out [here](https://github.com/prowler-cloud/prowler/blob/8818f47333a0c1c1a457453c87af0ea5b89a385f/README.md).
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# GitHub Authentication
|
||||
|
||||
Prowler supports multiple methods to [authenticate with GitHub](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api). These include:
|
||||
|
||||
- **Personal Access Token (PAT)**
|
||||
- **OAuth App Token**
|
||||
- **GitHub App Credentials**
|
||||
|
||||
This flexibility allows you to scan and analyze your GitHub account, including repositories, organizations, and applications, using the method that best suits your use case.
|
||||
|
||||
## Supported Login Methods
|
||||
|
||||
Here are the available login methods and their respective flags:
|
||||
|
||||
### Personal Access Token (PAT)
|
||||
Use this method by providing your personal access token directly.
|
||||
|
||||
```console
|
||||
prowler github --personal-access-token pat
|
||||
```
|
||||
|
||||
### OAuth App Token
|
||||
Authenticate using an OAuth app token.
|
||||
|
||||
```console
|
||||
prowler github --oauth-app-token oauth_token
|
||||
```
|
||||
|
||||
### GitHub App Credentials
|
||||
Use GitHub App credentials by specifying the App ID and the private key.
|
||||
|
||||
```console
|
||||
prowler github --github-app-id app_id --github-app-key app_key
|
||||
```
|
||||
|
||||
### Automatic Login Method Detection
|
||||
If no login method is explicitly provided, Prowler will automatically attempt to authenticate using environment variables in the following order of precedence:
|
||||
|
||||
1. `GITHUB_PERSONAL_ACCESS_TOKEN`
|
||||
2. `OAUTH_APP_TOKEN`
|
||||
3. `GITHUB_APP_ID` and `GITHUB_APP_KEY`
|
||||
|
||||
???+ note
|
||||
Ensure the corresponding environment variables are set up before running Prowler for automatic detection if you don't plan to specify the login method.
|
||||
Generated
+58
-13
@@ -884,7 +884,6 @@ description = "Foreign Function Interface for Python calling C code."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
markers = "platform_python_implementation != \"PyPy\""
|
||||
files = [
|
||||
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
|
||||
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
|
||||
@@ -954,6 +953,7 @@ files = [
|
||||
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
|
||||
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
|
||||
]
|
||||
markers = {dev = "platform_python_implementation != \"PyPy\""}
|
||||
|
||||
[package.dependencies]
|
||||
pycparser = "*"
|
||||
@@ -1907,14 +1907,14 @@ typing-extensions = {version = ">=4,<5", markers = "python_version < \"3.10\""}
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
|
||||
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
|
||||
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
||||
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1947,19 +1947,19 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
description = "A minimal low-level HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be"},
|
||||
{file = "httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad"},
|
||||
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
|
||||
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = "*"
|
||||
h11 = ">=0.13,<0.15"
|
||||
h11 = ">=0.16"
|
||||
|
||||
[package.extras]
|
||||
asyncio = ["anyio (>=4.0,<5.0)"]
|
||||
@@ -2184,8 +2184,6 @@ python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c"},
|
||||
{file = "jsonpath_ng-1.7.0-py2-none-any.whl", hash = "sha256:898c93fc173f0c336784a3fa63d7434297544b7198124a68f9a3ef9597b0ae6e"},
|
||||
{file = "jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -3753,11 +3751,11 @@ description = "C parser in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
markers = "platform_python_implementation != \"PyPy\""
|
||||
files = [
|
||||
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
|
||||
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
|
||||
]
|
||||
markers = {dev = "platform_python_implementation != \"PyPy\""}
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
@@ -3838,6 +3836,26 @@ files = [
|
||||
{file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygithub"
|
||||
version = "2.5.0"
|
||||
description = "Use the full Github API v3"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2"},
|
||||
{file = "pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
Deprecated = "*"
|
||||
pyjwt = {version = ">=2.4.0", extras = ["crypto"]}
|
||||
pynacl = ">=1.4.0"
|
||||
requests = ">=2.14.0"
|
||||
typing-extensions = ">=4.0.0"
|
||||
urllib3 = ">=1.26.0"
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.1"
|
||||
@@ -3924,6 +3942,33 @@ pyyaml = "*"
|
||||
[package.extras]
|
||||
extra = ["pygments (>=2.19.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "pynacl"
|
||||
version = "1.5.0"
|
||||
description = "Python binding to the Networking and Cryptography (NaCl) library"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"},
|
||||
{file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cffi = ">=1.4.1"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
|
||||
tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.2.3"
|
||||
@@ -5409,4 +5454,4 @@ type = ["pytest-mypy"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">3.9.1,<3.13"
|
||||
content-hash = "cc15c8ee6b064b3fda85177c0f1c24a57880c401682fe62daefc2d0f4043150a"
|
||||
content-hash = "f504af1d00a1da9dd65269509daf32f919c13be6c46f25cd9ef9bfba6b9c9a07"
|
||||
|
||||
@@ -8,6 +8,9 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Update the compliance list supported for each provider from docs. [(#7694)](https://github.com/prowler-cloud/prowler/pull/7694)
|
||||
- Allow setting cluster name in in-cluster mode in Kubernetes. [(#7695)](https://github.com/prowler-cloud/prowler/pull/7695)
|
||||
- Add Prowler ThreatScore for M365 provider. [(#7692)](https://github.com/prowler-cloud/prowler/pull/7692)
|
||||
- Add GitHub provider. [(#5787)](https://github.com/prowler-cloud/prowler/pull/5787)
|
||||
- Add `repository_code_changes_multi_approval_requirement` check for GitHub provider. [(#6160)](https://github.com/prowler-cloud/prowler/pull/6160)
|
||||
- Add GitHub provider documentation and CIS v1.0.0 compliance. [(#6116)](https://github.com/prowler-cloud/prowler/pull/6116)
|
||||
|
||||
### Fixed
|
||||
- Update CIS 4.0 for M365 provider. [(#7699)](https://github.com/prowler-cloud/prowler/pull/7699)
|
||||
|
||||
@@ -50,6 +50,7 @@ from prowler.lib.outputs.compliance.aws_well_architected.aws_well_architected im
|
||||
from prowler.lib.outputs.compliance.cis.cis_aws import AWSCIS
|
||||
from prowler.lib.outputs.compliance.cis.cis_azure import AzureCIS
|
||||
from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS
|
||||
from prowler.lib.outputs.compliance.cis.cis_github import GithubCIS
|
||||
from prowler.lib.outputs.compliance.cis.cis_kubernetes import KubernetesCIS
|
||||
from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS
|
||||
from prowler.lib.outputs.compliance.compliance import display_compliance_table
|
||||
@@ -96,6 +97,7 @@ from prowler.providers.azure.models import AzureOutputOptions
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.common.quick_inventory import run_provider_quick_inventory
|
||||
from prowler.providers.gcp.models import GCPOutputOptions
|
||||
from prowler.providers.github.models import GithubOutputOptions
|
||||
from prowler.providers.kubernetes.models import KubernetesOutputOptions
|
||||
from prowler.providers.m365.models import M365OutputOptions
|
||||
from prowler.providers.nhn.models import NHNOutputOptions
|
||||
@@ -280,6 +282,10 @@ def prowler():
|
||||
output_options = KubernetesOutputOptions(
|
||||
args, bulk_checks_metadata, global_provider.identity
|
||||
)
|
||||
elif provider == "github":
|
||||
output_options = GithubOutputOptions(
|
||||
args, bulk_checks_metadata, global_provider.identity
|
||||
)
|
||||
elif provider == "m365":
|
||||
output_options = M365OutputOptions(
|
||||
args, bulk_checks_metadata, global_provider.identity
|
||||
@@ -782,6 +788,36 @@ def prowler():
|
||||
generated_outputs["compliance"].append(generic_compliance)
|
||||
generic_compliance.batch_write_data_to_file()
|
||||
|
||||
elif provider == "github":
|
||||
for compliance_name in input_compliance_frameworks:
|
||||
if compliance_name.startswith("cis_"):
|
||||
# Generate CIS Finding Object
|
||||
filename = (
|
||||
f"{output_options.output_directory}/compliance/"
|
||||
f"{output_options.output_filename}_{compliance_name}.csv"
|
||||
)
|
||||
cis = GithubCIS(
|
||||
findings=finding_outputs,
|
||||
compliance=bulk_compliance_frameworks[compliance_name],
|
||||
create_file_descriptor=True,
|
||||
file_path=filename,
|
||||
)
|
||||
generated_outputs["compliance"].append(cis)
|
||||
cis.batch_write_data_to_file()
|
||||
else:
|
||||
filename = (
|
||||
f"{output_options.output_directory}/compliance/"
|
||||
f"{output_options.output_filename}_{compliance_name}.csv"
|
||||
)
|
||||
generic_compliance = GenericCompliance(
|
||||
findings=finding_outputs,
|
||||
compliance=bulk_compliance_frameworks[compliance_name],
|
||||
create_file_descriptor=True,
|
||||
file_path=filename,
|
||||
)
|
||||
generated_outputs["compliance"].append(generic_compliance)
|
||||
generic_compliance.batch_write_data_to_file()
|
||||
|
||||
# AWS Security Hub Integration
|
||||
if provider == "aws":
|
||||
# Send output to S3 if needed (-B / -D) for all the output formats
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ class Provider(str, Enum):
|
||||
AZURE = "azure"
|
||||
KUBERNETES = "kubernetes"
|
||||
M365 = "m365"
|
||||
GITHUB = "github"
|
||||
NHN = "nhn"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
### Account, Check and/or Region can be * to apply for all the cases.
|
||||
### Account == <GitHub Account Name>
|
||||
### Resources and tags are lists that can have either Regex or Keywords.
|
||||
### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together.
|
||||
### Use an alternation Regex to match one of multiple tags with "ORed" logic.
|
||||
### For each check you can except Accounts, Regions, Resources and/or Tags.
|
||||
########################### MUTELIST EXAMPLE ###########################
|
||||
Mutelist:
|
||||
Accounts:
|
||||
"account_1":
|
||||
Checks:
|
||||
"repository_public_has_securitymd_file":
|
||||
Regions:
|
||||
- "*"
|
||||
Resources:
|
||||
- "resource_1"
|
||||
- "resource_2"
|
||||
@@ -631,7 +631,10 @@ def execute(
|
||||
)
|
||||
elif global_provider.type == "kubernetes":
|
||||
is_finding_muted_args["cluster"] = global_provider.identity.cluster
|
||||
|
||||
elif global_provider.type == "github":
|
||||
is_finding_muted_args["account_name"] = (
|
||||
global_provider.identity.account_name
|
||||
)
|
||||
for finding in check_findings:
|
||||
is_finding_muted_args["finding"] = finding
|
||||
finding.muted = global_provider.mutelist.is_finding_muted(
|
||||
|
||||
@@ -542,6 +542,37 @@ class Check_Report_Kubernetes(Check_Report):
|
||||
self.namespace = "cluster-wide"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckReportGithub(Check_Report):
|
||||
"""Contains the GitHub Check's finding information."""
|
||||
|
||||
resource_name: str
|
||||
resource_id: str
|
||||
repository: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
metadata: Dict,
|
||||
resource: Any,
|
||||
resource_name: str = None,
|
||||
resource_id: str = None,
|
||||
repository: str = "global",
|
||||
) -> None:
|
||||
"""Initialize the GitHub Check's finding information.
|
||||
|
||||
Args:
|
||||
metadata: The metadata of the check.
|
||||
resource: Basic information about the resource. Defaults to None.
|
||||
resource_name: The name of the resource related with the finding.
|
||||
resource_id: The id of the resource related with the finding.
|
||||
repository: The repository of the resource related with the finding.
|
||||
"""
|
||||
super().__init__(metadata, resource)
|
||||
self.resource_name = resource_name or getattr(resource, "name", "")
|
||||
self.resource_id = resource_id or getattr(resource, "id", "")
|
||||
self.repository = repository or getattr(resource, "repository", "")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckReportM365(Check_Report):
|
||||
"""Contains the M365 Check's finding information."""
|
||||
|
||||
@@ -34,6 +34,7 @@ Available Cloud Providers:
|
||||
azure Azure Provider
|
||||
gcp GCP Provider
|
||||
kubernetes Kubernetes Provider
|
||||
github GitHub Provider
|
||||
m365 Microsoft 365 Provider
|
||||
nhn NHN Provider (Unofficial)
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
from datetime import datetime
|
||||
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.outputs.compliance.cis.models import GithubCISModel
|
||||
from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
|
||||
from prowler.lib.outputs.finding import Finding
|
||||
|
||||
|
||||
class GithubCIS(ComplianceOutput):
|
||||
"""
|
||||
This class represents the GitHub CIS compliance output.
|
||||
|
||||
Attributes:
|
||||
- _data (list): A list to store transformed data from findings.
|
||||
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
|
||||
|
||||
Methods:
|
||||
- transform: Transforms findings into GitHub CIS compliance format.
|
||||
"""
|
||||
|
||||
def transform(
|
||||
self,
|
||||
findings: list[Finding],
|
||||
compliance: Compliance,
|
||||
compliance_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Transforms a list of findings into GitHub CIS compliance format.
|
||||
|
||||
Parameters:
|
||||
- findings (list): A list of findings.
|
||||
- compliance (Compliance): A compliance model.
|
||||
- compliance_name (str): The name of the compliance model.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
for finding in findings:
|
||||
# Get the compliance requirements for the finding
|
||||
finding_requirements = finding.compliance.get(compliance_name, [])
|
||||
for requirement in compliance.Requirements:
|
||||
if requirement.Id in finding_requirements:
|
||||
for attribute in requirement.Attributes:
|
||||
compliance_row = GithubCISModel(
|
||||
Provider=finding.provider,
|
||||
Description=compliance.Description,
|
||||
Account_Id=finding.account_uid,
|
||||
Account_Name=finding.account_name,
|
||||
AssessmentDate=str(finding.timestamp),
|
||||
Requirements_Id=requirement.Id,
|
||||
Requirements_Description=requirement.Description,
|
||||
Requirements_Attributes_Section=attribute.Section,
|
||||
Requirements_Attributes_Profile=attribute.Profile,
|
||||
Requirements_Attributes_AssessmentStatus=attribute.AssessmentStatus,
|
||||
Requirements_Attributes_Description=attribute.Description,
|
||||
Requirements_Attributes_RationaleStatement=attribute.RationaleStatement,
|
||||
Requirements_Attributes_ImpactStatement=attribute.ImpactStatement,
|
||||
Requirements_Attributes_RemediationProcedure=attribute.RemediationProcedure,
|
||||
Requirements_Attributes_AuditProcedure=attribute.AuditProcedure,
|
||||
Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation,
|
||||
Requirements_Attributes_References=attribute.References,
|
||||
Requirements_Attributes_DefaultValue=attribute.DefaultValue,
|
||||
Status=finding.status,
|
||||
StatusExtended=finding.status_extended,
|
||||
ResourceId=finding.resource_uid,
|
||||
ResourceName=finding.resource_name,
|
||||
CheckId=finding.check_id,
|
||||
Muted=finding.muted,
|
||||
)
|
||||
self._data.append(compliance_row)
|
||||
# Add manual requirements to the compliance output
|
||||
for requirement in compliance.Requirements:
|
||||
if not requirement.Checks:
|
||||
for attribute in requirement.Attributes:
|
||||
compliance_row = GithubCISModel(
|
||||
Provider=compliance.Provider.lower(),
|
||||
Description=compliance.Description,
|
||||
Account_Id="",
|
||||
Account_Name="",
|
||||
AssessmentDate=str(datetime.now()),
|
||||
Requirements_Id=requirement.Id,
|
||||
Requirements_Description=requirement.Description,
|
||||
Requirements_Attributes_Section=attribute.Section,
|
||||
Requirements_Attributes_Profile=attribute.Profile,
|
||||
Requirements_Attributes_AssessmentStatus=attribute.AssessmentStatus,
|
||||
Requirements_Attributes_Description=attribute.Description,
|
||||
Requirements_Attributes_RationaleStatement=attribute.RationaleStatement,
|
||||
Requirements_Attributes_ImpactStatement=attribute.ImpactStatement,
|
||||
Requirements_Attributes_RemediationProcedure=attribute.RemediationProcedure,
|
||||
Requirements_Attributes_AuditProcedure=attribute.AuditProcedure,
|
||||
Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation,
|
||||
Requirements_Attributes_References=attribute.References,
|
||||
Requirements_Attributes_DefaultValue=attribute.DefaultValue,
|
||||
Status="MANUAL",
|
||||
StatusExtended="Manual check",
|
||||
ResourceId="manual_check",
|
||||
ResourceName="Manual check",
|
||||
CheckId="manual",
|
||||
Muted=False,
|
||||
)
|
||||
self._data.append(compliance_row)
|
||||
@@ -163,6 +163,37 @@ class KubernetesCISModel(BaseModel):
|
||||
Muted: bool
|
||||
|
||||
|
||||
class GithubCISModel(BaseModel):
|
||||
"""
|
||||
GithubCISModel generates a finding's output in Github CIS Compliance format.
|
||||
"""
|
||||
|
||||
Provider: str
|
||||
Description: str
|
||||
Account_Name: str
|
||||
Account_Id: str
|
||||
AssessmentDate: str
|
||||
Requirements_Id: str
|
||||
Requirements_Description: str
|
||||
Requirements_Attributes_Section: str
|
||||
Requirements_Attributes_Profile: str
|
||||
Requirements_Attributes_AssessmentStatus: str
|
||||
Requirements_Attributes_Description: str
|
||||
Requirements_Attributes_RationaleStatement: str
|
||||
Requirements_Attributes_ImpactStatement: str
|
||||
Requirements_Attributes_RemediationProcedure: str
|
||||
Requirements_Attributes_AuditProcedure: str
|
||||
Requirements_Attributes_AdditionalInformation: str
|
||||
Requirements_Attributes_References: str
|
||||
Requirements_Attributes_DefaultValue: str
|
||||
Status: str
|
||||
StatusExtended: str
|
||||
ResourceId: str
|
||||
ResourceName: str
|
||||
CheckId: str
|
||||
Muted: bool
|
||||
|
||||
|
||||
# TODO: Create a parent class for the common fields of CIS and have the specific classes from each provider to inherit from it.
|
||||
# It is not done yet because it is needed to respect the current order of the fields in the output file.
|
||||
|
||||
|
||||
@@ -245,6 +245,14 @@ class Finding(BaseModel):
|
||||
)
|
||||
output_data["region"] = f"namespace: {check_output.namespace}"
|
||||
|
||||
elif provider.type == "github":
|
||||
output_data["auth_method"] = provider.auth_method
|
||||
output_data["resource_name"] = check_output.resource_name
|
||||
output_data["resource_uid"] = check_output.resource_id
|
||||
output_data["account_name"] = provider.identity.account_name
|
||||
output_data["account_uid"] = provider.identity.account_id
|
||||
output_data["region"] = check_output.repository
|
||||
|
||||
elif provider.type == "m365":
|
||||
output_data["auth_method"] = (
|
||||
f"{provider.identity.identity_type}: {provider.identity.identity_id}"
|
||||
|
||||
@@ -544,11 +544,55 @@ class HTML(Output):
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def get_github_assessment_summary(provider: Provider) -> str:
|
||||
"""
|
||||
get_github_assessment_summary gets the HTML assessment summary for the provider
|
||||
|
||||
Args:
|
||||
provider (Provider): the provider object
|
||||
|
||||
Returns:
|
||||
str: the HTML assessment summary
|
||||
"""
|
||||
try:
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
GitHub Assessment Summary
|
||||
</div>
|
||||
<ul class="list-group
|
||||
list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>GitHub account:</b> {provider.identity.account_name}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
GitHub Credentials
|
||||
</div>
|
||||
<ul class="list-group
|
||||
list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>GitHub authentication method:</b> {provider.auth_method}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>"""
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def get_m365_assessment_summary(provider: Provider) -> str:
|
||||
"""
|
||||
get_m365_assessment_summary gets the HTML assessment summary for the provider
|
||||
|
||||
Args:
|
||||
provider (Provider): the provider object
|
||||
|
||||
@@ -564,7 +608,9 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>M365 Tenant Domain:</b> {provider.identity.tenant_domain}
|
||||
<b>M365 Tenant Domain:</b> {
|
||||
provider.identity.tenant_domain
|
||||
}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -581,9 +627,14 @@ class HTML(Output):
|
||||
<li class="list-group-item">
|
||||
<b>M365 Identity ID:</b> {provider.identity.identity_id}
|
||||
</li>
|
||||
{f'''<li class="list-group-item">
|
||||
{
|
||||
f'''<li class="list-group-item">
|
||||
<b>M365 User:</b> {provider.identity.user}
|
||||
</li>''' if hasattr(provider.identity, 'user') and provider.identity.user is not None else ""}
|
||||
</li>'''
|
||||
if hasattr(provider.identity, "user")
|
||||
and provider.identity.user is not None
|
||||
else ""
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>"""
|
||||
@@ -654,6 +705,7 @@ class HTML(Output):
|
||||
# It is not pretty but useful
|
||||
# AWS_provider --> aws
|
||||
# GCP_provider --> gcp
|
||||
# GitHub_provider --> github
|
||||
# Azure_provider --> azure
|
||||
# Kubernetes_provider --> kubernetes
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ def stdout_report(finding, color, verbose, status, fix):
|
||||
details = finding.location.lower()
|
||||
if finding.check_metadata.Provider == "kubernetes":
|
||||
details = finding.namespace.lower()
|
||||
if finding.check_metadata.Provider == "github":
|
||||
details = finding.repository
|
||||
if finding.check_metadata.Provider == "m365":
|
||||
details = finding.location
|
||||
if finding.check_metadata.Provider == "nhn":
|
||||
|
||||
@@ -71,7 +71,7 @@ class Slack:
|
||||
- logo (str): The logo URL associated with the provider type.
|
||||
"""
|
||||
|
||||
# TODO: support kubernetes
|
||||
# TODO: support kubernetes, m365, github
|
||||
try:
|
||||
identity = ""
|
||||
logo = aws_logo
|
||||
@@ -125,7 +125,7 @@ class Slack:
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"\n:white_check_mark: *{stats['total_pass']} Passed findings* ({round(stats['total_pass'] / stats['findings_count'] * 100 , 2)}%)\n",
|
||||
"text": f"\n:white_check_mark: *{stats['total_pass']} Passed findings* ({round(stats['total_pass'] / stats['findings_count'] * 100, 2)}%)\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -145,7 +145,7 @@ class Slack:
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"\n:x: *{stats['total_fail']} Failed findings* ({round(stats['total_fail'] / stats['findings_count'] * 100 , 2)}%)\n ",
|
||||
"text": f"\n:x: *{stats['total_fail']} Failed findings* ({round(stats['total_fail'] / stats['findings_count'] * 100, 2)}%)\n ",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ from prowler.config.config import (
|
||||
orange_color,
|
||||
)
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.github.models import GithubAppIdentityInfo, GithubIdentityInfo
|
||||
|
||||
|
||||
def display_summary_table(
|
||||
@@ -40,6 +41,13 @@ def display_summary_table(
|
||||
elif provider.type == "kubernetes":
|
||||
entity_type = "Context"
|
||||
audited_entities = provider.identity.context
|
||||
elif provider.type == "github":
|
||||
if isinstance(provider.identity, GithubIdentityInfo):
|
||||
entity_type = "User Name"
|
||||
audited_entities = provider.identity.account_name
|
||||
elif isinstance(provider.identity, GithubAppIdentityInfo):
|
||||
entity_type = "App ID"
|
||||
audited_entities = provider.identity.app_id
|
||||
elif provider.type == "m365":
|
||||
entity_type = "Tenant Domain"
|
||||
audited_entities = provider.identity.tenant_domain
|
||||
|
||||
@@ -234,6 +234,15 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "github" in provider_class_name.lower():
|
||||
provider_class(
|
||||
personal_access_token=arguments.personal_access_token,
|
||||
oauth_app_token=arguments.oauth_app_token,
|
||||
github_app_key=arguments.github_app_key,
|
||||
github_app_id=arguments.github_app_id,
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
config_path=arguments.config_file,
|
||||
)
|
||||
|
||||
except TypeError as error:
|
||||
logger.critical(
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
from prowler.exceptions.exceptions import ProwlerException
|
||||
|
||||
|
||||
# Exceptions codes from 5000 to 5999 are reserved for Github exceptions
|
||||
class GithubBaseException(ProwlerException):
|
||||
"""Base class for Github Errors."""
|
||||
|
||||
GITHUB_ERROR_CODES = {
|
||||
(5000, "GithubEnvironmentVariableError"): {
|
||||
"message": "Github environment variable error",
|
||||
"remediation": "Check the Github environment variables and ensure they are properly set.",
|
||||
},
|
||||
(5001, "GithubNonExistentTokenError"): {
|
||||
"message": "A Github token is required to authenticate against Github",
|
||||
"remediation": "Check the Github token and ensure it is properly set up.",
|
||||
},
|
||||
(5002, "GithubInvalidTokenError"): {
|
||||
"message": "Github token provided is not valid",
|
||||
"remediation": "Check the Github token and ensure it is valid.",
|
||||
},
|
||||
(5003, "GithubSetUpSessionError"): {
|
||||
"message": "Error setting up session",
|
||||
"remediation": "Check the session setup and ensure it is properly set up.",
|
||||
},
|
||||
(5004, "GithubSetUpIdentityError"): {
|
||||
"message": "Github identity setup error due to bad credentials",
|
||||
"remediation": "Check credentials and ensure they are properly set up for Github and the identity provider.",
|
||||
},
|
||||
(5005, "GithubInvalidCredentialsError"): {
|
||||
"message": "Github invalid App Key or App ID for GitHub APP login",
|
||||
"remediation": "Check user and password and ensure they are properly set up as in your Github account.",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
provider = "Github"
|
||||
error_info = self.GITHUB_ERROR_CODES.get((code, self.__class__.__name__))
|
||||
if message:
|
||||
error_info["message"] = message
|
||||
super().__init__(
|
||||
code=code,
|
||||
source=provider,
|
||||
file=file,
|
||||
original_exception=original_exception,
|
||||
error_info=error_info,
|
||||
)
|
||||
|
||||
|
||||
class GithubCredentialsError(GithubBaseException):
|
||||
"""Base class for Github credentials errors."""
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
super().__init__(code, file, original_exception, message)
|
||||
|
||||
|
||||
class GithubEnvironmentVariableError(GithubCredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
5000, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class GithubNonExistentTokenError(GithubCredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
5001, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class GithubInvalidTokenError(GithubCredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
5002, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class GithubSetUpSessionError(GithubCredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
5003, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class GithubSetUpIdentityError(GithubCredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
5004, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class GithubInvalidCredentialsError(GithubCredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
5005, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
@@ -0,0 +1,360 @@
|
||||
import os
|
||||
from os import environ
|
||||
from typing import Union
|
||||
|
||||
from colorama import Fore, Style
|
||||
from github import Auth, Github, GithubIntegration
|
||||
|
||||
from prowler.config.config import (
|
||||
default_config_file_path,
|
||||
get_default_mute_file_path,
|
||||
load_and_validate_config_file,
|
||||
)
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.mutelist.mutelist import Mutelist
|
||||
from prowler.lib.utils.utils import print_boxes
|
||||
from prowler.providers.common.models import Audit_Metadata
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.github.exceptions.exceptions import (
|
||||
GithubEnvironmentVariableError,
|
||||
GithubInvalidCredentialsError,
|
||||
GithubInvalidTokenError,
|
||||
GithubSetUpIdentityError,
|
||||
GithubSetUpSessionError,
|
||||
)
|
||||
from prowler.providers.github.lib.mutelist.mutelist import GithubMutelist
|
||||
from prowler.providers.github.models import (
|
||||
GithubAppIdentityInfo,
|
||||
GithubIdentityInfo,
|
||||
GithubSession,
|
||||
)
|
||||
|
||||
|
||||
def format_rsa_key(key: str) -> str:
|
||||
"""
|
||||
Format an RSA private key by adding line breaks to the key body.
|
||||
This function takes an RSA private key in PEM format as input and formats it by inserting line breaks every 64 characters in the key body. This formatting is necessary for the GitHub SDK Parser to correctly process the key.
|
||||
Args:
|
||||
key (str): The RSA private key in PEM format as a string. The key should start with "-----BEGIN RSA PRIVATE KEY-----" and end with "-----END RSA PRIVATE KEY-----".
|
||||
Returns:
|
||||
str: The formatted RSA private key with line breaks added to the key body. If the input key does not have the correct headers, it is returned unchanged.
|
||||
Example:
|
||||
>>> key = "-----BEGIN RSA PRIVATE KEY-----XXXXXXXXXXXXX...-----END RSA PRIVATE KEY-----"
|
||||
>>> formatted_key = format_rsa_key(key)
|
||||
>>> print(formatted_key)
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
XXXXXXXXXXXXX...
|
||||
-----END RSA PRIVATE KEY-----
|
||||
|
||||
"""
|
||||
if (
|
||||
key.startswith("-----BEGIN RSA PRIVATE KEY-----")
|
||||
and key.endswith("-----END RSA PRIVATE KEY-----")
|
||||
and "\n" not in key
|
||||
):
|
||||
# Extract the key body (excluding the headers)
|
||||
key_body = key[
|
||||
len("-----BEGIN RSA PRIVATE KEY-----") : len(key)
|
||||
- len("-----END RSA PRIVATE KEY-----")
|
||||
].strip()
|
||||
# Add line breaks to the body
|
||||
formatted_key_body = "\n".join(
|
||||
[key_body[i : i + 64] for i in range(0, len(key_body), 64)]
|
||||
)
|
||||
# Reconstruct the key with headers and formatted body
|
||||
return f"-----BEGIN RSA PRIVATE KEY-----\n{formatted_key_body}\n-----END RSA PRIVATE KEY-----"
|
||||
return key
|
||||
|
||||
|
||||
class GithubProvider(Provider):
|
||||
"""
|
||||
GitHub Provider class
|
||||
|
||||
This class is responsible for setting up the GitHub provider, including the session, identity, audit configuration, fixer configuration, and mutelist.
|
||||
|
||||
Attributes:
|
||||
_type (str): The type of the provider.
|
||||
_auth_method (str): The authentication method used by the provider.
|
||||
_session (GithubSession): The session object for the provider.
|
||||
_identity (GithubIdentityInfo): The identity information for the provider.
|
||||
_audit_config (dict): The audit configuration for the provider.
|
||||
_fixer_config (dict): The fixer configuration for the provider.
|
||||
_mutelist (Mutelist): The mutelist for the provider.
|
||||
audit_metadata (Audit_Metadata): The audit metadata for the provider.
|
||||
"""
|
||||
|
||||
_type: str = "github"
|
||||
_auth_method: str = None
|
||||
_session: GithubSession
|
||||
_identity: GithubIdentityInfo
|
||||
_audit_config: dict
|
||||
_mutelist: Mutelist
|
||||
audit_metadata: Audit_Metadata
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# Authentication credentials
|
||||
personal_access_token: str = "",
|
||||
oauth_app_token: str = "",
|
||||
github_app_key: str = "",
|
||||
github_app_id: int = 0,
|
||||
# Provider configuration
|
||||
config_path: str = None,
|
||||
config_content: dict = None,
|
||||
fixer_config: dict = {},
|
||||
mutelist_path: str = None,
|
||||
mutelist_content: dict = None,
|
||||
):
|
||||
"""
|
||||
GitHub Provider constructor
|
||||
|
||||
Args:
|
||||
personal_access_token (str): GitHub personal access token.
|
||||
oauth_app_token (str): GitHub OAuth App token.
|
||||
github_app_key (str): GitHub App key.
|
||||
github_app_id (int): GitHub App ID.
|
||||
config_path (str): Path to the audit configuration file.
|
||||
config_content (dict): Audit configuration content.
|
||||
fixer_config (dict): Fixer configuration content.
|
||||
mutelist_path (str): Path to the mutelist file.
|
||||
mutelist_content (dict): Mutelist content.
|
||||
"""
|
||||
logger.info("Instantiating GitHub Provider...")
|
||||
|
||||
self._session = self.setup_session(
|
||||
personal_access_token,
|
||||
oauth_app_token,
|
||||
github_app_id,
|
||||
github_app_key,
|
||||
)
|
||||
|
||||
self._identity = self.setup_identity()
|
||||
|
||||
# Audit Config
|
||||
if config_content:
|
||||
self._audit_config = config_content
|
||||
else:
|
||||
if not config_path:
|
||||
config_path = default_config_file_path
|
||||
self._audit_config = load_and_validate_config_file(self._type, config_path)
|
||||
|
||||
# Fixer Config
|
||||
self._fixer_config = fixer_config
|
||||
|
||||
# Mutelist
|
||||
if mutelist_content:
|
||||
self._mutelist = GithubMutelist(
|
||||
mutelist_content=mutelist_content,
|
||||
)
|
||||
else:
|
||||
if not mutelist_path:
|
||||
mutelist_path = get_default_mute_file_path(self.type)
|
||||
self._mutelist = GithubMutelist(
|
||||
mutelist_path=mutelist_path,
|
||||
)
|
||||
Provider.set_global_provider(self)
|
||||
|
||||
@property
|
||||
def auth_method(self):
|
||||
"""Returns the authentication method for the GitHub provider."""
|
||||
return self._auth_method
|
||||
|
||||
@property
|
||||
def pat(self):
|
||||
"""Returns the personal access token for the GitHub provider."""
|
||||
return self._pat
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
"""Returns the session object for the GitHub provider."""
|
||||
return self._session
|
||||
|
||||
@property
|
||||
def identity(self):
|
||||
"""Returns the identity information for the GitHub provider."""
|
||||
return self._identity
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
"""Returns the type of the GitHub provider."""
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def audit_config(self):
|
||||
return self._audit_config
|
||||
|
||||
@property
|
||||
def fixer_config(self):
|
||||
return self._fixer_config
|
||||
|
||||
@property
|
||||
def mutelist(self) -> GithubMutelist:
|
||||
"""
|
||||
mutelist method returns the provider's mutelist.
|
||||
"""
|
||||
return self._mutelist
|
||||
|
||||
def setup_session(
|
||||
self,
|
||||
personal_access_token: str = None,
|
||||
oauth_app_token: str = None,
|
||||
github_app_id: int = 0,
|
||||
github_app_key: str = None,
|
||||
) -> GithubSession:
|
||||
"""
|
||||
Returns the GitHub headers responsible authenticating API calls.
|
||||
|
||||
Args:
|
||||
personal_access_token (str): GitHub personal access token.
|
||||
oauth_app_token (str): GitHub OAuth App token.
|
||||
github_app_id (int): GitHub App ID.
|
||||
github_app_key (str): GitHub App key.
|
||||
|
||||
Returns:
|
||||
GithubSession: Authenticated session token for API requests.
|
||||
"""
|
||||
|
||||
session_token = ""
|
||||
app_key = ""
|
||||
app_id = 0
|
||||
|
||||
try:
|
||||
# Ensure that at least one authentication method is selected. Default to environment variable for PAT if none is provided.
|
||||
if personal_access_token:
|
||||
session_token = personal_access_token
|
||||
self._auth_method = "Personal Access Token"
|
||||
|
||||
elif oauth_app_token:
|
||||
session_token = oauth_app_token
|
||||
self._auth_method = "OAuth App Token"
|
||||
|
||||
elif github_app_id and github_app_key:
|
||||
app_id = github_app_id
|
||||
with open(github_app_key, "r") as rsa_key:
|
||||
app_key = rsa_key.read()
|
||||
|
||||
self._auth_method = "GitHub App Token"
|
||||
|
||||
else:
|
||||
# PAT
|
||||
logger.info(
|
||||
"Looking for GITHUB_PERSONAL_ACCESS_TOKEN environment variable as user has not provided any token...."
|
||||
)
|
||||
session_token = environ.get("GITHUB_PERSONAL_ACCESS_TOKEN", "")
|
||||
if session_token:
|
||||
self._auth_method = "Environment Variable for Personal Access Token"
|
||||
|
||||
if not session_token:
|
||||
# OAUTH
|
||||
logger.info(
|
||||
"Looking for GITHUB_OAUTH_TOKEN environment variable as user has not provided any token...."
|
||||
)
|
||||
session_token = environ.get("GITHUB_OAUTH_APP_TOKEN", "")
|
||||
if session_token:
|
||||
self._auth_method = "Environment Variable for OAuth App Token"
|
||||
|
||||
if not session_token:
|
||||
# APP
|
||||
logger.info(
|
||||
"Looking for GITHUB_APP_ID and GITHUB_APP_KEY environment variables as user has not provided any token...."
|
||||
)
|
||||
app_id = environ.get("GITHUB_APP_ID", "")
|
||||
app_key = format_rsa_key(environ.get(r"GITHUB_APP_KEY", ""))
|
||||
|
||||
if app_id and app_key:
|
||||
self._auth_method = (
|
||||
"Environment Variables for GitHub App Key and ID"
|
||||
)
|
||||
|
||||
if not self._auth_method:
|
||||
raise GithubEnvironmentVariableError(
|
||||
file=os.path.basename(__file__),
|
||||
message="No authentication method selected and not environment variables were found.",
|
||||
)
|
||||
|
||||
credentials = GithubSession(
|
||||
token=session_token,
|
||||
key=app_key,
|
||||
id=app_id,
|
||||
)
|
||||
|
||||
return credentials
|
||||
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
|
||||
)
|
||||
raise GithubSetUpSessionError(
|
||||
original_exception=error,
|
||||
)
|
||||
|
||||
def setup_identity(
|
||||
self,
|
||||
) -> Union[GithubIdentityInfo, GithubAppIdentityInfo]:
|
||||
"""
|
||||
Returns the GitHub identity information
|
||||
|
||||
Returns:
|
||||
GithubIdentityInfo | GithubAppIdentityInfo: An instance of GithubIdentityInfo or GithubAppIdentityInfo containing the identity information.
|
||||
"""
|
||||
credentials = self.session
|
||||
|
||||
try:
|
||||
if credentials.token:
|
||||
auth = Auth.Token(credentials.token)
|
||||
g = Github(auth=auth)
|
||||
try:
|
||||
identity = GithubIdentityInfo(
|
||||
account_id=g.get_user().id,
|
||||
account_name=g.get_user().login,
|
||||
account_url=g.get_user().url,
|
||||
)
|
||||
return identity
|
||||
|
||||
except Exception as error:
|
||||
raise GithubInvalidTokenError(
|
||||
original_exception=error,
|
||||
)
|
||||
|
||||
elif credentials.id != 0 and credentials.key:
|
||||
auth = Auth.AppAuth(credentials.id, credentials.key)
|
||||
gi = GithubIntegration(auth=auth)
|
||||
try:
|
||||
identity = GithubAppIdentityInfo(app_id=gi.get_app().id)
|
||||
return identity
|
||||
|
||||
except Exception as error:
|
||||
raise GithubInvalidCredentialsError(
|
||||
original_exception=error,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
|
||||
)
|
||||
raise GithubSetUpIdentityError(
|
||||
original_exception=error,
|
||||
)
|
||||
|
||||
def print_credentials(self):
|
||||
"""
|
||||
Prints the GitHub credentials.
|
||||
|
||||
Usage:
|
||||
>>> self.print_credentials()
|
||||
"""
|
||||
if isinstance(self.identity, GithubIdentityInfo):
|
||||
report_lines = [
|
||||
f"GitHub Account: {Fore.YELLOW}{self.identity.account_name}{Style.RESET_ALL}",
|
||||
f"GitHub Account ID: {Fore.YELLOW}{self.identity.account_id}{Style.RESET_ALL}",
|
||||
f"Authentication Method: {Fore.YELLOW}{self.auth_method}{Style.RESET_ALL}",
|
||||
]
|
||||
elif isinstance(self.identity, GithubAppIdentityInfo):
|
||||
report_lines = [
|
||||
f"GitHub App ID: {Fore.YELLOW}{self.identity.app_id}{Style.RESET_ALL}",
|
||||
f"Authentication Method: {Fore.YELLOW}{self.auth_method}{Style.RESET_ALL}",
|
||||
]
|
||||
report_title = (
|
||||
f"{Style.BRIGHT}Using the GitHub credentials below:{Style.RESET_ALL}"
|
||||
)
|
||||
print_boxes(report_lines, report_title)
|
||||
@@ -0,0 +1,34 @@
|
||||
def init_parser(self):
|
||||
"""Init the Github Provider CLI parser"""
|
||||
github_parser = self.subparsers.add_parser(
|
||||
"github", parents=[self.common_providers_parser], help="GitHub Provider"
|
||||
)
|
||||
github_auth_subparser = github_parser.add_argument_group("Authentication Modes")
|
||||
# Authentication Modes
|
||||
github_auth_subparser.add_argument(
|
||||
"--personal-access-token",
|
||||
nargs="?",
|
||||
help="Personal Access Token to log in against GitHub",
|
||||
default=None,
|
||||
)
|
||||
|
||||
github_auth_subparser.add_argument(
|
||||
"--oauth-app-token",
|
||||
nargs="?",
|
||||
help="OAuth App Token to log in against GitHub",
|
||||
default=None,
|
||||
)
|
||||
|
||||
# GitHub App Authentication
|
||||
github_auth_subparser.add_argument(
|
||||
"--github-app-id",
|
||||
nargs="?",
|
||||
help="GitHub App ID to log in against GitHub",
|
||||
default=None,
|
||||
)
|
||||
github_auth_subparser.add_argument(
|
||||
"--github-app-key",
|
||||
nargs="?",
|
||||
help="GitHub App Key Path to log in against GitHub",
|
||||
default=None,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
from prowler.lib.check.models import CheckReportGithub
|
||||
from prowler.lib.mutelist.mutelist import Mutelist
|
||||
from prowler.lib.outputs.utils import unroll_dict, unroll_tags
|
||||
|
||||
|
||||
class GithubMutelist(Mutelist):
|
||||
def is_finding_muted(
|
||||
self,
|
||||
finding: CheckReportGithub,
|
||||
account_name: str,
|
||||
) -> bool:
|
||||
return self.is_muted(
|
||||
account_name,
|
||||
finding.check_metadata.CheckID,
|
||||
"*", # TODO: Study regions in GitHub
|
||||
finding.resource_name,
|
||||
unroll_dict(unroll_tags(finding.resource_tags)),
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
from github import Auth, Github, GithubIntegration
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.github.github_provider import GithubProvider
|
||||
|
||||
|
||||
class GithubService:
|
||||
def __init__(
|
||||
self,
|
||||
service: str,
|
||||
provider: GithubProvider,
|
||||
):
|
||||
self.clients = self.__set_clients__(
|
||||
provider.session,
|
||||
)
|
||||
|
||||
self.audit_config = provider.audit_config
|
||||
self.fixer_config = provider.fixer_config
|
||||
|
||||
def __set_clients__(self, session):
|
||||
clients = []
|
||||
try:
|
||||
if session.token:
|
||||
auth = Auth.Token(session.token)
|
||||
clients = [Github(auth=auth)]
|
||||
|
||||
elif session.key and session.id:
|
||||
auth = Auth.AppAuth(
|
||||
session.id,
|
||||
session.key,
|
||||
)
|
||||
gi = GithubIntegration(auth=auth)
|
||||
|
||||
for installation in gi.get_installations():
|
||||
clients.append(installation.get_github_for_installation())
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return clients
|
||||
@@ -0,0 +1,42 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from prowler.config.config import output_file_timestamp
|
||||
from prowler.providers.common.models import ProviderOutputOptions
|
||||
|
||||
|
||||
class GithubSession(BaseModel):
|
||||
token: str
|
||||
key: str
|
||||
id: str
|
||||
|
||||
|
||||
class GithubIdentityInfo(BaseModel):
|
||||
account_id: str
|
||||
account_name: str
|
||||
account_url: str
|
||||
|
||||
|
||||
class GithubAppIdentityInfo(BaseModel):
|
||||
app_id: str
|
||||
|
||||
|
||||
class GithubOutputOptions(ProviderOutputOptions):
|
||||
def __init__(self, arguments, bulk_checks_metadata, identity):
|
||||
# First call ProviderOutputOptions init
|
||||
super().__init__(arguments, bulk_checks_metadata)
|
||||
# TODO move the below if to ProviderOutputOptions
|
||||
# Check if custom output filename was input, if not, set the default
|
||||
if (
|
||||
not hasattr(arguments, "output_filename")
|
||||
or arguments.output_filename is None
|
||||
):
|
||||
if isinstance(identity, GithubIdentityInfo):
|
||||
self.output_filename = (
|
||||
f"prowler-output-{identity.account_name}-{output_file_timestamp}"
|
||||
)
|
||||
elif isinstance(identity, GithubAppIdentityInfo):
|
||||
self.output_filename = (
|
||||
f"prowler-output-{identity.app_id}-{output_file_timestamp}"
|
||||
)
|
||||
else:
|
||||
self.output_filename = arguments.output_filename
|
||||
@@ -0,0 +1,6 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.github.services.organization.organization_service import (
|
||||
Organization,
|
||||
)
|
||||
|
||||
organization_client = Organization(Provider.get_global_provider())
|
||||
@@ -0,0 +1,33 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.github.lib.service.service import GithubService
|
||||
|
||||
|
||||
class Organization(GithubService):
|
||||
def __init__(self, provider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.organizations = self._list_organizations()
|
||||
|
||||
def _list_organizations(self):
|
||||
logger.info("Organization - Listing Organizations...")
|
||||
organizations = {}
|
||||
try:
|
||||
for client in self.clients:
|
||||
for org in client.get_user().get_orgs():
|
||||
organizations[org.id] = Org(
|
||||
id=org.id,
|
||||
name=org.login,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return organizations
|
||||
|
||||
|
||||
class Org(BaseModel):
|
||||
"""Model for Github Organization"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.github.services.repository.repository_service import Repository
|
||||
|
||||
repository_client = Repository(Provider.get_global_provider())
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"Provider": "github",
|
||||
"CheckID": "repository_code_changes_multi_approval_requirement",
|
||||
"CheckTitle": "Check if repositories require at least 2 code changes approvals",
|
||||
"CheckType": [],
|
||||
"ServiceName": "repository",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "github:user-id:repository/repository-name",
|
||||
"Severity": "high",
|
||||
"ResourceType": "GitHubRepository",
|
||||
"Description": "Ensure that repositories require at least 2 code changes approvals before merging a pull request.",
|
||||
"Risk": "If repositories do not require at least 2 code changes approvals before merging a pull request, it is possible that code changes are not being reviewed by multiple people, which could lead to the introduction of bugs or security vulnerabilities.",
|
||||
"RelatedUrl": "https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/approving-a-pull-request-with-required-reviews",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "To require at least 2 code changes approvals before merging a pull request, navigate to the repository settings, click on 'Branches', and then 'Add rule'.",
|
||||
"Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-pull-request-reviews-before-merging"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
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_code_changes_multi_approval_requirement(Check):
|
||||
"""Check if a repository enforces at least 2 approvals for code changes
|
||||
|
||||
This class verifies whether each repository enforces at least 2 approvals for code changes.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGithub]:
|
||||
"""Execute the Github Repository code changes enforce multi approval requirement check
|
||||
|
||||
Iterates over each repository and checks if the repository enforces at least 2 approvals for code changes.
|
||||
|
||||
Returns:
|
||||
List[CheckReportGithub]: A list of reports for each repository
|
||||
"""
|
||||
findings = []
|
||||
for repo in repository_client.repositories.values():
|
||||
if repo.approval_count is not None:
|
||||
report = CheckReportGithub(
|
||||
metadata=self.metadata(), resource=repo, repository=repo.name
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Repository {repo.name} does not enforce at least 2 approvals for code changes."
|
||||
|
||||
if repo.approval_count >= 2:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Repository {repo.name} does enforce at least 2 approvals for code changes."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"Provider": "github",
|
||||
"CheckID": "repository_public_has_securitymd_file",
|
||||
"CheckTitle": "Check if public repositories have a SECURITY.md file",
|
||||
"CheckType": [],
|
||||
"ServiceName": "repository",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "github:user-id:repository/repository-name",
|
||||
"Severity": "low",
|
||||
"ResourceType": "GitHubRepository",
|
||||
"Description": "Ensure that public repositories have a SECURITY.md file",
|
||||
"Risk": "Not having a SECURITY.md file in a public repository may lead to security vulnerabilities being overlooked by users and contributors.",
|
||||
"RelatedUrl": "https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Add a SECURITY.md file to the root of the repository. The file should contain information on how to report a security vulnerability, the security policy of the repository, and any other relevant information.",
|
||||
"Url": "https://github.blog/changelog/2019-05-23-security-policy/"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
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_public_has_securitymd_file(Check):
|
||||
"""Check if a public repository has a SECURITY.md file
|
||||
|
||||
This class verifies whether each public repository has a SECURITY.md file.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGithub]:
|
||||
"""Execute the Github Repository Public Has SECURITY.md File check
|
||||
|
||||
Iterates over all public repositories and checks if they have a SECURITY.md file.
|
||||
|
||||
Returns:
|
||||
List[CheckReportGithub]: A list of reports for each repository
|
||||
"""
|
||||
findings = []
|
||||
for repo in repository_client.repositories.values():
|
||||
if not repo.private and repo.securitymd is not None:
|
||||
report = CheckReportGithub(
|
||||
metadata=self.metadata(), resource=repo, repository=repo.name
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Repository {repo.name} does have a SECURITY.md file."
|
||||
)
|
||||
|
||||
if not repo.securitymd:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Repository {repo.name} does not have a SECURITY.md file."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,87 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.github.lib.service.service import GithubService
|
||||
|
||||
|
||||
class Repository(GithubService):
|
||||
def __init__(self, provider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.repositories = self._list_repositories()
|
||||
|
||||
def _list_repositories(self):
|
||||
logger.info("Repository - Listing Repositories...")
|
||||
repos = {}
|
||||
try:
|
||||
for client in self.clients:
|
||||
for repo in client.get_user().get_repos():
|
||||
default_branch = repo.default_branch
|
||||
securitymd_exists = False
|
||||
try:
|
||||
securitymd_exists = repo.get_contents("SECURITY.md") is not None
|
||||
except Exception as error:
|
||||
if "404" in str(error):
|
||||
securitymd_exists = False
|
||||
else:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
securitymd_exists = None
|
||||
|
||||
require_pr = False
|
||||
approval_cnt = 0
|
||||
try:
|
||||
branch = repo.get_branch(default_branch)
|
||||
if branch.protected:
|
||||
protection = branch.get_protection()
|
||||
if protection:
|
||||
require_pr = (
|
||||
protection.required_pull_request_reviews is not None
|
||||
)
|
||||
approval_cnt = (
|
||||
protection.required_pull_request_reviews.required_approving_review_count
|
||||
if require_pr
|
||||
else 0
|
||||
)
|
||||
except Exception as error:
|
||||
if "404" in str(error):
|
||||
require_pr = False
|
||||
approval_cnt = 0
|
||||
else:
|
||||
require_pr = None
|
||||
approval_cnt = None
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
repos[repo.id] = Repo(
|
||||
id=repo.id,
|
||||
name=repo.name,
|
||||
full_name=repo.full_name,
|
||||
default_branch=repo.default_branch,
|
||||
private=repo.private,
|
||||
securitymd=securitymd_exists,
|
||||
require_pull_request=require_pr,
|
||||
approval_count=approval_cnt,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return repos
|
||||
|
||||
|
||||
class Repo(BaseModel):
|
||||
"""Model for Github Repository"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
full_name: str
|
||||
default_branch: str
|
||||
private: bool
|
||||
securitymd: Optional[bool]
|
||||
require_pull_request: Optional[bool]
|
||||
approval_count: Optional[int]
|
||||
@@ -50,6 +50,7 @@ dependencies = [
|
||||
"pandas==2.2.3",
|
||||
"py-ocsf-models==0.3.1",
|
||||
"pydantic==1.10.21",
|
||||
"pygithub==2.5.0",
|
||||
"python-dateutil (>=2.9.0.post0,<3.0.0)",
|
||||
"pytz==2025.1",
|
||||
"schema==0.7.7",
|
||||
|
||||
@@ -330,3 +330,55 @@ class TestCompliance:
|
||||
"CIS-2.0": ["2.1.3"],
|
||||
"CIS-2.1": ["2.1.3"],
|
||||
}
|
||||
|
||||
def test_get_check_compliance_github(self):
|
||||
check_compliance = [
|
||||
Compliance(
|
||||
Framework="CIS",
|
||||
Provider="Github",
|
||||
Version="1.0",
|
||||
Description="This document provides prescriptive guidance for establishing a secure configuration posture for securing GitHub.",
|
||||
Requirements=[
|
||||
Compliance_Requirement(
|
||||
Checks=[],
|
||||
Id="1.1.11",
|
||||
Description="Ensure all open comments are resolved before allowing code change merging",
|
||||
Attributes=[
|
||||
CIS_Requirement_Attribute(
|
||||
Section="1.1",
|
||||
Profile="Level 2",
|
||||
AssessmentStatus="Manual",
|
||||
Description='Organizations should enforce a "no open comments" policy before allowing code change merging.',
|
||||
RationaleStatement="In an open code change proposal, reviewers can leave comments containing their questions and suggestions. These comments can also include potential bugs and security issues. Requiring all comments on a code change proposal to be resolved before it can be merged ensures that every concern is properly addressed or acknowledged before the new code changes are introduced to the code base.",
|
||||
ImpactStatement="Code change proposals containing open comments would not be able to be merged into the code base.",
|
||||
RemediationProcedure='For each code repository in use, require open comments to be resolved before the relevant code change can be merged by performing the following:\n \n\n 1. On GitHub.com, navigate to the main page of the repository.\n 2. Under your repository name, click **Settings**.\n 3. In the "Code and automation" section of the sidebar, click **Branches**.\n 4. Next to "Branch protection rules", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn\'t, click **Add rule**.\n 5. If you add the rule, under "Branch name pattern", type the branch name or pattern you want to protect.\n 6. Select **Require conversation resolution before merging**.\n 7. Click **Create** or **Save changes**.',
|
||||
AuditProcedure='For every code repository in use, verify that each merged code change does not contain open, unattended comments by performing the following:\n \n\n 1. On GitHub.com, navigate to the main page of the repository.\n 2. Under your repository name, click **Settings**.\n 3. In the "Code and automation" section of the sidebar, click **Branches**.\n 4. Next to "Branch protection rules", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn\'t, you are not compliant.\n 5. Ensure that **Require conversation resolution before merging** is checked.',
|
||||
AdditionalInformation="",
|
||||
References="",
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
finding = Check_Report(
|
||||
metadata=load_check_metadata(
|
||||
f"{path.dirname(path.realpath(__file__))}/../fixtures/metadata.json"
|
||||
).json(),
|
||||
resource={},
|
||||
)
|
||||
finding.resource_details = "Test resource details"
|
||||
finding.resource_id = "test-resource"
|
||||
finding.resource_arn = "test-arn"
|
||||
finding.region = "eu-west-1"
|
||||
finding.status = "PASS"
|
||||
finding.status_extended = "This is a test"
|
||||
|
||||
bulk_checks_metadata = {}
|
||||
bulk_checks_metadata["iam_user_accesskey_unused"] = mock.MagicMock()
|
||||
bulk_checks_metadata["iam_user_accesskey_unused"].Compliance = check_compliance
|
||||
|
||||
assert get_check_compliance(finding, "github", bulk_checks_metadata) == {
|
||||
"CIS-1.0": ["1.1.11"],
|
||||
}
|
||||
|
||||
@@ -328,7 +328,10 @@ class TestOCSF:
|
||||
assert isinstance(resource_details[0], ResourceDetails)
|
||||
assert resource_details[0].labels == ["Name:test", "Environment:dev"]
|
||||
assert resource_details[0].name == finding_output.resource_name
|
||||
assert resource_details[0].uid == finding_output.resource_uid
|
||||
assert resource_details[0].data == {
|
||||
"details": finding_output.resource_details,
|
||||
"metadata": {}, # TODO: add metadata to the resource details
|
||||
}
|
||||
assert resource_details[0].type == finding_output.metadata.ResourceType
|
||||
assert resource_details[0].cloud_partition == finding_output.partition
|
||||
assert resource_details[0].region == finding_output.region
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from mock import MagicMock
|
||||
|
||||
from prowler.providers.github.github_provider import GithubProvider
|
||||
from prowler.providers.github.models import GithubIdentityInfo, GithubSession
|
||||
|
||||
# GitHub Identity
|
||||
ACCOUNT_NAME = "account-name"
|
||||
ACCOUNT_ID = "account-id"
|
||||
ACCOUNT_URL = "/user"
|
||||
|
||||
# GitHub Credentials
|
||||
PAT_TOKEN = "github-token"
|
||||
OAUTH_TOKEN = "oauth-token"
|
||||
APP_ID = "app-id"
|
||||
APP_KEY = "app-key"
|
||||
|
||||
|
||||
# Mocked GitHub Provider
|
||||
def set_mocked_github_provider(
|
||||
auth_method: str = "personal_access",
|
||||
credentials: GithubSession = GithubSession(token=PAT_TOKEN, id=APP_ID, key=APP_KEY),
|
||||
identity: GithubIdentityInfo = GithubIdentityInfo(
|
||||
account_name=ACCOUNT_NAME,
|
||||
account_id=ACCOUNT_ID,
|
||||
account_url=ACCOUNT_URL,
|
||||
),
|
||||
audit_config: dict = None,
|
||||
) -> GithubProvider:
|
||||
|
||||
provider = MagicMock()
|
||||
provider.type = "github"
|
||||
provider.auth_method = auth_method
|
||||
provider.session = credentials
|
||||
provider.identity = identity
|
||||
provider.audit_config = audit_config
|
||||
|
||||
return provider
|
||||
@@ -0,0 +1,137 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.config.config import (
|
||||
default_fixer_config_file_path,
|
||||
load_and_validate_config_file,
|
||||
)
|
||||
from prowler.providers.github.github_provider import GithubProvider
|
||||
from prowler.providers.github.models import (
|
||||
GithubAppIdentityInfo,
|
||||
GithubIdentityInfo,
|
||||
GithubSession,
|
||||
)
|
||||
from tests.providers.github.github_fixtures import (
|
||||
ACCOUNT_ID,
|
||||
ACCOUNT_NAME,
|
||||
ACCOUNT_URL,
|
||||
APP_ID,
|
||||
APP_KEY,
|
||||
OAUTH_TOKEN,
|
||||
PAT_TOKEN,
|
||||
)
|
||||
|
||||
|
||||
class TestGitHubProvider:
|
||||
def test_github_provider_PAT(self):
|
||||
personal_access_token = PAT_TOKEN
|
||||
oauth_app_token = None
|
||||
github_app_id = None
|
||||
github_app_key = None
|
||||
fixer_config = load_and_validate_config_file(
|
||||
"github", default_fixer_config_file_path
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.github.github_provider.GithubProvider.setup_session",
|
||||
return_value=GithubSession(token=PAT_TOKEN, id="", key=""),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.github.github_provider.GithubProvider.setup_identity",
|
||||
return_value=GithubIdentityInfo(
|
||||
account_id=ACCOUNT_ID,
|
||||
account_name=ACCOUNT_NAME,
|
||||
account_url=ACCOUNT_URL,
|
||||
),
|
||||
),
|
||||
):
|
||||
provider = GithubProvider(
|
||||
personal_access_token,
|
||||
oauth_app_token,
|
||||
github_app_id,
|
||||
github_app_key,
|
||||
)
|
||||
|
||||
assert provider._type == "github"
|
||||
assert provider.session == GithubSession(token=PAT_TOKEN, id="", key="")
|
||||
assert provider.identity == GithubIdentityInfo(
|
||||
account_name=ACCOUNT_NAME,
|
||||
account_id=ACCOUNT_ID,
|
||||
account_url=ACCOUNT_URL,
|
||||
)
|
||||
assert provider._audit_config == {}
|
||||
assert provider._fixer_config == fixer_config
|
||||
|
||||
def test_github_provider_OAuth(self):
|
||||
personal_access_token = None
|
||||
oauth_app_token = OAUTH_TOKEN
|
||||
github_app_id = None
|
||||
github_app_key = None
|
||||
fixer_config = load_and_validate_config_file(
|
||||
"github", default_fixer_config_file_path
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.github.github_provider.GithubProvider.setup_session",
|
||||
return_value=GithubSession(token=OAUTH_TOKEN, id="", key=""),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.github.github_provider.GithubProvider.setup_identity",
|
||||
return_value=GithubIdentityInfo(
|
||||
account_id=ACCOUNT_ID,
|
||||
account_name=ACCOUNT_NAME,
|
||||
account_url=ACCOUNT_URL,
|
||||
),
|
||||
),
|
||||
):
|
||||
provider = GithubProvider(
|
||||
personal_access_token,
|
||||
oauth_app_token,
|
||||
github_app_id,
|
||||
github_app_key,
|
||||
)
|
||||
|
||||
assert provider._type == "github"
|
||||
assert provider.session == GithubSession(token=OAUTH_TOKEN, id="", key="")
|
||||
assert provider.identity == GithubIdentityInfo(
|
||||
account_name=ACCOUNT_NAME,
|
||||
account_id=ACCOUNT_ID,
|
||||
account_url=ACCOUNT_URL,
|
||||
)
|
||||
assert provider._audit_config == {}
|
||||
assert provider._fixer_config == fixer_config
|
||||
|
||||
def test_github_provider_App(self):
|
||||
personal_access_token = None
|
||||
oauth_app_token = None
|
||||
github_app_id = APP_ID
|
||||
github_app_key = APP_KEY
|
||||
fixer_config = load_and_validate_config_file(
|
||||
"github", default_fixer_config_file_path
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.github.github_provider.GithubProvider.setup_session",
|
||||
return_value=GithubSession(token="", id=APP_ID, key=APP_KEY),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.github.github_provider.GithubProvider.setup_identity",
|
||||
return_value=GithubAppIdentityInfo(
|
||||
app_id=APP_ID,
|
||||
),
|
||||
),
|
||||
):
|
||||
provider = GithubProvider(
|
||||
personal_access_token,
|
||||
oauth_app_token,
|
||||
github_app_id,
|
||||
github_app_key,
|
||||
)
|
||||
|
||||
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._fixer_config == fixer_config
|
||||
@@ -0,0 +1,17 @@
|
||||
### Account, Check and/or Region can be * to apply for all the cases.
|
||||
### Account == <GitHub Account Name>
|
||||
### Resources and tags are lists that can have either Regex or Keywords.
|
||||
### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together.
|
||||
### Use an alternation Regex to match one of multiple tags with "ORed" logic.
|
||||
### For each check you can except Accounts, Regions, Resources and/or Tags.
|
||||
########################### MUTELIST EXAMPLE ###########################
|
||||
Mutelist:
|
||||
Accounts:
|
||||
"account_1":
|
||||
Checks:
|
||||
"repository_public_has_securitymd_file":
|
||||
Regions:
|
||||
- "*"
|
||||
Resources:
|
||||
- "resource_1"
|
||||
- "resource_2"
|
||||
@@ -0,0 +1,100 @@
|
||||
import yaml
|
||||
from mock import MagicMock
|
||||
|
||||
from prowler.providers.github.lib.mutelist.mutelist import GithubMutelist
|
||||
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
|
||||
|
||||
MUTELIST_FIXTURE_PATH = (
|
||||
"tests/providers/github/lib/mutelist/fixtures/github_mutelist.yaml"
|
||||
)
|
||||
|
||||
|
||||
class TestGithubMutelist:
|
||||
def test_get_mutelist_file_from_local_file(self):
|
||||
mutelist = GithubMutelist(mutelist_path=MUTELIST_FIXTURE_PATH)
|
||||
|
||||
with open(MUTELIST_FIXTURE_PATH) as f:
|
||||
mutelist_fixture = yaml.safe_load(f)["Mutelist"]
|
||||
|
||||
assert mutelist.mutelist == mutelist_fixture
|
||||
assert mutelist.mutelist_file_path == MUTELIST_FIXTURE_PATH
|
||||
|
||||
def test_get_mutelist_file_from_local_file_non_existent(self):
|
||||
mutelist_path = "tests/lib/mutelist/fixtures/not_present"
|
||||
mutelist = GithubMutelist(mutelist_path=mutelist_path)
|
||||
|
||||
assert mutelist.mutelist == {}
|
||||
assert mutelist.mutelist_file_path == mutelist_path
|
||||
|
||||
def test_validate_mutelist_not_valid_key(self):
|
||||
mutelist_path = MUTELIST_FIXTURE_PATH
|
||||
with open(mutelist_path) as f:
|
||||
mutelist_fixture = yaml.safe_load(f)["Mutelist"]
|
||||
|
||||
mutelist_fixture["Accounts1"] = mutelist_fixture["Accounts"]
|
||||
del mutelist_fixture["Accounts"]
|
||||
|
||||
mutelist = GithubMutelist(mutelist_content=mutelist_fixture)
|
||||
|
||||
assert not mutelist.validate_mutelist()
|
||||
assert mutelist.mutelist == {}
|
||||
assert mutelist.mutelist_file_path is None
|
||||
|
||||
def test_is_finding_muted(self):
|
||||
# Mutelist
|
||||
mutelist_content = {
|
||||
"Accounts": {
|
||||
"account_1": {
|
||||
"Checks": {
|
||||
"check_test": {
|
||||
"Regions": ["*"],
|
||||
"Resources": ["test_resource"],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutelist = GithubMutelist(mutelist_content=mutelist_content)
|
||||
|
||||
finding = MagicMock()
|
||||
finding.check_metadata = MagicMock()
|
||||
finding.check_metadata.CheckID = "check_test"
|
||||
finding.status = "FAIL"
|
||||
finding.resource_name = "test_resource"
|
||||
finding.account_name = "account_1"
|
||||
finding.location = "test-location"
|
||||
finding.resource_tags = []
|
||||
|
||||
assert mutelist.is_finding_muted(finding, finding.account_name)
|
||||
|
||||
def test_mute_finding(self):
|
||||
# Mutelist
|
||||
mutelist_content = {
|
||||
"Accounts": {
|
||||
"account_1": {
|
||||
"Checks": {
|
||||
"check_test": {
|
||||
"Regions": ["*"],
|
||||
"Resources": ["test_resource"],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutelist = GithubMutelist(mutelist_content=mutelist_content)
|
||||
|
||||
finding_1 = generate_finding_output(
|
||||
check_id="check_test",
|
||||
status="FAIL",
|
||||
account_uid="account_1",
|
||||
resource_uid="test_resource",
|
||||
resource_tags=[],
|
||||
)
|
||||
|
||||
muted_finding = mutelist.mute_finding(finding=finding_1)
|
||||
|
||||
assert muted_finding.status == "MUTED"
|
||||
assert muted_finding.muted
|
||||
assert muted_finding.raw["status"] == "FAIL"
|
||||
@@ -0,0 +1,35 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.github.services.organization.organization_service import (
|
||||
Org,
|
||||
Organization,
|
||||
)
|
||||
from tests.providers.github.github_fixtures import set_mocked_github_provider
|
||||
|
||||
|
||||
def mock_list_organizations(_):
|
||||
return {
|
||||
1: Org(
|
||||
id=1,
|
||||
name="test-organization",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@patch(
|
||||
"prowler.providers.github.services.organization.organization_service.Organization._list_organizations",
|
||||
new=mock_list_organizations,
|
||||
)
|
||||
class Test_Repository_Service:
|
||||
def test_get_client(self):
|
||||
repository_service = Organization(set_mocked_github_provider())
|
||||
assert repository_service.clients[0].__class__.__name__ == "Github"
|
||||
|
||||
def test_get_service(self):
|
||||
repository_service = Organization(set_mocked_github_provider())
|
||||
assert repository_service.__class__.__name__ == "Organization"
|
||||
|
||||
def test_list_organizations(self):
|
||||
repository_service = Organization(set_mocked_github_provider())
|
||||
assert len(repository_service.organizations) == 1
|
||||
assert repository_service.organizations[1].name == "test-organization"
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
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_code_changes_multi_approval_requirement:
|
||||
def test_no_repositories(self):
|
||||
repository_client = mock.MagicMock
|
||||
repository_client.repositories = {}
|
||||
|
||||
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_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement.repository_client",
|
||||
new=repository_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.github.services.repository.repository_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement import (
|
||||
repository_code_changes_multi_approval_requirement,
|
||||
)
|
||||
|
||||
check = repository_code_changes_multi_approval_requirement()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_repository_no_require_pull_request(self):
|
||||
repository_client = mock.MagicMock
|
||||
repo_name = "repo1"
|
||||
repository_client.repositories = {
|
||||
1: Repo(
|
||||
id=1,
|
||||
name=repo_name,
|
||||
full_name="account-name/repo1",
|
||||
default_branch="main",
|
||||
private=False,
|
||||
securitymd=False,
|
||||
require_pull_request=False,
|
||||
approval_count=0,
|
||||
),
|
||||
}
|
||||
|
||||
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_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement.repository_client",
|
||||
new=repository_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.github.services.repository.repository_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement import (
|
||||
repository_code_changes_multi_approval_requirement,
|
||||
)
|
||||
|
||||
check = repository_code_changes_multi_approval_requirement()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == 1
|
||||
assert result[0].resource_name == "repo1"
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Repository {repo_name} does not enforce at least 2 approvals for code changes."
|
||||
)
|
||||
|
||||
def test_repository_no_approvals(self):
|
||||
repository_client = mock.MagicMock
|
||||
repo_name = "repo1"
|
||||
repository_client.repositories = {
|
||||
1: Repo(
|
||||
id=1,
|
||||
name=repo_name,
|
||||
full_name="account-name/repo1",
|
||||
default_branch="master",
|
||||
private=False,
|
||||
securitymd=False,
|
||||
require_pull_request=True,
|
||||
approval_count=0,
|
||||
),
|
||||
}
|
||||
|
||||
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_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement.repository_client",
|
||||
new=repository_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.github.services.repository.repository_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement import (
|
||||
repository_code_changes_multi_approval_requirement,
|
||||
)
|
||||
|
||||
check = repository_code_changes_multi_approval_requirement()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == 1
|
||||
assert result[0].resource_name == "repo1"
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Repository {repo_name} does not enforce at least 2 approvals for code changes."
|
||||
)
|
||||
|
||||
def test_repository_two_approvals(self):
|
||||
repository_client = mock.MagicMock
|
||||
repo_name = "repo1"
|
||||
repository_client.repositories = {
|
||||
1: Repo(
|
||||
id=1,
|
||||
name=repo_name,
|
||||
full_name="account-name/repo1",
|
||||
default_branch="master",
|
||||
private=False,
|
||||
securitymd=True,
|
||||
require_pull_request=True,
|
||||
approval_count=2,
|
||||
),
|
||||
}
|
||||
|
||||
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_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement.repository_client",
|
||||
new=repository_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.github.services.repository.repository_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement import (
|
||||
repository_code_changes_multi_approval_requirement,
|
||||
)
|
||||
|
||||
check = repository_code_changes_multi_approval_requirement()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == 1
|
||||
assert result[0].resource_name == "repo1"
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Repository {repo_name} does enforce at least 2 approvals for code changes."
|
||||
)
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
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_public_has_securitymd_file_test:
|
||||
def test_no_repositories(self):
|
||||
repository_client = mock.MagicMock
|
||||
repository_client.repositories = {}
|
||||
|
||||
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_public_has_securitymd_file.repository_public_has_securitymd_file.repository_client",
|
||||
new=repository_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.github.services.repository.repository_public_has_securitymd_file.repository_public_has_securitymd_file import (
|
||||
repository_public_has_securitymd_file,
|
||||
)
|
||||
|
||||
check = repository_public_has_securitymd_file()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_one_repository_no_securitymd(self):
|
||||
repository_client = mock.MagicMock
|
||||
repo_name = "repo1"
|
||||
repository_client.repositories = {
|
||||
1: Repo(
|
||||
id=1,
|
||||
name=repo_name,
|
||||
full_name="account-name/repo1",
|
||||
default_branch="main",
|
||||
private=False,
|
||||
securitymd=False,
|
||||
require_pull_request=False,
|
||||
approval_count=0,
|
||||
),
|
||||
}
|
||||
|
||||
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_public_has_securitymd_file.repository_public_has_securitymd_file.repository_client",
|
||||
new=repository_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.github.services.repository.repository_public_has_securitymd_file.repository_public_has_securitymd_file import (
|
||||
repository_public_has_securitymd_file,
|
||||
)
|
||||
|
||||
check = repository_public_has_securitymd_file()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == 1
|
||||
assert result[0].resource_name == "repo1"
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Repository {repo_name} does not have a SECURITY.md file."
|
||||
)
|
||||
|
||||
def test_one_repository_securitymd(self):
|
||||
repository_client = mock.MagicMock
|
||||
repo_name = "repo1"
|
||||
repository_client.repositories = {
|
||||
1: Repo(
|
||||
id=1,
|
||||
name=repo_name,
|
||||
full_name="account-name/repo1",
|
||||
default_branch="main",
|
||||
private=False,
|
||||
securitymd=True,
|
||||
require_pull_request=False,
|
||||
approval_count=0,
|
||||
),
|
||||
}
|
||||
|
||||
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_public_has_securitymd_file.repository_public_has_securitymd_file.repository_client",
|
||||
new=repository_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.github.services.repository.repository_public_has_securitymd_file.repository_public_has_securitymd_file import (
|
||||
repository_public_has_securitymd_file,
|
||||
)
|
||||
|
||||
check = repository_public_has_securitymd_file()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == 1
|
||||
assert result[0].resource_name == "repo1"
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Repository {repo_name} does have a SECURITY.md file."
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.github.services.repository.repository_service import (
|
||||
Repo,
|
||||
Repository,
|
||||
)
|
||||
from tests.providers.github.github_fixtures import set_mocked_github_provider
|
||||
|
||||
|
||||
def mock_list_repositories(_):
|
||||
return {
|
||||
1: Repo(
|
||||
id=1,
|
||||
name="repo1",
|
||||
full_name="account-name/repo1",
|
||||
default_branch="main",
|
||||
private=False,
|
||||
securitymd=True,
|
||||
require_pull_request=True,
|
||||
approval_count=2,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@patch(
|
||||
"prowler.providers.github.services.repository.repository_service.Repository._list_repositories",
|
||||
new=mock_list_repositories,
|
||||
)
|
||||
class Test_Repository_Service:
|
||||
def test_get_client(self):
|
||||
repository_service = Repository(set_mocked_github_provider())
|
||||
assert repository_service.clients[0].__class__.__name__ == "Github"
|
||||
|
||||
def test_get_service(self):
|
||||
repository_service = Repository(set_mocked_github_provider())
|
||||
assert repository_service.__class__.__name__ == "Repository"
|
||||
|
||||
def test_list_repositories(self):
|
||||
repository_service = Repository(set_mocked_github_provider())
|
||||
assert len(repository_service.repositories) == 1
|
||||
assert repository_service.repositories[1].name == "repo1"
|
||||
assert repository_service.repositories[1].full_name == "account-name/repo1"
|
||||
assert repository_service.repositories[1].private is False
|
||||
assert repository_service.repositories[1].securitymd
|
||||
assert repository_service.repositories[1].require_pull_request
|
||||
assert repository_service.repositories[1].approval_count == 2
|
||||
@@ -8,11 +8,14 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
- Add a new chart to show the split between passed and failed findings. [(#7680)](https://github.com/prowler-cloud/prowler/pull/7680)
|
||||
- Added `Accordion` component. [(#7700)](https://github.com/prowler-cloud/prowler/pull/7700)
|
||||
- Improve `Provider UID` filter by adding more context and enhancing the UI/UX. [(#7741)](https://github.com/prowler-cloud/prowler/pull/7741)
|
||||
- Added an AWS CloudFormation Quick Link to the IAM Role credentials step [(#7735)](https://github.com/prowler-cloud/prowler/pull/7735)
|
||||
|
||||
### 🐞 Fixes
|
||||
|
||||
- Fix form validation in launch scan workflow. [(#7693)](https://github.com/prowler-cloud/prowler/pull/7693)
|
||||
- Moved ProviderType to a shared types file and replaced all occurrences across the codebase. [(#7710)](https://github.com/prowler-cloud/prowler/pull/7710)
|
||||
- Added filter to retrieve only connected providers on the scan page. [(#7723)](https://github.com/prowler-cloud/prowler/pull/7723)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { createDict } from "@/lib";
|
||||
import { ProviderProps } from "@/types";
|
||||
import { ProviderAccountProps, ProviderProps } from "@/types";
|
||||
import { FindingProps, ScanProps, SearchParamsProps } from "@/types/components";
|
||||
|
||||
export default async function Findings({
|
||||
@@ -73,14 +73,29 @@ export default async function Findings({
|
||||
// Get findings data
|
||||
|
||||
// Extract provider UIDs
|
||||
const providerUIDs = Array.from(
|
||||
const providerUIDs: string[] = Array.from(
|
||||
new Set(
|
||||
providersData?.data
|
||||
?.map((provider: ProviderProps) => provider.attributes.uid)
|
||||
?.map((provider: ProviderProps) => provider.attributes?.uid)
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
const providerDetails: Array<{ [uid: string]: ProviderAccountProps }> =
|
||||
providerUIDs.map((uid) => {
|
||||
const provider = providersData.data.find(
|
||||
(p: { attributes: { uid: string } }) => p.attributes?.uid === uid,
|
||||
);
|
||||
|
||||
return {
|
||||
[uid]: {
|
||||
provider: provider?.attributes?.provider || "",
|
||||
uid: uid,
|
||||
alias: provider?.attributes?.alias ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Extract scan UUIDs with "completed" state and more than one resource
|
||||
const completedScans = scansData?.data
|
||||
?.filter(
|
||||
@@ -122,6 +137,7 @@ export default async function Findings({
|
||||
key: "provider_uid__in",
|
||||
labelCheckboxGroup: "Provider UID",
|
||||
values: providerUIDs,
|
||||
valueLabelMapping: providerDetails,
|
||||
},
|
||||
{
|
||||
key: "scan__in",
|
||||
|
||||
@@ -40,7 +40,9 @@ export default async function Scans({
|
||||
connected: provider.attributes.connection.connected,
|
||||
})) || [];
|
||||
|
||||
const providersCountConnected = await getProviders({});
|
||||
const providersCountConnected = await getProviders({
|
||||
filters: { "filter[connected]": true },
|
||||
});
|
||||
const thereIsNoProviders =
|
||||
!providersCountConnected?.data || providersCountConnected.data.length === 0;
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Snippet } from "@nextui-org/react";
|
||||
import Link from "next/link";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { getAWSCredentialsTemplateLinks } from "@/lib";
|
||||
|
||||
export const CredentialsRoleHelper = () => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
@@ -12,24 +14,50 @@ export const CredentialsRoleHelper = () => {
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
A <strong>new read-only IAM role</strong> must be manually created.
|
||||
</p>
|
||||
|
||||
<CustomButton
|
||||
ariaLabel="Use the following AWS CloudFormation Quick Link to deploy the IAM Role"
|
||||
color="transparent"
|
||||
className="h-auto w-fit min-w-0 p-0 text-blue-500"
|
||||
asLink={`${getAWSCredentialsTemplateLinks().cloudformationQuickLink}${session?.tenantId}`}
|
||||
target="_blank"
|
||||
>
|
||||
Use the following AWS CloudFormation Quick Link to deploy the IAM Role
|
||||
</CustomButton>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-700" />
|
||||
<span className="text-xs font-bold text-gray-900 dark:text-gray-300">
|
||||
or
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Use one of the following templates to create the IAM role:
|
||||
</p>
|
||||
|
||||
<div className="flex w-fit flex-col gap-2">
|
||||
<Link
|
||||
href="https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml"
|
||||
<CustomButton
|
||||
ariaLabel="CloudFormation Template"
|
||||
color="transparent"
|
||||
className="h-auto w-fit min-w-0 p-0 text-blue-500"
|
||||
asLink={getAWSCredentialsTemplateLinks().cloudformation}
|
||||
target="_blank"
|
||||
className="text-sm font-medium text-blue-500 hover:underline"
|
||||
>
|
||||
CloudFormation Template
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/terraform/main.tf"
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
ariaLabel="Terraform Code"
|
||||
color="transparent"
|
||||
className="h-auto w-fit min-w-0 p-0 text-blue-500"
|
||||
asLink={getAWSCredentialsTemplateLinks().terraform}
|
||||
target="_blank"
|
||||
className="text-sm font-medium text-blue-500 hover:underline"
|
||||
>
|
||||
Terraform Code
|
||||
</Link>
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-bold text-gray-600 dark:text-gray-400">
|
||||
The External ID will also be required:
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Select, SelectItem, Spacer } from "@nextui-org/react";
|
||||
import { Divider, Select, SelectItem, Spacer } from "@nextui-org/react";
|
||||
import { Control, UseFormSetValue, useWatch } from "react-hook-form";
|
||||
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
@@ -89,10 +89,11 @@ export const AWSCredentialsRoleForm = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<span className="text-xs font-bold text-default-500">Assume Role</span>
|
||||
<CredentialsRoleHelper />
|
||||
|
||||
<Spacer y={2} />
|
||||
<span className="text-xs font-bold text-default-500">Assume Role</span>
|
||||
|
||||
<CustomInput
|
||||
control={control}
|
||||
@@ -111,7 +112,7 @@ export const AWSCredentialsRoleForm = ({
|
||||
type="text"
|
||||
label="External ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the External ID"
|
||||
placeholder={externalId}
|
||||
variant="bordered"
|
||||
defaultValue={externalId}
|
||||
isDisabled
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
|
||||
import { getProviderName } from "@/components/ui/entities/get-provider-logo";
|
||||
import { getProviderLogo } from "@/components/ui/entities/get-provider-logo";
|
||||
import { getProviderHelpText } from "@/lib";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
export const ProviderTitleDocs = ({
|
||||
@@ -9,41 +10,6 @@ export const ProviderTitleDocs = ({
|
||||
}: {
|
||||
providerType: ProviderType;
|
||||
}) => {
|
||||
const getProviderHelpText = (provider: string) => {
|
||||
switch (provider) {
|
||||
case "aws":
|
||||
return {
|
||||
text: "Need help connecting your AWS account?",
|
||||
link: "https://goto.prowler.com/provider-aws",
|
||||
};
|
||||
case "azure":
|
||||
return {
|
||||
text: "Need help connecting your Azure subscription?",
|
||||
link: "https://goto.prowler.com/provider-azure",
|
||||
};
|
||||
case "m365":
|
||||
return {
|
||||
text: "Need help connecting your Microsoft 365 account?",
|
||||
link: "https://goto.prowler.com/provider-m365",
|
||||
};
|
||||
case "gcp":
|
||||
return {
|
||||
text: "Need help connecting your GCP project?",
|
||||
link: "https://goto.prowler.com/provider-gcp",
|
||||
};
|
||||
case "kubernetes":
|
||||
return {
|
||||
text: "Need help connecting your Kubernetes cluster?",
|
||||
link: "https://goto.prowler.com/provider-k8s",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: "How to setup a provider?",
|
||||
link: "https://goto.prowler.com/provider-help",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex space-x-4">
|
||||
|
||||
@@ -18,6 +18,8 @@ import { PlusCircleIcon } from "@/components/icons";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { CustomDropdownFilterProps } from "@/types";
|
||||
|
||||
import { EntityInfoShort } from "../entities";
|
||||
|
||||
const filterSelectedClass =
|
||||
"inline-flex items-center border py-1 text-xs transition-colors border-transparent bg-default-500 text-secondary-foreground hover:bg-default-500/80 rounded-md px-2 font-normal";
|
||||
|
||||
@@ -192,18 +194,35 @@ export const CustomDropdownFilter: React.FC<CustomDropdownFilterProps> = ({
|
||||
hideScrollBar
|
||||
className="flex max-h-96 max-w-56 flex-col gap-y-2 py-2"
|
||||
>
|
||||
{memoizedFilterValues.map((value) => (
|
||||
<Checkbox
|
||||
classNames={{
|
||||
label: "text-small font-normal",
|
||||
wrapper: "checkbox-update",
|
||||
}}
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{value}
|
||||
</Checkbox>
|
||||
))}
|
||||
{memoizedFilterValues.map((value) => {
|
||||
// Find the corresponding entity from valueLabelMapping
|
||||
const matchingEntry = filter.valueLabelMapping?.find(
|
||||
(entry) => entry[value],
|
||||
);
|
||||
const entity = matchingEntry?.[value];
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
classNames={{
|
||||
label: "text-small font-normal",
|
||||
wrapper: "checkbox-update",
|
||||
}}
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{entity ? (
|
||||
<EntityInfoShort
|
||||
cloudProvider={entity.provider}
|
||||
entityAlias={entity.alias}
|
||||
entityId={entity.uid}
|
||||
hideCopyButton
|
||||
/>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</ScrollShadow>
|
||||
</CheckboxGroup>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export const getProviderHelpText = (provider: string) => {
|
||||
switch (provider) {
|
||||
case "aws":
|
||||
return {
|
||||
text: "Need help connecting your AWS account?",
|
||||
link: "https://goto.prowler.com/provider-aws",
|
||||
};
|
||||
case "azure":
|
||||
return {
|
||||
text: "Need help connecting your Azure subscription?",
|
||||
link: "https://goto.prowler.com/provider-azure",
|
||||
};
|
||||
case "m365":
|
||||
return {
|
||||
text: "Need help connecting your Microsoft 365 account?",
|
||||
link: "https://goto.prowler.com/provider-m365",
|
||||
};
|
||||
case "gcp":
|
||||
return {
|
||||
text: "Need help connecting your GCP project?",
|
||||
link: "https://goto.prowler.com/provider-gcp",
|
||||
};
|
||||
case "kubernetes":
|
||||
return {
|
||||
text: "Need help connecting your Kubernetes cluster?",
|
||||
link: "https://goto.prowler.com/provider-k8s",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: "How to setup a provider?",
|
||||
link: "https://goto.prowler.com/provider-help",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getAWSCredentialsTemplateLinks = () => {
|
||||
return {
|
||||
cloudformation:
|
||||
"https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml",
|
||||
cloudformationQuickLink:
|
||||
"https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=ProwlerScanRole¶m_ExternalId=",
|
||||
terraform:
|
||||
"https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/terraform/main.tf",
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./external-urls";
|
||||
export * from "./helper";
|
||||
export * from "./menu-list";
|
||||
export * from "./utils";
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ProviderAccountProps } from "./providers";
|
||||
|
||||
export interface FilterOption {
|
||||
key: string;
|
||||
labelCheckboxGroup: string;
|
||||
values: string[];
|
||||
valueLabelMapping?: Array<{ [uid: string]: ProviderAccountProps }>;
|
||||
}
|
||||
|
||||
export interface CustomDropdownFilterProps {
|
||||
|
||||
@@ -45,6 +45,12 @@ export interface ProviderProps {
|
||||
groupNames?: string[];
|
||||
}
|
||||
|
||||
export interface ProviderAccountProps {
|
||||
provider: ProviderType;
|
||||
uid: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export interface ProviderOverviewProps {
|
||||
data: {
|
||||
type: "provider-overviews";
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
|
||||
# Convert a CSV file following the CIS 1.0 GitHub benchmark into a Prowler v3.0 Compliance JSON file
|
||||
# CSV fields:
|
||||
# Id, Title,Checks,Attributes_Section,Attributes_Level,Attributes_AssessmentStatus,Attributes_Description,Attributes_RationalStatement,Attributes_ImpactStatement,Attributes_RemediationProcedure,Attributes_AuditProcedure,Attributes_AdditionalInformation,Attributes_References
|
||||
|
||||
# get the CSV filename to convert from
|
||||
file_name = sys.argv[1]
|
||||
|
||||
# read the CSV file rows and use the column fields to form the Prowler compliance JSON file 'cis_1.0_github.json'
|
||||
output = {"Framework": "CIS-GitHub", "Version": "1.5", "Requirements": []}
|
||||
with open(file_name, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f, delimiter=",")
|
||||
for row in reader:
|
||||
attribute = {
|
||||
"Section": row[3],
|
||||
"Profile": row[4],
|
||||
"AssessmentStatus": row[5],
|
||||
"Description": row[6],
|
||||
"RationaleStatement": row[7],
|
||||
"ImpactStatement": row[8],
|
||||
"RemediationProcedure": row[9],
|
||||
"AuditProcedure": row[10],
|
||||
"AdditionalInformation": row[11],
|
||||
"References": row[12],
|
||||
}
|
||||
output["Requirements"].append(
|
||||
{
|
||||
"Id": row[0],
|
||||
"Description": row[1],
|
||||
"Checks": list(map(str.strip, row[2].split(","))),
|
||||
"Attributes": [attribute],
|
||||
}
|
||||
)
|
||||
|
||||
# Write the output Prowler compliance JSON file 'cis_1.0_github.json' locally
|
||||
with open("cis_1.0_github.json", "w", encoding="utf-8") as outfile:
|
||||
json.dump(output, outfile, indent=4, ensure_ascii=False)
|
||||
Reference in New Issue
Block a user