mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
feat(github_action): inital POC
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
# GitHub Actions Security Scanning with Prowler
|
||||
|
||||
Prowler integrates with [zizmor](https://github.com/woodruffw/zizmor) to provide comprehensive security scanning for GitHub Actions workflows. This feature helps identify security vulnerabilities and misconfigurations in your CI/CD pipelines.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before using the GitHub Actions provider, you need to install zizmor:
|
||||
|
||||
### Install Zizmor
|
||||
|
||||
```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**
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Scan Local Workflows
|
||||
|
||||
To scan GitHub Actions workflows in your current directory:
|
||||
|
||||
```bash
|
||||
prowler github_action
|
||||
```
|
||||
|
||||
To scan workflows in a specific directory:
|
||||
|
||||
```bash
|
||||
prowler github_action --workflow-path /path/to/repository
|
||||
```
|
||||
|
||||
### Scan Remote Repository
|
||||
|
||||
To scan a GitHub repository directly:
|
||||
|
||||
```bash
|
||||
# Public repository
|
||||
prowler github_action --repository-url https://github.com/user/repo
|
||||
|
||||
# Private repository with authentication
|
||||
prowler github_action --repository-url https://github.com/user/private-repo \
|
||||
--github-username YOUR_USERNAME \
|
||||
--personal-access-token YOUR_TOKEN
|
||||
```
|
||||
|
||||
## Authentication Options
|
||||
|
||||
For scanning private repositories, Prowler supports multiple authentication methods:
|
||||
|
||||
### Personal Access Token
|
||||
|
||||
```bash
|
||||
prowler github_action --repository-url https://github.com/org/private-repo \
|
||||
--github-username YOUR_USERNAME \
|
||||
--personal-access-token YOUR_PAT
|
||||
```
|
||||
|
||||
### OAuth App Token
|
||||
|
||||
```bash
|
||||
prowler github_action --repository-url https://github.com/org/private-repo \
|
||||
--oauth-app-token YOUR_OAUTH_TOKEN
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can also set authentication via environment variables:
|
||||
|
||||
```bash
|
||||
export GITHUB_USERNAME=your-username
|
||||
export GITHUB_PERSONAL_ACCESS_TOKEN=your-token
|
||||
# or
|
||||
export GITHUB_OAUTH_APP_TOKEN=your-oauth-token
|
||||
|
||||
prowler github_action --repository-url https://github.com/org/private-repo
|
||||
```
|
||||
|
||||
## Excluding Workflows
|
||||
|
||||
To exclude specific workflows or patterns from scanning:
|
||||
|
||||
```bash
|
||||
prowler github_action --exclude-workflows "test-*.yml" "experimental/*"
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
The GitHub Actions provider supports all standard Prowler output formats:
|
||||
|
||||
```bash
|
||||
# Generate HTML, CSV, and JSON reports
|
||||
prowler github_action --output-formats html csv json-ocsf
|
||||
|
||||
# Custom output directory
|
||||
prowler github_action --output-directory ./security-reports
|
||||
|
||||
# Custom output filename
|
||||
prowler github_action --output-filename github-actions-security-scan
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Security Scan with Full Reporting
|
||||
|
||||
```bash
|
||||
prowler github_action \
|
||||
--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_action \
|
||||
--workflow-path ./$repo \
|
||||
--output-filename "scan-$repo" \
|
||||
--output-directory ./reports
|
||||
done
|
||||
```
|
||||
|
||||
## Understanding Results
|
||||
|
||||
The scanner will identify 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
|
||||
|
||||
You can integrate Prowler's GitHub Actions scanning into your CI/CD pipeline:
|
||||
|
||||
```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_action \
|
||||
--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 you get an error about zizmor not being found:
|
||||
|
||||
1. Ensure zizmor is installed: `which zizmor`
|
||||
2. Install it using: `cargo install zizmor`
|
||||
3. Make sure it's 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
|
||||
+7
-4
@@ -99,6 +99,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_action.models import GithubActionOutputOptions
|
||||
from prowler.providers.iac.models import IACOutputOptions
|
||||
from prowler.providers.kubernetes.models import KubernetesOutputOptions
|
||||
from prowler.providers.m365.models import M365OutputOptions
|
||||
@@ -177,8 +178,8 @@ def prowler():
|
||||
# Load compliance frameworks
|
||||
logger.debug("Loading compliance frameworks from .json files")
|
||||
|
||||
# Skip compliance frameworks for IAC provider
|
||||
if provider != "iac":
|
||||
# Skip compliance frameworks for IAC and GitHub Action providers
|
||||
if provider not in ["iac", "github_action"]:
|
||||
bulk_compliance_frameworks = Compliance.get_bulk(provider)
|
||||
# Complete checks metadata with the compliance framework specification
|
||||
bulk_checks_metadata = update_checks_metadata_with_compliance(
|
||||
@@ -306,6 +307,8 @@ def prowler():
|
||||
)
|
||||
elif provider == "iac":
|
||||
output_options = IACOutputOptions(args, bulk_checks_metadata)
|
||||
elif provider == "github_action":
|
||||
output_options = GithubActionOutputOptions(args, bulk_checks_metadata)
|
||||
|
||||
# Run the quick inventory for the provider if available
|
||||
if hasattr(args, "quick_inventory") and args.quick_inventory:
|
||||
@@ -315,8 +318,8 @@ def prowler():
|
||||
# Execute checks
|
||||
findings = []
|
||||
|
||||
if provider == "iac":
|
||||
# For IAC provider, run the scan directly
|
||||
if provider in ["iac", "github_action"]:
|
||||
# For IAC and GitHub Action providers, run the scan directly
|
||||
findings = global_provider.run()
|
||||
elif len(checks_to_execute):
|
||||
findings = execute_checks(
|
||||
|
||||
@@ -20,8 +20,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 IAC provider since it uses Trivy directly
|
||||
if provider == "iac":
|
||||
# Bypass check loading for IAC and GitHub Action providers since they use external tools directly
|
||||
if provider in ["iac", "github_action"]:
|
||||
return set()
|
||||
|
||||
# Local subsets
|
||||
|
||||
@@ -678,6 +678,38 @@ 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
|
||||
|
||||
def __init__(
|
||||
self, metadata: dict = {}, finding: dict = {}, workflow_path: str = ""
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the GitHub Action Check's finding information from a zizmor finding dict.
|
||||
|
||||
Args:
|
||||
metadata (Dict): Optional check metadata (can be None).
|
||||
finding (dict): A single finding result from zizmor's JSON output.
|
||||
workflow_path (str): Path to the workflow file.
|
||||
"""
|
||||
super().__init__(metadata, finding)
|
||||
|
||||
self.resource = finding
|
||||
self.resource_name = workflow_path
|
||||
# Extract line range from location if available
|
||||
location = finding.get("location", {})
|
||||
if location.get("line"):
|
||||
start_line = location.get("line", "")
|
||||
end_line = location.get("end_line", start_line)
|
||||
self.resource_line_range = f"{start_line}:{end_line}" if start_line else ""
|
||||
else:
|
||||
self.resource_line_range = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckReportNHN(Check_Report):
|
||||
"""Contains the NHN Check's finding information."""
|
||||
|
||||
@@ -14,8 +14,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 provider since it uses Trivy directly
|
||||
if provider == "iac":
|
||||
# Bypass check loading for IAC and GitHub Action providers since they use external tools directly
|
||||
if provider in ["iac", "github_action"]:
|
||||
return []
|
||||
|
||||
checks = []
|
||||
@@ -63,8 +63,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 IAC and GitHub Action providers since they use external tools directly
|
||||
if provider in ["iac", "github_action"]:
|
||||
return set()
|
||||
|
||||
checks = set()
|
||||
|
||||
@@ -26,10 +26,10 @@ class ProwlerArgumentParser:
|
||||
self.parser = argparse.ArgumentParser(
|
||||
prog="prowler",
|
||||
formatter_class=RawTextHelpFormatter,
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,dashboard,iac} ...",
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,dashboard,iac,github_action} ...",
|
||||
epilog="""
|
||||
Available Cloud Providers:
|
||||
{aws,azure,gcp,kubernetes,m365,github,iac,nhn}
|
||||
{aws,azure,gcp,kubernetes,m365,github,iac,nhn,github_action}
|
||||
aws AWS Provider
|
||||
azure Azure Provider
|
||||
gcp GCP Provider
|
||||
@@ -38,6 +38,7 @@ Available Cloud Providers:
|
||||
github GitHub Provider
|
||||
iac IaC Provider (Preview)
|
||||
nhn NHN Provider (Unofficial)
|
||||
github_action GitHub Actions Security Provider
|
||||
|
||||
Available components:
|
||||
dashboard Local dashboard
|
||||
|
||||
@@ -302,6 +302,15 @@ class Finding(BaseModel):
|
||||
output_data["region"] = check_output.resource_line_range
|
||||
output_data["resource_line_range"] = check_output.resource_line_range
|
||||
output_data["framework"] = check_output.check_metadata.ServiceName
|
||||
elif provider.type == "github_action":
|
||||
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"
|
||||
|
||||
# check_output Unique ID
|
||||
# TODO: move this to a function
|
||||
|
||||
@@ -61,6 +61,13 @@ def display_summary_table(
|
||||
else:
|
||||
entity_type = "Directory"
|
||||
audited_entities = provider.scan_path
|
||||
elif provider.type == "github_action":
|
||||
if provider.repository_url:
|
||||
entity_type = "Repository"
|
||||
audited_entities = provider.repository_url
|
||||
else:
|
||||
entity_type = "Directory"
|
||||
audited_entities = provider.workflow_path
|
||||
|
||||
# Check if there are findings and that they are not all MANUAL
|
||||
if findings and not all(finding.status == "MANUAL" for finding in findings):
|
||||
|
||||
@@ -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 github_action provider
|
||||
if arguments.provider == "github_action":
|
||||
provider_class_name = "GithubActionProvider"
|
||||
else:
|
||||
provider_class_name = f"{arguments.provider.capitalize()}Provider"
|
||||
provider_class = getattr(
|
||||
import_module(provider_class_path), provider_class_name
|
||||
)
|
||||
@@ -237,6 +241,17 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "githubaction" 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,
|
||||
github_username=getattr(arguments, "github_username", None),
|
||||
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,0 +1,429 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from os import environ
|
||||
from typing import List
|
||||
|
||||
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 CheckReportGithubAction, CheckMetadata
|
||||
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 GithubActionProvider(Provider):
|
||||
_type: str = "github_action"
|
||||
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 = {},
|
||||
github_username: str = None,
|
||||
personal_access_token: str = None,
|
||||
oauth_app_token: str = None,
|
||||
):
|
||||
logger.info("Instantiating GitHub Action 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")
|
||||
github_username = github_username or environ.get("GITHUB_USERNAME")
|
||||
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.github_username = None
|
||||
self.personal_access_token = None
|
||||
self._auth_method = "OAuth App Token"
|
||||
logger.info("Using OAuth App Token for GitHub authentication")
|
||||
elif github_username and personal_access_token:
|
||||
self.github_username = github_username
|
||||
self.personal_access_token = personal_access_token
|
||||
self.oauth_app_token = None
|
||||
self._auth_method = "Personal Access Token"
|
||||
logger.info(
|
||||
"Using GitHub username and personal access token for authentication"
|
||||
)
|
||||
else:
|
||||
self.github_username = None
|
||||
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_action",
|
||||
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 Action provider doesn't need a session since it uses zizmor directly"""
|
||||
return None
|
||||
|
||||
def _process_zizmor_finding(
|
||||
self, finding: dict, workflow_file: str, location: dict
|
||||
) -> CheckReportGithubAction:
|
||||
"""
|
||||
Process a zizmor finding with the new JSON structure.
|
||||
|
||||
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"githubaction_{finding.get('ident', 'unknown').replace('-', '_')}"
|
||||
|
||||
# Prepare metadata dict
|
||||
metadata = {
|
||||
"Provider": "github_action",
|
||||
"CheckID": finding_id,
|
||||
"CheckTitle": finding.get("desc", "Unknown GitHub Actions Security Issue"),
|
||||
"CheckType": ["Security"],
|
||||
"ServiceName": "githubaction",
|
||||
"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 with metadata as JSON string and finding
|
||||
report = CheckReportGithubAction(
|
||||
metadata=json.dumps(metadata),
|
||||
finding=finding,
|
||||
workflow_path=workflow_file
|
||||
)
|
||||
|
||||
report.resource_name = workflow_file
|
||||
report.resource_line_range = line_range
|
||||
report.status = "FAIL"
|
||||
report.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')}"
|
||||
)
|
||||
|
||||
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,
|
||||
github_username: str = None,
|
||||
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 github_username and personal_access_token:
|
||||
repository_url = repository_url.replace(
|
||||
"https://github.com/",
|
||||
f"https://{github_username}:{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}"
|
||||
)
|
||||
|
||||
def run(self) -> List[CheckReportGithubAction]:
|
||||
temp_dir = None
|
||||
if self.repository_url:
|
||||
scan_dir = temp_dir = self._clone_repository(
|
||||
self.repository_url,
|
||||
getattr(self, "github_username", None),
|
||||
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:
|
||||
logger.info(f"Running GitHub Actions security scan on {directory} ...")
|
||||
|
||||
# Build zizmor command
|
||||
zizmor_command = [
|
||||
"zizmor",
|
||||
directory,
|
||||
"--format",
|
||||
"json",
|
||||
]
|
||||
|
||||
# Add exclude patterns if provided
|
||||
for exclude_pattern in exclude_workflows:
|
||||
zizmor_command.extend(["--exclude", exclude_pattern])
|
||||
|
||||
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
|
||||
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"]:
|
||||
if "symbolic" in location and "key" in location["symbolic"]:
|
||||
key = location["symbolic"]["key"]
|
||||
if "Local" in key:
|
||||
workflow_file = key["Local"]["given_path"]
|
||||
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,0 +1,91 @@
|
||||
from prowler.lib.logger import logger
|
||||
|
||||
|
||||
def init_parser(self):
|
||||
"""Initialize the GitHub Action Provider parser to add all the arguments and flags.
|
||||
|
||||
Receives a ProwlerArgumentParser object and fills it.
|
||||
"""
|
||||
github_action_parser = self.subparsers.add_parser(
|
||||
"github_action",
|
||||
parents=[self.common_providers_parser],
|
||||
help="GitHub Action provider for scanning GitHub Actions workflows security",
|
||||
)
|
||||
|
||||
github_action_auth_subparser = github_action_parser.add_argument_group(
|
||||
"Authentication"
|
||||
)
|
||||
github_action_auth_subparser.add_argument(
|
||||
"--github-username",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="GitHub username for authentication when cloning private repositories",
|
||||
)
|
||||
github_action_auth_subparser.add_argument(
|
||||
"--personal-access-token",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="GitHub personal access token for authentication when cloning private repositories",
|
||||
)
|
||||
github_action_auth_subparser.add_argument(
|
||||
"--oauth-app-token",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="GitHub OAuth App token for authentication when cloning private repositories",
|
||||
)
|
||||
|
||||
github_action_scan_subparser = github_action_parser.add_argument_group(
|
||||
"Scan Configuration"
|
||||
)
|
||||
github_action_scan_subparser.add_argument(
|
||||
"--workflow-path",
|
||||
"--scan-path",
|
||||
nargs="?",
|
||||
default=".",
|
||||
help="Path to the directory containing GitHub Actions workflow files (default: current directory)",
|
||||
)
|
||||
github_action_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_action_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 Action 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, "github_username") and hasattr(arguments, "personal_access_token"):
|
||||
if arguments.github_username 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, "")
|
||||
@@ -0,0 +1,27 @@
|
||||
from prowler.config.config import output_file_timestamp
|
||||
from prowler.providers.common.models import ProviderOutputOptions
|
||||
|
||||
|
||||
class GithubActionOutputOptions(ProviderOutputOptions):
|
||||
"""
|
||||
GithubActionOutputOptions overrides ProviderOutputOptions for GitHub Action-specific output logic.
|
||||
For example, generating a filename that includes github-action 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 Action.
|
||||
"""
|
||||
|
||||
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-action-{output_file_timestamp}"
|
||||
# If --output-filename was explicitly given, respect that
|
||||
else:
|
||||
self.output_filename = arguments.output_filename
|
||||
@@ -0,0 +1,188 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.providers.github_action.github_action_provider import GithubActionProvider
|
||||
|
||||
|
||||
class TestGithubActionProvider:
|
||||
"""Test cases for the GitHub Action Provider"""
|
||||
|
||||
def test_github_action_provider_init_default(self):
|
||||
"""Test provider initialization with default values"""
|
||||
with patch.object(GithubActionProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionProvider()
|
||||
|
||||
assert provider.type == "github_action"
|
||||
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_action_provider_init_with_repository_url(self):
|
||||
"""Test provider initialization with repository URL"""
|
||||
with patch.object(GithubActionProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionProvider(
|
||||
repository_url="https://github.com/test/repo",
|
||||
github_username="testuser",
|
||||
personal_access_token="token123"
|
||||
)
|
||||
|
||||
assert provider.repository_url == "https://github.com/test/repo"
|
||||
assert provider.github_username == "testuser"
|
||||
assert provider.personal_access_token == "token123"
|
||||
assert provider.auth_method == "Personal Access Token"
|
||||
|
||||
def test_github_action_provider_init_with_oauth_token(self):
|
||||
"""Test provider initialization with OAuth token"""
|
||||
with patch.object(GithubActionProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionProvider(
|
||||
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.github_username is None
|
||||
assert provider.personal_access_token is None
|
||||
|
||||
def test_process_finding(self):
|
||||
"""Test processing a zizmor finding"""
|
||||
with patch.object(GithubActionProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionProvider()
|
||||
|
||||
# Sample zizmor finding
|
||||
finding = {
|
||||
"id": "template-injection",
|
||||
"title": "Template Injection Vulnerability",
|
||||
"level": "HIGH",
|
||||
"description": "Potential code injection in workflow",
|
||||
"documentation_url": "https://example.com/docs",
|
||||
"location": {
|
||||
"line": 10,
|
||||
"column": 5
|
||||
},
|
||||
"risk": "High risk of code execution",
|
||||
"remediation": "Use environment variables instead"
|
||||
}
|
||||
|
||||
report = provider._process_finding(finding, ".github/workflows/test.yml")
|
||||
|
||||
assert report.resource_name == ".github/workflows/test.yml"
|
||||
assert report.status == "FAIL"
|
||||
assert "line 10, column 5" in report.status_extended
|
||||
|
||||
# Check metadata
|
||||
assert report.check_metadata.CheckID == "githubaction_template_injection" # Prefixed with githubaction_
|
||||
assert report.check_metadata.Severity == "high"
|
||||
assert report.check_metadata.Provider == "github_action"
|
||||
|
||||
def test_run_scan_no_issues(self):
|
||||
"""Test scanning when no issues are found"""
|
||||
with patch.object(GithubActionProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionProvider()
|
||||
|
||||
# Mock subprocess to return empty findings
|
||||
mock_process = MagicMock()
|
||||
mock_process.stdout = '{"findings": {}}'
|
||||
mock_process.stderr = ""
|
||||
mock_process.returncode = 0
|
||||
|
||||
with patch("subprocess.run", return_value=mock_process):
|
||||
with patch("prowler.providers.github_action.github_action_provider.alive_bar"):
|
||||
reports = provider.run_scan(".", [])
|
||||
|
||||
assert reports == []
|
||||
|
||||
def test_run_scan_with_findings(self):
|
||||
"""Test scanning with security findings"""
|
||||
with patch.object(GithubActionProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionProvider()
|
||||
|
||||
# Mock subprocess to return findings
|
||||
mock_output = {
|
||||
"findings": {
|
||||
".github/workflows/ci.yml": [
|
||||
{
|
||||
"id": "excessive-permissions",
|
||||
"title": "Excessive Permissions",
|
||||
"level": "MEDIUM",
|
||||
"description": "Workflow has write-all permissions",
|
||||
"documentation_url": "https://docs.example.com",
|
||||
"location": {"line": 5}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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_action.github_action_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(GithubActionProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionProvider()
|
||||
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError("No such file or directory: 'zizmor'")):
|
||||
with patch("prowler.providers.github_action.github_action_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(GithubActionProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionProvider()
|
||||
|
||||
with patch("tempfile.mkdtemp", return_value="/tmp/test"):
|
||||
with patch("dulwich.porcelain.clone"):
|
||||
with patch("prowler.providers.github_action.github_action_provider.alive_bar"):
|
||||
temp_dir = provider._clone_repository(
|
||||
"https://github.com/test/repo",
|
||||
github_username="user",
|
||||
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(GithubActionProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionProvider(workflow_path="/path/to/workflows")
|
||||
|
||||
with patch("prowler.lib.utils.utils.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(GithubActionProvider, "setup_session", return_value=None):
|
||||
provider = GithubActionProvider(
|
||||
repository_url="https://github.com/test/repo",
|
||||
exclude_workflows=["test*.yml"]
|
||||
)
|
||||
|
||||
with patch("prowler.lib.utils.utils.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])
|
||||
Reference in New Issue
Block a user