mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-01 13:47:21 +00:00
Compare commits
18 Commits
PROWLER-12
...
DEVREL-93-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7338390a | ||
|
|
21642bb5f9 | ||
|
|
38077e9a0a | ||
|
|
4674b839b1 | ||
|
|
3fdeb1120d | ||
|
|
559b35f60f | ||
|
|
32faf9896a | ||
|
|
2800b34d54 | ||
|
|
a693d7af7e | ||
|
|
07dc0524b6 | ||
|
|
84de6498db | ||
|
|
fd19c56a9a | ||
|
|
f0547cddf2 | ||
|
|
71acf67bf6 | ||
|
|
f8d8c47416 | ||
|
|
3fdfa7a12f | ||
|
|
aadcebfa0e | ||
|
|
1f1e905d9e |
@@ -256,6 +256,13 @@
|
||||
"user-guide/providers/iac/authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "GitHub Actions",
|
||||
"pages": [
|
||||
"user-guide/providers/github-actions/getting-started-github-actions",
|
||||
"user-guide/providers/github-actions/authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "MongoDB Atlas",
|
||||
"pages": [
|
||||
|
||||
12
docs/user-guide/providers/github-actions/authentication.mdx
Normal file
12
docs/user-guide/providers/github-actions/authentication.mdx
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: "GitHub Actions Authentication in Prowler"
|
||||
---
|
||||
|
||||
Prowler's GitHub Actions provider enables scanning of local or remote GitHub Actions workflows for security and compliance issues using [zizmor](https://github.com/woodruffw/zizmor).
|
||||
|
||||
## Authentication
|
||||
|
||||
- For local scans, no authentication is required.
|
||||
- For remote repository scans, authentication can be provided via:
|
||||
- [**GitHub Username and Personal Access Token (PAT)**](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic)
|
||||
- [**GitHub OAuth App Token**](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token)
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
title: "Getting Started with the GitHub Actions Provider"
|
||||
---
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
Prowler's GitHub Actions provider enables comprehensive security scanning for GitHub Actions workflows using [zizmor](https://github.com/woodruffw/zizmor). This provider helps identify security vulnerabilities and misconfigurations in CI/CD pipelines.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before using the GitHub Actions provider, zizmor must be installed:
|
||||
|
||||
```bash
|
||||
# Using Cargo (Rust package manager)
|
||||
cargo install zizmor
|
||||
|
||||
# Or download from GitHub releases
|
||||
# See: https://github.com/woodruffw/zizmor/releases
|
||||
```
|
||||
|
||||
## What Does It Scan?
|
||||
|
||||
The GitHub Actions provider scans for:
|
||||
|
||||
- **Template injection vulnerabilities** - Prevents attacker-controlled code execution
|
||||
- **Accidental credential persistence and leakage** - Detects exposed secrets
|
||||
- **Excessive permission scopes** - Identifies over-privileged workflows
|
||||
- **Impostor commits and confusable git references** - Spots suspicious references
|
||||
- **Other GitHub Actions security best practices**
|
||||
|
||||
## How It Works
|
||||
|
||||
- The GitHub Actions provider scans `.github/workflows/` directories for workflow files.
|
||||
- Local scans require no authentication.
|
||||
- Remote repository scans support authentication via GitHub credentials.
|
||||
- Check the [GitHub Actions Authentication](/user-guide/providers/github-actions/authentication) page for more details.
|
||||
- Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.).
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
<VersionBadge version="5.14.0" />
|
||||
|
||||
### Basic Usage
|
||||
|
||||
#### Scan Local Workflows
|
||||
|
||||
Scan GitHub Actions workflows in the current directory:
|
||||
|
||||
```bash
|
||||
prowler github_actions
|
||||
```
|
||||
|
||||
Scan workflows in a specific directory:
|
||||
|
||||
```bash
|
||||
prowler github_actions --workflow-path /path/to/repository
|
||||
```
|
||||
|
||||
#### Scan Remote Repository
|
||||
|
||||
Scan a public GitHub repository:
|
||||
|
||||
```bash
|
||||
prowler github_actions --repository-url https://github.com/user/repo
|
||||
```
|
||||
|
||||
Scan a private repository with authentication:
|
||||
|
||||
```bash
|
||||
prowler github_actions --repository-url https://github.com/user/private-repo \
|
||||
--personal-access-token YOUR_TOKEN
|
||||
```
|
||||
|
||||
### Authentication for Private Repositories
|
||||
|
||||
Authentication for private repositories can be provided using one of the following methods:
|
||||
|
||||
- **Personal Access Token:**
|
||||
```bash
|
||||
prowler github_actions --repository-url https://github.com/org/private-repo \
|
||||
--personal-access-token YOUR_PAT
|
||||
```
|
||||
- **OAuth App Token:**
|
||||
```bash
|
||||
prowler github_actions --repository-url https://github.com/org/private-repo \
|
||||
--oauth-app-token YOUR_OAUTH_TOKEN
|
||||
```
|
||||
- If not provided via CLI, the following environment variables will be used:
|
||||
```bash
|
||||
export GITHUB_PERSONAL_ACCESS_TOKEN=your-token
|
||||
# or
|
||||
export GITHUB_OAUTH_APP_TOKEN=your-oauth-token
|
||||
|
||||
prowler github_actions --repository-url https://github.com/org/private-repo
|
||||
```
|
||||
|
||||
### Excluding Workflows
|
||||
|
||||
Exclude specific workflows from the scan results using glob patterns. Prowler filters findings after zizmor completes the scan, matching patterns against both workflow filenames and full paths:
|
||||
|
||||
```bash
|
||||
# Exclude by filename pattern (matches any path)
|
||||
prowler github_actions --exclude-workflows "test-*.yml" "api-*.yml"
|
||||
|
||||
# Exclude by full path pattern
|
||||
prowler github_actions --exclude-workflows ".github/workflows/experimental-*.yml"
|
||||
|
||||
# Exclude entire subdirectories
|
||||
prowler github_actions --exclude-workflows "**/draft/*.yml"
|
||||
|
||||
# Combine multiple pattern types
|
||||
prowler github_actions \
|
||||
--workflow-path .github/workflows \
|
||||
--exclude-workflows "test-*.yml" ".github/workflows/experimental/*"
|
||||
```
|
||||
|
||||
<Note>
|
||||
Patterns can match against either the full path or just the filename:
|
||||
- `test-*.yml` matches `test-deploy.yml` in any directory
|
||||
- `.github/workflows/test-*.yml` matches only workflows in that specific directory
|
||||
- `**/experimental/*.yml` matches workflows in any `experimental` subdirectory
|
||||
</Note>
|
||||
|
||||
### Output Formats
|
||||
|
||||
The GitHub Actions provider supports all standard Prowler output formats:
|
||||
|
||||
```bash
|
||||
# Generate HTML, CSV, and JSON reports
|
||||
prowler github_actions --output-formats html csv json-ocsf
|
||||
|
||||
# Custom output directory
|
||||
prowler github_actions --output-directory ./security-reports
|
||||
|
||||
# Custom output filename
|
||||
prowler github_actions --output-filename github-actions-security-scan
|
||||
```
|
||||
|
||||
### Complete Security Scan with Full Reporting
|
||||
|
||||
```bash
|
||||
prowler github_actions \
|
||||
--repository-url https://github.com/my-org/my-repo \
|
||||
--personal-access-token $GITHUB_TOKEN \
|
||||
--output-formats html csv json-ocsf \
|
||||
--output-directory ./security-reports \
|
||||
--verbose
|
||||
```
|
||||
|
||||
### Scan Multiple Local Repositories
|
||||
|
||||
```bash
|
||||
for repo in repo1 repo2 repo3; do
|
||||
echo "Scanning $repo..."
|
||||
prowler github_actions \
|
||||
--workflow-path ./$repo \
|
||||
--output-filename "scan-$repo" \
|
||||
--output-directory ./reports
|
||||
done
|
||||
```
|
||||
|
||||
## Understanding Results
|
||||
|
||||
The scanner identifies issues with different severity levels:
|
||||
|
||||
- **CRITICAL/HIGH**: Immediate security risks that should be addressed urgently
|
||||
- **MEDIUM**: Potential security issues that should be reviewed
|
||||
- **LOW/INFO**: Best practice violations or informational findings
|
||||
|
||||
Each finding includes:
|
||||
- Description of the security issue
|
||||
- Affected workflow file and line number
|
||||
- Remediation recommendations
|
||||
- Links to relevant documentation
|
||||
|
||||
## Integration with CI/CD
|
||||
|
||||
Integrate Prowler's GitHub Actions scanning into CI/CD pipelines:
|
||||
|
||||
```yaml
|
||||
name: Security Scan
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
|
||||
jobs:
|
||||
scan-workflows:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install zizmor
|
||||
run: |
|
||||
cargo install zizmor
|
||||
|
||||
- name: Install Prowler
|
||||
run: |
|
||||
pip install prowler
|
||||
|
||||
- name: Scan GitHub Actions workflows
|
||||
run: |
|
||||
prowler github_actions \
|
||||
--workflow-path . \
|
||||
--output-formats json-ocsf \
|
||||
--output-directory ./reports
|
||||
|
||||
- name: Upload scan results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: workflow-security-scan
|
||||
path: ./reports/
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Zizmor Not Found
|
||||
|
||||
If an error about zizmor not being found occurs:
|
||||
|
||||
1. Ensure zizmor is installed: `which zizmor`
|
||||
2. Install it using: `cargo install zizmor`
|
||||
3. Verify it is in your PATH
|
||||
|
||||
### Authentication Issues
|
||||
|
||||
For private repositories:
|
||||
- Ensure your token has appropriate permissions (`repo` scope for private repos)
|
||||
- Check that credentials are correctly set
|
||||
- Verify the repository URL is correct
|
||||
|
||||
### No Findings
|
||||
|
||||
If no findings are returned:
|
||||
- Verify that `.github/workflows/` directory exists
|
||||
- Check that workflow files have `.yml` or `.yaml` extension
|
||||
- Run with `--verbose` flag for more details
|
||||
@@ -340,6 +340,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Add organization ID parameter for MongoDB Atlas provider [(#9167)](https://github.com/prowler-cloud/prowler/pull/9167)
|
||||
- Add multiple compliance improvements [(#9145)](https://github.com/prowler-cloud/prowler/pull/9145)
|
||||
- Added validation for invalid checks, services, and categories in `load_checks_to_execute` function [(#8971)](https://github.com/prowler-cloud/prowler/pull/8971)
|
||||
- GitHub Actions provider for scanning workflow security issues using zizmor [(#9182)](https://github.com/prowler-cloud/prowler/pull/9182)
|
||||
- NIST CSF 2.0 compliance framework for the AWS provider [(#9185)](https://github.com/prowler-cloud/prowler/pull/9185)
|
||||
- Add FedRAMP 20x KSI Low for AWS, Azure and GCP [(#9198)](https://github.com/prowler-cloud/prowler/pull/9198)
|
||||
- Add verification for provider ID in MongoDB Atlas provider [(#9211)](https://github.com/prowler-cloud/prowler/pull/9211)
|
||||
|
||||
@@ -128,6 +128,7 @@ 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.github_actions.models import GithubActionsOutputOptions
|
||||
from prowler.providers.googleworkspace.models import GoogleWorkspaceOutputOptions
|
||||
from prowler.providers.iac.models import IACOutputOptions
|
||||
from prowler.providers.image.exceptions.exceptions import ImageBaseException
|
||||
@@ -188,7 +189,8 @@ def prowler():
|
||||
if compliance_framework:
|
||||
args.output_formats.extend(compliance_framework)
|
||||
# If no input compliance framework, set all, unless a specific service or check is input
|
||||
elif default_execution:
|
||||
# Skip for IAC, GitHub Actions, and LLM providers that don't use compliance frameworks
|
||||
elif default_execution and provider not in ["iac", "github_actions", "llm"]:
|
||||
args.output_formats.extend(get_available_compliance_frameworks(provider))
|
||||
|
||||
# Set Logger configuration
|
||||
@@ -369,6 +371,8 @@ def prowler():
|
||||
)
|
||||
elif provider == "iac":
|
||||
output_options = IACOutputOptions(args, bulk_checks_metadata)
|
||||
elif provider == "github_actions":
|
||||
output_options = GithubActionsOutputOptions(args, bulk_checks_metadata)
|
||||
elif provider == "image":
|
||||
output_options = ImageOutputOptions(args, bulk_checks_metadata)
|
||||
elif provider == "llm":
|
||||
@@ -404,14 +408,15 @@ def prowler():
|
||||
|
||||
findings = global_provider.run_scan(streaming_callback=streaming_callback)
|
||||
else:
|
||||
# Original behavior for IAC or non-verbose LLM
|
||||
# Original behavior for IAC, GitHub Actions, and Image
|
||||
try:
|
||||
findings = global_provider.run()
|
||||
except ImageBaseException as error:
|
||||
logger.critical(f"{error}")
|
||||
sys.exit(1)
|
||||
# Note: IaC doesn't support granular progress tracking since Trivy runs as a black box
|
||||
# and returns all findings at once. Progress tracking would just be 0% → 100%.
|
||||
# Note: External tool providers don't support granular progress tracking since
|
||||
# they run external tools as a black box and return all findings at once.
|
||||
# Progress tracking would just be 0% → 100%.
|
||||
|
||||
# Filter findings by status if specified
|
||||
if hasattr(args, "status") and args.status:
|
||||
|
||||
@@ -69,7 +69,7 @@ class Provider(str, Enum):
|
||||
|
||||
# Providers that delegate scanning to an external tool (e.g. Trivy, promptfoo)
|
||||
# and bypass standard check/service loading.
|
||||
EXTERNAL_TOOL_PROVIDERS = frozenset({"iac", "llm", "image"})
|
||||
EXTERNAL_TOOL_PROVIDERS = frozenset({"iac", "llm", "image", "github_actions"})
|
||||
|
||||
# Compliance
|
||||
actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
@@ -2,6 +2,7 @@ import sys
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS
|
||||
from prowler.lib.check.check import parse_checks_from_file
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.models import CheckMetadata, Severity
|
||||
@@ -22,8 +23,8 @@ def load_checks_to_execute(
|
||||
) -> set:
|
||||
"""Generate the list of checks to execute based on the cloud provider and the input arguments given"""
|
||||
try:
|
||||
# Bypass check loading for providers that use Trivy directly
|
||||
if provider in ("iac", "image"):
|
||||
# Bypass check loading for providers that use external tools directly
|
||||
if provider in EXTERNAL_TOOL_PROVIDERS:
|
||||
return set()
|
||||
|
||||
# Local subsets
|
||||
|
||||
@@ -896,6 +896,14 @@ class CheckReportIAC(Check_Report):
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckReportGithubAction(Check_Report):
|
||||
"""Contains the GitHub Action Check's finding information using zizmor."""
|
||||
|
||||
resource_name: str
|
||||
resource_line_range: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckReportImage(Check_Report):
|
||||
"""Contains the Container Image Check's finding information using Trivy."""
|
||||
|
||||
@@ -2,6 +2,7 @@ import importlib
|
||||
import sys
|
||||
from pkgutil import walk_packages
|
||||
|
||||
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS
|
||||
from prowler.lib.logger import logger
|
||||
|
||||
|
||||
@@ -14,8 +15,8 @@ def recover_checks_from_provider(
|
||||
Returns a list of tuples with the following format (check_name, check_path)
|
||||
"""
|
||||
try:
|
||||
# Bypass check loading for IAC, LLM, and Image providers since they use external tools directly
|
||||
if provider in ("iac", "llm", "image"):
|
||||
# Bypass check loading for providers that use external tools directly
|
||||
if provider in EXTERNAL_TOOL_PROVIDERS:
|
||||
return []
|
||||
|
||||
checks = []
|
||||
@@ -63,8 +64,8 @@ def recover_checks_from_service(service_list: list, provider: str) -> set:
|
||||
Returns a set of checks from the given services
|
||||
"""
|
||||
try:
|
||||
# Bypass check loading for IAC provider since it uses Trivy directly
|
||||
if provider == "iac":
|
||||
# Bypass check loading for providers that use external tools directly
|
||||
if provider in EXTERNAL_TOOL_PROVIDERS:
|
||||
return set()
|
||||
|
||||
checks = set()
|
||||
|
||||
@@ -27,10 +27,10 @@ class ProwlerArgumentParser:
|
||||
self.parser = argparse.ArgumentParser(
|
||||
prog="prowler",
|
||||
formatter_class=RawTextHelpFormatter,
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ...",
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,github_actions,image,llm} ...",
|
||||
epilog="""
|
||||
Available Cloud Providers:
|
||||
{aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack}
|
||||
{aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,github_actions,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack}
|
||||
aws AWS Provider
|
||||
azure Azure Provider
|
||||
gcp GCP Provider
|
||||
@@ -43,6 +43,7 @@ Available Cloud Providers:
|
||||
openstack OpenStack Provider
|
||||
alibabacloud Alibaba Cloud Provider
|
||||
iac IaC Provider (Beta)
|
||||
github_actions GitHub Actions Security Provider
|
||||
llm LLM Provider (Beta)
|
||||
image Container Image Provider
|
||||
nhn NHN Provider (Unofficial)
|
||||
|
||||
@@ -333,6 +333,15 @@ class Finding(BaseModel):
|
||||
check_output, "resource_line_range", ""
|
||||
)
|
||||
output_data["framework"] = check_output.check_metadata.ServiceName
|
||||
elif provider.type == "github_actions":
|
||||
output_data["auth_method"] = provider.auth_method
|
||||
output_data["account_uid"] = "github_actions"
|
||||
output_data["account_name"] = "github_actions"
|
||||
output_data["resource_name"] = check_output.resource_name
|
||||
output_data["resource_uid"] = check_output.resource_name
|
||||
output_data["region"] = check_output.resource_line_range
|
||||
output_data["resource_line_range"] = check_output.resource_line_range
|
||||
output_data["framework"] = "zizmor"
|
||||
|
||||
elif provider.type == "llm":
|
||||
output_data["auth_method"] = provider.auth_method
|
||||
|
||||
@@ -76,6 +76,13 @@ def display_summary_table(
|
||||
else:
|
||||
entity_type = "Directory"
|
||||
audited_entities = provider.scan_path
|
||||
elif provider.type == "github_actions":
|
||||
if provider.repository_url:
|
||||
entity_type = "Repository"
|
||||
audited_entities = provider.repository_url
|
||||
else:
|
||||
entity_type = "Directory"
|
||||
audited_entities = provider.workflow_path
|
||||
elif provider.type == "llm":
|
||||
entity_type = "LLM"
|
||||
audited_entities = provider.model
|
||||
|
||||
@@ -149,7 +149,11 @@ class Provider(ABC):
|
||||
provider_class_path = (
|
||||
f"{providers_path}.{arguments.provider}.{arguments.provider}_provider"
|
||||
)
|
||||
provider_class_name = f"{arguments.provider.capitalize()}Provider"
|
||||
# Special handling for certain providers
|
||||
if arguments.provider == "github_actions":
|
||||
provider_class_name = "GithubActionsProvider"
|
||||
else:
|
||||
provider_class_name = f"{arguments.provider.capitalize()}Provider"
|
||||
provider_class = getattr(
|
||||
import_module(provider_class_path), provider_class_name
|
||||
)
|
||||
@@ -237,6 +241,18 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "githubactions" in provider_class_name.lower():
|
||||
provider_class(
|
||||
workflow_path=getattr(arguments, "workflow_path", "."),
|
||||
repository_url=getattr(arguments, "repository_url", None),
|
||||
exclude_workflows=getattr(arguments, "exclude_workflows", []),
|
||||
config_path=getattr(arguments, "config_file", None),
|
||||
fixer_config=fixer_config,
|
||||
personal_access_token=getattr(
|
||||
arguments, "personal_access_token", None
|
||||
),
|
||||
oauth_app_token=getattr(arguments, "oauth_app_token", None),
|
||||
)
|
||||
elif "github" in provider_class_name.lower():
|
||||
provider_class(
|
||||
personal_access_token=arguments.personal_access_token,
|
||||
|
||||
0
prowler/providers/github_actions/__init__.py
Normal file
0
prowler/providers/github_actions/__init__.py
Normal file
528
prowler/providers/github_actions/github_actions_provider.py
Normal file
528
prowler/providers/github_actions/github_actions_provider.py
Normal file
@@ -0,0 +1,528 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from fnmatch import fnmatch
|
||||
from os import environ
|
||||
from typing import List, Optional
|
||||
|
||||
from alive_progress import alive_bar
|
||||
from colorama import Fore, Style
|
||||
from dulwich import porcelain
|
||||
|
||||
from prowler.config.config import (
|
||||
default_config_file_path,
|
||||
load_and_validate_config_file,
|
||||
)
|
||||
from prowler.lib.check.models import CheckMetadata, CheckReportGithubAction
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.utils.utils import print_boxes
|
||||
from prowler.providers.common.models import Audit_Metadata
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
|
||||
class GithubActionsProvider(Provider):
|
||||
_type: str = "github_actions"
|
||||
audit_metadata: Audit_Metadata
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow_path: str = ".",
|
||||
repository_url: str = None,
|
||||
exclude_workflows: list[str] = [],
|
||||
config_path: str = None,
|
||||
config_content: dict = None,
|
||||
fixer_config: dict = {},
|
||||
personal_access_token: str = None,
|
||||
oauth_app_token: str = None,
|
||||
):
|
||||
logger.info("Instantiating GitHub Actions Provider...")
|
||||
|
||||
self.workflow_path = workflow_path
|
||||
self.repository_url = repository_url
|
||||
self.exclude_workflows = exclude_workflows
|
||||
self.region = "global"
|
||||
self.audited_account = "github_actions"
|
||||
self._session = None
|
||||
self._identity = "prowler"
|
||||
self._auth_method = "No auth"
|
||||
|
||||
if repository_url:
|
||||
oauth_app_token = oauth_app_token or environ.get("GITHUB_OAUTH_APP_TOKEN")
|
||||
personal_access_token = personal_access_token or environ.get(
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
)
|
||||
|
||||
if oauth_app_token:
|
||||
self.oauth_app_token = oauth_app_token
|
||||
self.personal_access_token = None
|
||||
self._auth_method = "OAuth App Token"
|
||||
logger.info("Using OAuth App Token for GitHub authentication")
|
||||
elif personal_access_token:
|
||||
self.personal_access_token = personal_access_token
|
||||
self.oauth_app_token = None
|
||||
self._auth_method = "Personal Access Token"
|
||||
logger.info("Using personal access token for authentication")
|
||||
else:
|
||||
self.personal_access_token = None
|
||||
self.oauth_app_token = None
|
||||
logger.debug(
|
||||
"No GitHub authentication method provided; proceeding without authentication."
|
||||
)
|
||||
|
||||
# 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 (not needed for GitHub Actions since zizmor has its own ignore logic)
|
||||
self._mutelist = None
|
||||
|
||||
self.audit_metadata = Audit_Metadata(
|
||||
provider=self._type,
|
||||
account_id=self.audited_account,
|
||||
account_name="github_actions",
|
||||
region=self.region,
|
||||
services_scanned=0, # GitHub Actions doesn't use services
|
||||
expected_checks=[], # GitHub Actions doesn't use checks
|
||||
completed_checks=0, # GitHub Actions doesn't use checks
|
||||
audit_progress=0, # GitHub Actions doesn't use progress tracking
|
||||
)
|
||||
|
||||
Provider.set_global_provider(self)
|
||||
|
||||
@property
|
||||
def auth_method(self):
|
||||
return self._auth_method
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def identity(self):
|
||||
return self._identity
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
return self._session
|
||||
|
||||
@property
|
||||
def audit_config(self):
|
||||
return self._audit_config
|
||||
|
||||
@property
|
||||
def fixer_config(self):
|
||||
return self._fixer_config
|
||||
|
||||
def setup_session(self):
|
||||
"""GitHub Actions provider doesn't need a session since it uses zizmor directly"""
|
||||
return None
|
||||
|
||||
def _should_exclude_workflow(
|
||||
self, workflow_file: str, exclude_patterns: list[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a workflow file should be excluded based on exclude patterns.
|
||||
|
||||
Patterns can match against either:
|
||||
- The full workflow path (e.g., ".github/workflows/test-*.yml")
|
||||
- Just the filename (e.g., "test-*.yml")
|
||||
|
||||
Args:
|
||||
workflow_file: The workflow file path
|
||||
exclude_patterns: List of glob patterns to exclude
|
||||
|
||||
Returns:
|
||||
True if the workflow should be excluded, False otherwise
|
||||
"""
|
||||
if not exclude_patterns:
|
||||
return False
|
||||
|
||||
# Get just the filename from the path
|
||||
from os.path import basename
|
||||
|
||||
filename = basename(workflow_file)
|
||||
|
||||
# Check if the pattern matches either the full path or just the filename
|
||||
for pattern in exclude_patterns:
|
||||
# Try matching against full path first
|
||||
if fnmatch(workflow_file, pattern):
|
||||
logger.debug(
|
||||
f"Excluding workflow {workflow_file} (matches full path pattern: {pattern})"
|
||||
)
|
||||
return True
|
||||
|
||||
# Also try matching against just the filename for convenience
|
||||
if fnmatch(filename, pattern):
|
||||
logger.debug(
|
||||
f"Excluding workflow {workflow_file} (matches filename pattern: {pattern})"
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _extract_workflow_file_from_location(self, location: dict) -> Optional[str]:
|
||||
"""
|
||||
Extract the workflow file path from a location object.
|
||||
Supports zizmor v1.x+ JSON format.
|
||||
|
||||
Args:
|
||||
location: The location object from zizmor output
|
||||
|
||||
Returns:
|
||||
The workflow file path, or None if not found
|
||||
"""
|
||||
try:
|
||||
symbolic = location.get("symbolic", {})
|
||||
|
||||
# v1.x+ format: symbolic.key.Local.given_path
|
||||
if "key" in symbolic:
|
||||
key = symbolic["key"]
|
||||
if isinstance(key, dict) and "Local" in key:
|
||||
local = key["Local"]
|
||||
if isinstance(local, dict) and "given_path" in local:
|
||||
return local["given_path"]
|
||||
|
||||
logger.warning(f"Could not extract workflow file from location: {location}")
|
||||
return None
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Error extracting workflow file from location: {error.__class__.__name__} - {error}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _process_zizmor_finding(
|
||||
self, finding: dict, workflow_file: str, location: dict
|
||||
) -> CheckReportGithubAction:
|
||||
"""
|
||||
Process a zizmor finding (v1.x+ format).
|
||||
|
||||
Args:
|
||||
finding: The finding object from zizmor output
|
||||
workflow_file: The path to the workflow file
|
||||
location: The specific location object for this finding
|
||||
|
||||
Returns:
|
||||
CheckReportGithubAction: The processed check report
|
||||
"""
|
||||
try:
|
||||
# Extract location details
|
||||
concrete_location = location.get("concrete", {}).get("location", {})
|
||||
start = concrete_location.get("start_point", {})
|
||||
end = concrete_location.get("end_point", {})
|
||||
|
||||
# Format line range
|
||||
if start and end:
|
||||
if start.get("row") == end.get("row"):
|
||||
line_range = f"line {start.get('row', 'unknown')}"
|
||||
else:
|
||||
line_range = f"lines {start.get('row', 'unknown')}-{end.get('row', 'unknown')}"
|
||||
else:
|
||||
line_range = "location unknown"
|
||||
|
||||
# Get determinations (severity/confidence)
|
||||
determinations = finding.get("determinations", {})
|
||||
severity = determinations.get("severity", "Unknown").lower()
|
||||
confidence = determinations.get("confidence", "Unknown")
|
||||
|
||||
# Map zizmor severity to Prowler severity
|
||||
severity_map = {
|
||||
"critical": "critical",
|
||||
"high": "high",
|
||||
"medium": "medium",
|
||||
"low": "low",
|
||||
"informational": "informational",
|
||||
"unknown": "medium",
|
||||
}
|
||||
prowler_severity = severity_map.get(severity, "medium")
|
||||
|
||||
# Create CheckReport
|
||||
finding_id = (
|
||||
f"githubactions_{finding.get('ident', 'unknown').replace('-', '_')}"
|
||||
)
|
||||
|
||||
# Prepare metadata dict
|
||||
metadata = {
|
||||
"Provider": "github_actions",
|
||||
"CheckID": finding_id,
|
||||
"CheckTitle": finding.get(
|
||||
"desc", "Unknown GitHub Actions Security Issue"
|
||||
),
|
||||
"CheckType": ["Security"],
|
||||
"ServiceName": "githubactions",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": prowler_severity,
|
||||
"ResourceType": "GitHubActionsWorkflow",
|
||||
"Description": finding.get(
|
||||
"desc", "Security issue detected in GitHub Actions workflow"
|
||||
),
|
||||
"Risk": location.get("symbolic", {}).get(
|
||||
"annotation", "Security risk in workflow"
|
||||
),
|
||||
"RelatedUrl": finding.get("url", "https://docs.zizmor.sh/"),
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "Review and fix the security issue in your GitHub Actions workflow",
|
||||
"Terraform": "",
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": f"Review the security issue at {line_range} in {workflow_file}. {finding.get('desc', '')}",
|
||||
"Url": finding.get("url", "https://docs.zizmor.sh/"),
|
||||
},
|
||||
},
|
||||
"Categories": ["security"],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "",
|
||||
}
|
||||
|
||||
# Create the report - need to pass all required fields to the dataclass
|
||||
# Since CheckReportGithubAction is a dataclass without custom __init__,
|
||||
# we need to create it with all required fields from Check_Report
|
||||
report = CheckReportGithubAction(
|
||||
status="FAIL",
|
||||
status_extended=(
|
||||
f"GitHub Actions security issue found in {workflow_file} at {line_range}: "
|
||||
f"{finding.get('desc', 'Unknown issue')}. "
|
||||
f"Confidence: {confidence}. "
|
||||
f"Details: {location.get('symbolic', {}).get('annotation', 'No details available')}"
|
||||
),
|
||||
check_metadata=CheckMetadata.parse_raw(json.dumps(metadata)),
|
||||
resource=finding,
|
||||
resource_details="",
|
||||
resource_tags=[],
|
||||
muted=False,
|
||||
resource_name=workflow_file,
|
||||
resource_line_range=line_range,
|
||||
)
|
||||
|
||||
return report
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def _clone_repository(
|
||||
self,
|
||||
repository_url: str,
|
||||
personal_access_token: str = None,
|
||||
oauth_app_token: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
Clone a git repository to a temporary directory, supporting GitHub authentication.
|
||||
"""
|
||||
try:
|
||||
original_url = repository_url
|
||||
|
||||
if personal_access_token:
|
||||
repository_url = repository_url.replace(
|
||||
"https://github.com/",
|
||||
f"https://{personal_access_token}@github.com/",
|
||||
)
|
||||
elif oauth_app_token:
|
||||
repository_url = repository_url.replace(
|
||||
"https://github.com/",
|
||||
f"https://oauth2:{oauth_app_token}@github.com/",
|
||||
)
|
||||
|
||||
temporary_directory = tempfile.mkdtemp()
|
||||
logger.info(
|
||||
f"Cloning repository {original_url} into {temporary_directory}..."
|
||||
)
|
||||
with alive_bar(
|
||||
ctrl_c=False,
|
||||
bar="blocks",
|
||||
spinner="classic",
|
||||
stats=False,
|
||||
enrich_print=False,
|
||||
) as bar:
|
||||
try:
|
||||
bar.title = f"-> Cloning {original_url}..."
|
||||
porcelain.clone(repository_url, temporary_directory, depth=1)
|
||||
bar.title = "-> Repository cloned successfully!"
|
||||
except Exception as clone_error:
|
||||
bar.title = "-> Cloning failed!"
|
||||
raise clone_error
|
||||
return temporary_directory
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
|
||||
)
|
||||
sys.exit(1)
|
||||
return "" # Unreachable, but satisfies static analysis
|
||||
|
||||
def run(self) -> List[CheckReportGithubAction]:
|
||||
temp_dir = None
|
||||
if self.repository_url:
|
||||
scan_dir = temp_dir = self._clone_repository(
|
||||
self.repository_url,
|
||||
getattr(self, "personal_access_token", None),
|
||||
getattr(self, "oauth_app_token", None),
|
||||
)
|
||||
else:
|
||||
scan_dir = self.workflow_path
|
||||
|
||||
try:
|
||||
reports = self.run_scan(scan_dir, self.exclude_workflows)
|
||||
finally:
|
||||
if temp_dir:
|
||||
logger.info(f"Removing temporary directory {temp_dir}...")
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
return reports
|
||||
|
||||
def run_scan(
|
||||
self, directory: str, exclude_workflows: list[str]
|
||||
) -> List[CheckReportGithubAction]:
|
||||
try:
|
||||
# Check zizmor version
|
||||
try:
|
||||
version_result = subprocess.run(
|
||||
["zizmor", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
zizmor_version = version_result.stdout.strip()
|
||||
logger.info(f"Using {zizmor_version}")
|
||||
except Exception as version_error:
|
||||
logger.warning(f"Could not determine zizmor version: {version_error}")
|
||||
|
||||
logger.info(f"Running GitHub Actions security scan on {directory} ...")
|
||||
|
||||
# Build zizmor command
|
||||
# Note: zizmor doesn't support --exclude, so we filter findings after scanning
|
||||
zizmor_command = [
|
||||
"zizmor",
|
||||
directory,
|
||||
"--format",
|
||||
"json",
|
||||
]
|
||||
|
||||
with alive_bar(
|
||||
ctrl_c=False,
|
||||
bar="blocks",
|
||||
spinner="classic",
|
||||
stats=False,
|
||||
enrich_print=False,
|
||||
) as bar:
|
||||
try:
|
||||
bar.title = (
|
||||
f"-> Running GitHub Actions security scan on {directory} ..."
|
||||
)
|
||||
# Run zizmor with JSON output
|
||||
# Note: zizmor exits with non-zero code when findings exist (e.g., 14)
|
||||
# This is expected behavior, not an error
|
||||
process = subprocess.run(
|
||||
zizmor_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
bar.title = "-> Scan completed!"
|
||||
except Exception as error:
|
||||
bar.title = "-> Scan failed!"
|
||||
raise error
|
||||
|
||||
# Log zizmor's stderr output
|
||||
if process.stderr:
|
||||
for line in process.stderr.strip().split("\n"):
|
||||
if line.strip():
|
||||
logger.debug(f"zizmor: {line}")
|
||||
|
||||
try:
|
||||
# Parse zizmor JSON output
|
||||
if process.stdout:
|
||||
output = json.loads(process.stdout)
|
||||
else:
|
||||
logger.warning("No output returned from zizmor scan")
|
||||
return []
|
||||
|
||||
# zizmor returns an array of findings directly
|
||||
if not output or (isinstance(output, list) and len(output) == 0):
|
||||
logger.info("No security issues found in GitHub Actions workflows")
|
||||
return []
|
||||
|
||||
except json.JSONDecodeError as error:
|
||||
# zizmor might not output JSON for certain cases
|
||||
logger.warning(f"Failed to parse zizmor output as JSON: {error}")
|
||||
logger.debug(f"Raw output: {process.stdout}")
|
||||
return []
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
reports = []
|
||||
|
||||
# zizmor returns an array of findings, each with its own location info
|
||||
for finding in output:
|
||||
# Extract workflow file from the finding's location
|
||||
if "locations" in finding and finding["locations"]:
|
||||
for location in finding["locations"]:
|
||||
workflow_file = self._extract_workflow_file_from_location(
|
||||
location
|
||||
)
|
||||
if workflow_file:
|
||||
# Check if this workflow should be excluded
|
||||
if self._should_exclude_workflow(
|
||||
workflow_file, exclude_workflows
|
||||
):
|
||||
continue
|
||||
|
||||
report = self._process_zizmor_finding(
|
||||
finding, workflow_file, location
|
||||
)
|
||||
reports.append(report)
|
||||
|
||||
return reports
|
||||
|
||||
except Exception as error:
|
||||
if "No such file or directory: 'zizmor'" in str(error):
|
||||
logger.critical(
|
||||
"zizmor binary not found. Please install zizmor from https://github.com/woodruffw/zizmor "
|
||||
"or use your system package manager (e.g., 'cargo install zizmor' with Rust cargo)"
|
||||
)
|
||||
sys.exit(1)
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def print_credentials(self):
|
||||
if self.repository_url:
|
||||
report_title = (
|
||||
f"{Style.BRIGHT}Scanning remote GitHub repository:{Style.RESET_ALL}"
|
||||
)
|
||||
report_lines = [
|
||||
f"Repository: {Fore.YELLOW}{self.repository_url}{Style.RESET_ALL}",
|
||||
]
|
||||
else:
|
||||
report_title = f"{Style.BRIGHT}Scanning local GitHub Actions workflows:{Style.RESET_ALL}"
|
||||
report_lines = [
|
||||
f"Directory: {Fore.YELLOW}{self.workflow_path}{Style.RESET_ALL}",
|
||||
]
|
||||
|
||||
if self.exclude_workflows:
|
||||
report_lines.append(
|
||||
f"Excluded workflows: {Fore.YELLOW}{', '.join(self.exclude_workflows)}{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
report_lines.append(
|
||||
f"Authentication method: {Fore.YELLOW}{self.auth_method}{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
print_boxes(report_lines, report_title)
|
||||
0
prowler/providers/github_actions/lib/__init__.py
Normal file
0
prowler/providers/github_actions/lib/__init__.py
Normal file
87
prowler/providers/github_actions/lib/arguments/arguments.py
Normal file
87
prowler/providers/github_actions/lib/arguments/arguments.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from prowler.lib.logger import logger
|
||||
|
||||
|
||||
def init_parser(self):
|
||||
"""Initialize the GitHub Actions Provider parser to add all the arguments and flags.
|
||||
|
||||
Receives a ProwlerArgumentParser object and fills it.
|
||||
"""
|
||||
github_actions_parser = self.subparsers.add_parser(
|
||||
"github_actions",
|
||||
parents=[self.common_providers_parser],
|
||||
help="GitHub Actions provider for scanning GitHub Actions workflows security",
|
||||
)
|
||||
|
||||
github_actions_auth_subparser = github_actions_parser.add_argument_group(
|
||||
"Authentication"
|
||||
)
|
||||
github_actions_auth_subparser.add_argument(
|
||||
"--personal-access-token",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="GitHub personal access token for authentication when cloning private repositories",
|
||||
)
|
||||
github_actions_auth_subparser.add_argument(
|
||||
"--oauth-app-token",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="GitHub OAuth App token for authentication when cloning private repositories",
|
||||
)
|
||||
|
||||
github_actions_scan_subparser = github_actions_parser.add_argument_group(
|
||||
"Scan Configuration"
|
||||
)
|
||||
github_actions_scan_subparser.add_argument(
|
||||
"--workflow-path",
|
||||
"--scan-path",
|
||||
nargs="?",
|
||||
default=".",
|
||||
help="Path to the directory containing GitHub Actions workflow files (default: current directory)",
|
||||
)
|
||||
github_actions_scan_subparser.add_argument(
|
||||
"--repository-url",
|
||||
"--scan-repository-url",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="URL of the GitHub repository to scan (e.g., https://github.com/user/repo)",
|
||||
)
|
||||
github_actions_scan_subparser.add_argument(
|
||||
"--exclude-workflows",
|
||||
"--exclude-path",
|
||||
nargs="+",
|
||||
default=[],
|
||||
help="List of workflow files or patterns to exclude from scanning",
|
||||
)
|
||||
|
||||
|
||||
def validate_arguments(arguments):
|
||||
"""Validate the arguments for the GitHub Actions provider."""
|
||||
|
||||
# Check if both local path and repository URL are provided
|
||||
if hasattr(arguments, "workflow_path") and hasattr(arguments, "repository_url"):
|
||||
if arguments.repository_url and arguments.workflow_path != ".":
|
||||
return (
|
||||
False,
|
||||
"Cannot specify both --workflow-path and --repository-url. Please choose one.",
|
||||
)
|
||||
|
||||
# Check authentication when using repository URL
|
||||
if hasattr(arguments, "repository_url") and arguments.repository_url:
|
||||
has_github_auth = False
|
||||
|
||||
if (
|
||||
hasattr(arguments, "personal_access_token")
|
||||
and arguments.personal_access_token
|
||||
):
|
||||
has_github_auth = True
|
||||
|
||||
if hasattr(arguments, "oauth_app_token") and arguments.oauth_app_token:
|
||||
has_github_auth = True
|
||||
|
||||
# Note: Authentication is optional for public repositories
|
||||
if not has_github_auth:
|
||||
logger.info(
|
||||
"No GitHub authentication provided. Only public repositories will be accessible."
|
||||
)
|
||||
|
||||
return (True, "")
|
||||
29
prowler/providers/github_actions/models.py
Normal file
29
prowler/providers/github_actions/models.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from prowler.config.config import output_file_timestamp
|
||||
from prowler.providers.common.models import ProviderOutputOptions
|
||||
|
||||
|
||||
class GithubActionsOutputOptions(ProviderOutputOptions):
|
||||
"""
|
||||
GithubActionsOutputOptions overrides ProviderOutputOptions for GitHub Actions-specific output logic.
|
||||
For example, generating a filename that includes github_actions identifier.
|
||||
|
||||
Attributes inherited from ProviderOutputOptions:
|
||||
- output_filename (str): The base filename used for generated reports.
|
||||
- output_directory (str): The directory to store the output files.
|
||||
- ... see ProviderOutputOptions for more details.
|
||||
|
||||
Methods:
|
||||
- __init__: Customizes the output filename logic for GitHub Actions.
|
||||
"""
|
||||
|
||||
def __init__(self, arguments, bulk_checks_metadata):
|
||||
super().__init__(arguments, bulk_checks_metadata)
|
||||
|
||||
# If --output-filename is not specified, build a default name.
|
||||
if not getattr(arguments, "output_filename", None):
|
||||
self.output_filename = (
|
||||
f"prowler-output-github_actions-{output_file_timestamp}"
|
||||
)
|
||||
# If --output-filename was explicitly given, respect that
|
||||
else:
|
||||
self.output_filename = arguments.output_filename
|
||||
@@ -17,7 +17,7 @@ prowler_command = "prowler"
|
||||
|
||||
# capsys
|
||||
# https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html
|
||||
prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ..."
|
||||
prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,github_actions,image,llm} ..."
|
||||
|
||||
|
||||
def mock_get_available_providers():
|
||||
@@ -35,6 +35,8 @@ def mock_get_available_providers():
|
||||
"mongodbatlas",
|
||||
"oraclecloud",
|
||||
"alibabacloud",
|
||||
"github_actions",
|
||||
"llm",
|
||||
"cloudflare",
|
||||
"openstack",
|
||||
]
|
||||
|
||||
0
tests/providers/github_actions/__init__.py
Normal file
0
tests/providers/github_actions/__init__.py
Normal file
270
tests/providers/github_actions/test_github_actions_provider.py
Normal file
270
tests/providers/github_actions/test_github_actions_provider.py
Normal file
@@ -0,0 +1,270 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.providers.github_actions.github_actions_provider import (
|
||||
GithubActionsProvider,
|
||||
)
|
||||
|
||||
|
||||
class TestGithubActionsProvider:
|
||||
"""Test cases for the GitHub Actions Provider"""
|
||||
|
||||
def test_github_actions_provider_init_default(self):
|
||||
"""Test provider initialization with default values"""
|
||||
with patch.object(GithubActionsProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionsProvider()
|
||||
|
||||
assert provider.type == "github_actions"
|
||||
assert provider.workflow_path == "."
|
||||
assert provider.repository_url is None
|
||||
assert provider.exclude_workflows == []
|
||||
assert provider.auth_method == "No auth"
|
||||
assert provider.region == "global"
|
||||
assert provider.audited_account == "github_actions"
|
||||
|
||||
def test_github_actions_provider_init_with_repository_url(self):
|
||||
"""Test provider initialization with repository URL"""
|
||||
with patch.object(GithubActionsProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionsProvider(
|
||||
repository_url="https://github.com/test/repo",
|
||||
personal_access_token="token123",
|
||||
)
|
||||
|
||||
assert provider.repository_url == "https://github.com/test/repo"
|
||||
assert provider.personal_access_token == "token123"
|
||||
assert provider.auth_method == "Personal Access Token"
|
||||
|
||||
def test_github_actions_provider_init_with_oauth_token(self):
|
||||
"""Test provider initialization with OAuth token"""
|
||||
with patch.object(GithubActionsProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionsProvider(
|
||||
repository_url="https://github.com/test/repo",
|
||||
oauth_app_token="oauth_token123",
|
||||
)
|
||||
|
||||
assert provider.oauth_app_token == "oauth_token123"
|
||||
assert provider.auth_method == "OAuth App Token"
|
||||
assert provider.personal_access_token is None
|
||||
|
||||
def test_process_zizmor_finding(self):
|
||||
"""Test processing a zizmor finding"""
|
||||
with patch.object(GithubActionsProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionsProvider()
|
||||
|
||||
# Sample zizmor v1.x+ finding
|
||||
finding = {
|
||||
"ident": "template-injection",
|
||||
"desc": "Template Injection Vulnerability",
|
||||
"determinations": {"severity": "high", "confidence": "High"},
|
||||
"url": "https://example.com/docs",
|
||||
}
|
||||
|
||||
location = {
|
||||
"symbolic": {
|
||||
"annotation": "High risk of code execution",
|
||||
"key": {"Local": {"given_path": ".github/workflows/test.yml"}},
|
||||
},
|
||||
"concrete": {
|
||||
"location": {
|
||||
"start_point": {"row": 10, "column": 5},
|
||||
"end_point": {"row": 10, "column": 15},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
report = provider._process_zizmor_finding(
|
||||
finding, ".github/workflows/test.yml", location
|
||||
)
|
||||
|
||||
assert report.resource_name == ".github/workflows/test.yml"
|
||||
assert report.status == "FAIL"
|
||||
assert "line 10" in report.resource_line_range
|
||||
|
||||
# Check metadata
|
||||
assert (
|
||||
report.check_metadata.CheckID == "githubactions_template_injection"
|
||||
) # Prefixed with githubactions_
|
||||
assert report.check_metadata.Severity == "high"
|
||||
assert report.check_metadata.Provider == "github_actions"
|
||||
|
||||
def test_run_scan_no_issues(self):
|
||||
"""Test scanning when no issues are found"""
|
||||
with patch.object(GithubActionsProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionsProvider()
|
||||
|
||||
# Mock subprocess to return empty findings (zizmor v1.x+ format)
|
||||
mock_process = MagicMock()
|
||||
mock_process.stdout = "[]"
|
||||
mock_process.stderr = ""
|
||||
mock_process.returncode = 0
|
||||
|
||||
with patch("subprocess.run", return_value=mock_process):
|
||||
with patch(
|
||||
"prowler.providers.github_actions.github_actions_provider.alive_bar"
|
||||
):
|
||||
reports = provider.run_scan(".", [])
|
||||
|
||||
assert reports == []
|
||||
|
||||
def test_run_scan_with_findings(self):
|
||||
"""Test scanning with security findings"""
|
||||
with patch.object(GithubActionsProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionsProvider()
|
||||
|
||||
# Mock subprocess to return findings (zizmor v1.x+ format)
|
||||
mock_output = [
|
||||
{
|
||||
"ident": "excessive-permissions",
|
||||
"desc": "Workflow has write-all permissions",
|
||||
"determinations": {"severity": "medium", "confidence": "High"},
|
||||
"url": "https://docs.example.com",
|
||||
"locations": [
|
||||
{
|
||||
"symbolic": {
|
||||
"annotation": "Excessive permissions detected",
|
||||
"key": {
|
||||
"Local": {"given_path": ".github/workflows/ci.yml"}
|
||||
},
|
||||
},
|
||||
"concrete": {
|
||||
"location": {
|
||||
"start_point": {"row": 5, "column": 1},
|
||||
"end_point": {"row": 5, "column": 20},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.stdout = json.dumps(mock_output)
|
||||
mock_process.stderr = ""
|
||||
mock_process.returncode = 0
|
||||
|
||||
with patch("subprocess.run", return_value=mock_process):
|
||||
with patch(
|
||||
"prowler.providers.github_actions.github_actions_provider.alive_bar"
|
||||
):
|
||||
reports = provider.run_scan(".", [])
|
||||
|
||||
assert len(reports) == 1
|
||||
assert reports[0].resource_name == ".github/workflows/ci.yml"
|
||||
|
||||
def test_run_scan_zizmor_not_found(self):
|
||||
"""Test error handling when zizmor is not installed"""
|
||||
with patch.object(GithubActionsProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionsProvider()
|
||||
|
||||
with patch(
|
||||
"subprocess.run",
|
||||
side_effect=FileNotFoundError("No such file or directory: 'zizmor'"),
|
||||
):
|
||||
with patch(
|
||||
"prowler.providers.github_actions.github_actions_provider.alive_bar"
|
||||
):
|
||||
with pytest.raises(SystemExit):
|
||||
provider.run_scan(".", [])
|
||||
|
||||
def test_clone_repository_with_pat(self):
|
||||
"""Test cloning repository with personal access token"""
|
||||
with patch.object(GithubActionsProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionsProvider()
|
||||
|
||||
with patch("tempfile.mkdtemp", return_value="/tmp/test"):
|
||||
with patch("dulwich.porcelain.clone"):
|
||||
with patch(
|
||||
"prowler.providers.github_actions.github_actions_provider.alive_bar"
|
||||
):
|
||||
temp_dir = provider._clone_repository(
|
||||
"https://github.com/test/repo",
|
||||
personal_access_token="token",
|
||||
)
|
||||
|
||||
assert temp_dir == "/tmp/test"
|
||||
|
||||
def test_print_credentials_local_scan(self):
|
||||
"""Test printing credentials for local scan"""
|
||||
with patch.object(GithubActionsProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionsProvider(workflow_path="/path/to/workflows")
|
||||
|
||||
with patch(
|
||||
"prowler.providers.github_actions.github_actions_provider.print_boxes"
|
||||
) as mock_print:
|
||||
provider.print_credentials()
|
||||
|
||||
# Verify print_boxes was called with expected content
|
||||
mock_print.assert_called_once()
|
||||
args = mock_print.call_args[0]
|
||||
assert "/path/to/workflows" in str(args[0])
|
||||
|
||||
def test_print_credentials_remote_scan(self):
|
||||
"""Test printing credentials for remote repository scan"""
|
||||
with patch.object(GithubActionsProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionsProvider(
|
||||
repository_url="https://github.com/test/repo",
|
||||
exclude_workflows=["test*.yml"],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"prowler.providers.github_actions.github_actions_provider.print_boxes"
|
||||
) as mock_print:
|
||||
provider.print_credentials()
|
||||
|
||||
# Verify print_boxes was called with expected content
|
||||
mock_print.assert_called_once()
|
||||
args = mock_print.call_args[0]
|
||||
assert "https://github.com/test/repo" in str(args[0])
|
||||
assert "test*.yml" in str(args[0])
|
||||
|
||||
def test_should_exclude_workflow(self):
|
||||
"""Test workflow exclusion pattern matching"""
|
||||
with patch.object(GithubActionsProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionsProvider()
|
||||
|
||||
# Test no exclusions
|
||||
assert not provider._should_exclude_workflow(
|
||||
".github/workflows/test.yml", []
|
||||
)
|
||||
|
||||
# Test exact filename match
|
||||
assert provider._should_exclude_workflow(
|
||||
".github/workflows/test.yml", ["test.yml"]
|
||||
)
|
||||
|
||||
# Test wildcard pattern on filename
|
||||
assert provider._should_exclude_workflow(
|
||||
".github/workflows/test-api.yml", ["test-*.yml"]
|
||||
)
|
||||
|
||||
# Test multiple patterns
|
||||
assert provider._should_exclude_workflow(
|
||||
".github/workflows/api-test.yml", ["test-*.yml", "api-*.yml"]
|
||||
)
|
||||
|
||||
# Test no match
|
||||
assert not provider._should_exclude_workflow(
|
||||
".github/workflows/deploy.yml", ["test-*.yml", "api-*.yml"]
|
||||
)
|
||||
|
||||
# Test full path matching
|
||||
assert provider._should_exclude_workflow(
|
||||
".github/workflows/test.yml", [".github/workflows/test.yml"]
|
||||
)
|
||||
|
||||
# Test full path with wildcard
|
||||
assert provider._should_exclude_workflow(
|
||||
".github/workflows/api-tests.yml", [".github/workflows/api-*.yml"]
|
||||
)
|
||||
|
||||
# Test subdirectory patterns
|
||||
assert provider._should_exclude_workflow(
|
||||
".github/workflows/experimental/test.yml", ["**/experimental/*.yml"]
|
||||
)
|
||||
|
||||
# Test that filename pattern works regardless of path
|
||||
assert provider._should_exclude_workflow(
|
||||
"workflows/subdir/test-deploy.yml", ["test-*.yml"]
|
||||
)
|
||||
Reference in New Issue
Block a user