feat(github_action): inital POC

This commit is contained in:
Andoni A.
2025-08-27 16:25:02 +02:00
parent 329ac113f2
commit 1f1e905d9e
17 changed files with 1032 additions and 13 deletions
+217
View File
@@ -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
View File
@@ -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(
+2 -2
View File
@@ -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
+32
View File
@@ -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."""
+4 -4
View File
@@ -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()
+3 -2
View File
@@ -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
+9
View File
@@ -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
+7
View File
@@ -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):
+16 -1
View File
@@ -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, "")
+27
View File
@@ -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])