diff --git a/docs/tutorials/github-action.md b/docs/tutorials/github-action.md new file mode 100644 index 0000000000..70c26f44d3 --- /dev/null +++ b/docs/tutorials/github-action.md @@ -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 \ No newline at end of file diff --git a/prowler/__main__.py b/prowler/__main__.py index 8dec8188de..7ade7ab31c 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -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( diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py index c03034b0e4..7effbb3e97 100644 --- a/prowler/lib/check/checks_loader.py +++ b/prowler/lib/check/checks_loader.py @@ -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 diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index de38162cbc..5bae4b83c0 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -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.""" diff --git a/prowler/lib/check/utils.py b/prowler/lib/check/utils.py index cab19486d5..45bce10898 100644 --- a/prowler/lib/check/utils.py +++ b/prowler/lib/check/utils.py @@ -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() diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 02039c3c71..ab286c36bb 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -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 diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index f686554a88..1a1148ffc7 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -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 diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index a6e011755a..45eb23dbb6 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -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): diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 1b2de71148..4e33a3a263 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -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, diff --git a/prowler/providers/github_action/__init__.py b/prowler/providers/github_action/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github_action/github_action_provider.py b/prowler/providers/github_action/github_action_provider.py new file mode 100644 index 0000000000..c0e29faf48 --- /dev/null +++ b/prowler/providers/github_action/github_action_provider.py @@ -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) \ No newline at end of file diff --git a/prowler/providers/github_action/lib/__init__.py b/prowler/providers/github_action/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github_action/lib/arguments/__init__.py b/prowler/providers/github_action/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github_action/lib/arguments/arguments.py b/prowler/providers/github_action/lib/arguments/arguments.py new file mode 100644 index 0000000000..ff88a90a3c --- /dev/null +++ b/prowler/providers/github_action/lib/arguments/arguments.py @@ -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, "") \ No newline at end of file diff --git a/prowler/providers/github_action/models.py b/prowler/providers/github_action/models.py new file mode 100644 index 0000000000..ca4356d93b --- /dev/null +++ b/prowler/providers/github_action/models.py @@ -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 \ No newline at end of file diff --git a/tests/providers/github_action/__init__.py b/tests/providers/github_action/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/github_action/test_github_action_provider.py b/tests/providers/github_action/test_github_action_provider.py new file mode 100644 index 0000000000..da48eb99c0 --- /dev/null +++ b/tests/providers/github_action/test_github_action_provider.py @@ -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]) \ No newline at end of file