diff --git a/Dockerfile b/Dockerfile index e8a0d19a56..49fbc5356e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,9 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} ARG TRIVY_VERSION=0.69.2 ENV TRIVY_VERSION=${TRIVY_VERSION} +ARG ZIZMOR_VERSION=1.24.1 +ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} + # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \ @@ -48,6 +51,22 @@ RUN ARCH=$(uname -m) && \ mkdir -p /tmp/.cache/trivy && \ chmod 777 /tmp/.cache/trivy +# Install zizmor for GitHub Actions workflow scanning +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + ZIZMOR_ARCH="x86_64-unknown-linux-gnu" ; \ + elif [ "$ARCH" = "aarch64" ]; then \ + ZIZMOR_ARCH="aarch64-unknown-linux-gnu" ; \ + else \ + echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \ + fi && \ + wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \ + mkdir -p /tmp/zizmor-extract && \ + tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \ + mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \ + chmod +x /usr/local/bin/zizmor && \ + rm -rf /tmp/zizmor.tar.gz /tmp/zizmor-extract + # Add prowler user RUN addgroup --gid 1000 prowler && \ adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index d6ac07f956..40579b954b 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🚀 Added - CIS Benchmark PDF report generation for scans, exposing the latest CIS version per provider via `GET /scans/{id}/cis/{name}/` and picking the variant dynamically via `_pick_latest_cis_variant` (no hard-coded provider → version mapping) [(#10650)](https://github.com/prowler-cloud/prowler/pull/10650) +- Install zizmor v1.24.1 in API Docker image for GitHub Actions workflow scanning [(#10607)](https://github.com/prowler-cloud/prowler/pull/10607) ### 🔄 Changed diff --git a/api/Dockerfile b/api/Dockerfile index 1bcffc479e..6f8385934d 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -8,6 +8,9 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} ARG TRIVY_VERSION=0.69.2 ENV TRIVY_VERSION=${TRIVY_VERSION} +ARG ZIZMOR_VERSION=1.24.1 +ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} + # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ wget \ @@ -58,6 +61,22 @@ RUN ARCH=$(uname -m) && \ mkdir -p /tmp/.cache/trivy && \ chmod 777 /tmp/.cache/trivy +# Install zizmor for GitHub Actions workflow scanning +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + ZIZMOR_ARCH="x86_64-unknown-linux-gnu" ; \ + elif [ "$ARCH" = "aarch64" ]; then \ + ZIZMOR_ARCH="aarch64-unknown-linux-gnu" ; \ + else \ + echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \ + fi && \ + wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \ + mkdir -p /tmp/zizmor-extract && \ + tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \ + mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \ + chmod +x /usr/local/bin/zizmor && \ + rm -rf /tmp/zizmor.tar.gz /tmp/zizmor-extract + # Add prowler user RUN addgroup --gid 1000 prowler && \ adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 34c4733d81..b43f379f5e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added +- GitHub Actions service for scanning workflow security issues using zizmor [(#10607)](https://github.com/prowler-cloud/prowler/pull/10607) - SARIF output format for the IaC provider, enabling GitHub Code Scanning integration via `--output-formats sarif` [(#10626)](https://github.com/prowler-cloud/prowler/pull/10626) - `repository_default_branch_dismisses_stale_reviews` check for GitHub provider to ensure stale pull request approvals are dismissed when new commits are pushed [(#10569)](https://github.com/prowler-cloud/prowler/pull/10569) - Official Prowler GitHub Action (`prowler-cloud/prowler@5.25`) for running scans in GitHub workflows with optional `--push-to-cloud` and SARIF upload to GitHub Code Scanning [(#10872)](https://github.com/prowler-cloud/prowler/pull/10872) diff --git a/prowler/__main__.py b/prowler/__main__.py index c1fbeb5a1f..925fa1008b 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -199,7 +199,8 @@ def prowler(): if compliance_framework: args.output_formats.extend(compliance_framework) # If no input compliance framework, set all, unless a specific service or check is input - elif default_execution: + # Skip for IAC and LLM providers that don't use compliance frameworks + elif default_execution and provider not in ["iac", "llm"]: args.output_formats.extend(get_available_compliance_frameworks(provider)) # Set Logger configuration @@ -430,14 +431,15 @@ def prowler(): findings = global_provider.run_scan(streaming_callback=streaming_callback) else: - # Original behavior for IAC or non-verbose LLM + # Original behavior for IAC and Image try: findings = global_provider.run() except ImageBaseException as error: logger.critical(f"{error}") sys.exit(1) - # Note: IaC doesn't support granular progress tracking since Trivy runs as a black box - # and returns all findings at once. Progress tracking would just be 0% → 100%. + # Note: External tool providers don't support granular progress tracking since + # they run external tools as a black box and return all findings at once. + # Progress tracking would just be 0% → 100%. # Filter findings by status if specified if hasattr(args, "status") and args.status: diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py index 366f1ed9d6..5737c0b232 100644 --- a/prowler/lib/check/checks_loader.py +++ b/prowler/lib/check/checks_loader.py @@ -2,6 +2,7 @@ import sys from colorama import Fore, Style +from prowler.config.config import EXTERNAL_TOOL_PROVIDERS from prowler.lib.check.check import parse_checks_from_file from prowler.lib.check.compliance_models import Compliance from prowler.lib.check.models import CheckMetadata, Severity @@ -24,8 +25,8 @@ def load_checks_to_execute( ) -> set: """Generate the list of checks to execute based on the cloud provider and the input arguments given""" try: - # Bypass check loading for providers that use Trivy directly - if provider in ("iac", "image"): + # Bypass check loading for providers that use external tools directly + if provider in EXTERNAL_TOOL_PROVIDERS: return set() # Local subsets diff --git a/prowler/lib/check/utils.py b/prowler/lib/check/utils.py index 45f16cde32..7647c25c9c 100644 --- a/prowler/lib/check/utils.py +++ b/prowler/lib/check/utils.py @@ -2,6 +2,7 @@ import importlib import sys from pkgutil import walk_packages +from prowler.config.config import EXTERNAL_TOOL_PROVIDERS from prowler.lib.logger import logger @@ -14,8 +15,8 @@ def recover_checks_from_provider( Returns a list of tuples with the following format (check_name, check_path) """ try: - # Bypass check loading for IAC, LLM, and Image providers since they use external tools directly - if provider in ("iac", "llm", "image"): + # Bypass check loading for providers that use external tools directly + if provider in EXTERNAL_TOOL_PROVIDERS: return [] checks = [] @@ -63,8 +64,8 @@ def recover_checks_from_service(service_list: list, provider: str) -> set: Returns a set of checks from the given services """ try: - # Bypass check loading for IAC provider since it uses Trivy directly - if provider == "iac": + # Bypass check loading for providers that use external tools directly + if provider in EXTERNAL_TOOL_PROVIDERS: return set() checks = set() diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index f35b5881cc..24aa02083d 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -29,7 +29,7 @@ class ProwlerArgumentParser: self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image} ...", + usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image,llm} ...", epilog=""" Available Cloud Providers: {aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel} @@ -44,11 +44,11 @@ Available Cloud Providers: oraclecloud Oracle Cloud Infrastructure Provider openstack OpenStack Provider alibabacloud Alibaba Cloud Provider - iac IaC Provider (Beta) + iac IaC Provider llm LLM Provider (Beta) image Container Image Provider nhn NHN Provider (Unofficial) - mongodbatlas MongoDB Atlas Provider (Beta) + mongodbatlas MongoDB Atlas Provider vercel Vercel Provider Available components: diff --git a/prowler/lib/outputs/compliance/compliance.py b/prowler/lib/outputs/compliance/compliance.py index d399900837..db657063fa 100644 --- a/prowler/lib/outputs/compliance/compliance.py +++ b/prowler/lib/outputs/compliance/compliance.py @@ -41,6 +41,9 @@ def display_compliance_table( Returns: None """ + # Filter out findings with dynamic CheckIDs not present in bulk_checks_metadata + findings = [f for f in findings if f.check_metadata.CheckID in bulk_checks_metadata] + try: if "ens_" in compliance_framework: get_ens_table( diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 610cd10f04..b0486d9410 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -282,6 +282,11 @@ class Provider(ABC): repositories=repos, repo_list_file=getattr(arguments, "repo_list_file", None), organizations=orgs, + github_actions_enabled=not getattr( + arguments, "no_github_actions", False + ), + exclude_workflows=getattr(arguments, "exclude_workflows", []), + fixer_config=fixer_config, ) elif "googleworkspace" in provider_class_name.lower(): provider_class( diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index ab3441b81c..0f6e7f59ea 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -119,6 +119,9 @@ class GithubProvider(Provider): repositories: list = None, repo_list_file: str = None, organizations: list = None, + # GitHub Actions scanning + github_actions_enabled: bool = True, + exclude_workflows: list = None, ): """ GitHub Provider constructor @@ -210,8 +213,20 @@ class GithubProvider(Provider): self._mutelist = GithubMutelist( mutelist_path=mutelist_path, ) + # GitHub Actions scanning configuration + self._github_actions_enabled = github_actions_enabled + self._exclude_workflows = exclude_workflows or [] + Provider.set_global_provider(self) + @property + def github_actions_enabled(self) -> bool: + return self._github_actions_enabled + + @property + def exclude_workflows(self) -> list: + return self._exclude_workflows + @property def auth_method(self): """Returns the authentication method for the GitHub provider.""" diff --git a/prowler/providers/github/lib/arguments/arguments.py b/prowler/providers/github/lib/arguments/arguments.py index 946029ab43..ab68597fdc 100644 --- a/prowler/providers/github/lib/arguments/arguments.py +++ b/prowler/providers/github/lib/arguments/arguments.py @@ -64,3 +64,20 @@ def init_parser(self): default=None, metavar="ORGANIZATION", ) + + github_actions_subparser = github_parser.add_argument_group( + "GitHub Actions Scanning" + ) + github_actions_subparser.add_argument( + "--no-github-actions", + action="store_true", + default=False, + help="Disable GitHub Actions workflow security scanning", + ) + github_actions_subparser.add_argument( + "--exclude-workflows", + nargs="+", + default=[], + help="Workflow files or glob patterns to exclude from GitHub Actions scanning", + metavar="PATTERN", + ) diff --git a/prowler/providers/github/services/githubactions/__init__.py b/prowler/providers/github/services/githubactions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/githubactions/githubactions_client.py b/prowler/providers/github/services/githubactions/githubactions_client.py new file mode 100644 index 0000000000..c4c81de887 --- /dev/null +++ b/prowler/providers/github/services/githubactions/githubactions_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.github.services.githubactions.githubactions_service import ( + GithubActions, +) + +githubactions_client = GithubActions(Provider.get_global_provider()) diff --git a/prowler/providers/github/services/githubactions/githubactions_service.py b/prowler/providers/github/services/githubactions/githubactions_service.py new file mode 100644 index 0000000000..e18b2d8967 --- /dev/null +++ b/prowler/providers/github/services/githubactions/githubactions_service.py @@ -0,0 +1,273 @@ +import io +import json +import shutil +import subprocess +import tempfile +from fnmatch import fnmatch +from os.path import basename +from typing import Optional + +from dulwich import porcelain +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.github.github_provider import GithubProvider +from prowler.providers.github.lib.service.service import GithubService + + +class GithubActions(GithubService): + def __init__(self, provider: GithubProvider): + super().__init__(__class__.__name__, provider) + + self.findings: dict[int, list[GithubActionsWorkflowFinding]] = {} + self.scan_enabled = False + + if not getattr(provider, "github_actions_enabled", True): + logger.info( + "GitHub Actions scanning is disabled via --no-github-actions flag." + ) + return + + if not shutil.which("zizmor"): + logger.warning( + "zizmor binary not found. Skipping GitHub Actions workflow security scanning. " + "Install zizmor from https://github.com/woodruffw/zizmor" + ) + return + + self.scan_enabled = True + + self._scan_repositories(provider) + + def _scan_repositories(self, provider: GithubProvider): + from prowler.providers.github.services.repository.repository_client import ( + repository_client, + ) + + exclude_workflows = getattr(provider, "exclude_workflows", []) or [] + + for repo_id, repo in repository_client.repositories.items(): + temp_dir = None + try: + temp_dir = self._clone_repository( + f"https://github.com/{repo.full_name}", + provider.session.token, + ) + if not temp_dir: + continue + + raw_findings = self._run_zizmor(temp_dir) + + repo_findings = [] + for finding in raw_findings: + for location in finding.get("locations", []): + workflow_file = self._extract_workflow_file_from_location( + location + ) + if not workflow_file: + continue + if workflow_file.startswith(temp_dir): + workflow_file = workflow_file[len(temp_dir) :].lstrip("/") + if self._should_exclude_workflow( + workflow_file, exclude_workflows + ): + continue + + parsed = self._parse_finding( + finding, workflow_file, location, repo + ) + if parsed: + repo_findings.append(parsed) + + self.findings[repo_id] = repo_findings + + except Exception as error: + logger.error( + f"Error scanning repository {repo.full_name}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + finally: + if temp_dir: + shutil.rmtree(temp_dir, ignore_errors=True) + + def _clone_repository( + self, repository_url: str, token: str = None + ) -> Optional[str]: + try: + auth_url = repository_url + if token: + auth_url = repository_url.replace( + "https://github.com/", + f"https://{token}@github.com/", + ) + + temp_dir = tempfile.mkdtemp() + logger.info(f"Cloning repository {repository_url} into {temp_dir}...") + porcelain.clone(auth_url, temp_dir, depth=1, errstream=io.BytesIO()) + return temp_dir + except Exception as error: + error_msg = str(error) + if token: + error_msg = error_msg.replace(token, "***") + logger.error( + f"Failed to clone {repository_url}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error_msg}" + ) + return None + + def _run_zizmor(self, directory: str) -> list[dict]: + try: + process = subprocess.run( + ["zizmor", directory, "--format", "json"], + capture_output=True, + text=True, + timeout=1800, + ) + + if process.stderr: + for line in process.stderr.strip().split("\n"): + if line.strip(): + logger.debug(f"zizmor: {line}") + + if not process.stdout: + return [] + + output = json.loads(process.stdout) + if not output or (isinstance(output, list) and len(output) == 0): + return [] + + return output + + except json.JSONDecodeError as error: + logger.warning(f"Failed to parse zizmor output as JSON: {error}") + return [] + except Exception as error: + logger.error( + f"Error running zizmor: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return [] + + @staticmethod + def _should_exclude_workflow( + workflow_file: str, exclude_patterns: list[str] + ) -> bool: + if not exclude_patterns: + return False + + filename = basename(workflow_file) + + for pattern in exclude_patterns: + if fnmatch(workflow_file, pattern): + logger.debug( + f"Excluding workflow {workflow_file} (matches full path pattern: {pattern})" + ) + return True + if fnmatch(filename, pattern): + logger.debug( + f"Excluding workflow {workflow_file} (matches filename pattern: {pattern})" + ) + return True + + return False + + @staticmethod + def _extract_workflow_file_from_location(location: dict) -> Optional[str]: + try: + symbolic = location.get("symbolic", {}) + if "key" in symbolic: + key = symbolic["key"] + if isinstance(key, dict) and "Local" in key: + local = key["Local"] + if isinstance(local, dict) and "given_path" in local: + return local["given_path"] + + logger.debug(f"Could not extract workflow file from location: {location}") + return None + except Exception as error: + logger.error( + f"Error extracting workflow file from location: " + f"{error.__class__.__name__} - {error}" + ) + return None + + @staticmethod + def _parse_finding( + finding: dict, workflow_file: str, location: dict, repo + ) -> Optional["GithubActionsWorkflowFinding"]: + try: + concrete_location = location.get("concrete", {}).get("location", {}) + start = concrete_location.get("start_point", {}) + end = concrete_location.get("end_point", {}) + + 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" + + determinations = finding.get("determinations", {}) + severity = determinations.get("severity", "Unknown").lower() + confidence = determinations.get("confidence", "Unknown") + + severity_map = { + "critical": "critical", + "high": "high", + "medium": "medium", + "low": "low", + "informational": "informational", + "unknown": "medium", + } + + default_branch = getattr( + getattr(repo, "default_branch", None), "name", "main" + ) + workflow_url = f"https://github.com/{repo.full_name}/blob/{default_branch}/{workflow_file}" + + ident = finding.get("ident", "unknown") + + return GithubActionsWorkflowFinding( + repo_id=repo.id, + repo_name=repo.name, + repo_full_name=repo.full_name, + repo_owner=repo.owner, + workflow_file=workflow_file, + workflow_url=workflow_url, + line_range=line_range, + finding_id=f"githubactions_{ident.replace('-', '_')}", + ident=ident, + description=finding.get( + "desc", "Security issue detected in GitHub Actions workflow" + ), + severity=severity_map.get(severity, "medium"), + confidence=confidence, + annotation=location.get("symbolic", {}).get( + "annotation", "No details available" + ), + url=finding.get("url", "https://docs.zizmor.sh/"), + ) + except Exception as error: + logger.error( + f"Error parsing zizmor finding: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + +class GithubActionsWorkflowFinding(BaseModel): + repo_id: int + repo_name: str + repo_full_name: str + repo_owner: str + workflow_file: str + workflow_url: str + line_range: str + finding_id: str + ident: str + description: str + severity: str + confidence: str + annotation: str + url: str diff --git a/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/__init__.py b/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.metadata.json b/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.metadata.json new file mode 100644 index 0000000000..12bc19df5f --- /dev/null +++ b/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "github", + "CheckID": "githubactions_workflow_security_scan", + "CheckTitle": "GitHub Actions workflows have no security issues detected by zizmor", + "CheckType": [], + "ServiceName": "githubactions", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "GitHubActionsWorkflow", + "ResourceGroup": "devops", + "Description": "Scan GitHub Actions workflow files for security issues such as template injection, excessive permissions, unpinned actions, and other misconfigurations using the zizmor static analysis tool.", + "Risk": "Insecure GitHub Actions workflows can lead to supply chain attacks, credential theft, code injection, and unauthorized access to repository secrets.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Review and fix the security issues identified by zizmor in your GitHub Actions workflow files.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Review your GitHub Actions workflows for security best practices including pinning actions to commit SHAs, using minimal permissions, avoiding template injection, and securing workflow triggers.", + "Url": "https://hub.prowler.com/check/githubactions_workflow_security_scan" + } + }, + "Categories": [ + "software-supply-chain" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check requires zizmor to be installed. If zizmor is not available, the check will be skipped gracefully.", + "AdditionalURLs": [ + "https://docs.zizmor.sh/" + ] +} diff --git a/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.py b/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.py new file mode 100644 index 0000000000..5fa2897664 --- /dev/null +++ b/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.py @@ -0,0 +1,82 @@ +import json +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.githubactions.githubactions_client import ( + githubactions_client, +) +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class githubactions_workflow_security_scan(Check): + def execute(self) -> List[CheckReportGithub]: + findings = [] + + if not githubactions_client.scan_enabled: + return findings + + for repo_id, repo in repository_client.repositories.items(): + repo_findings = githubactions_client.findings.get(repo_id, []) + + if not repo_findings: + report = CheckReportGithub( + metadata=self.metadata(), + resource=repo, + ) + report.status = "PASS" + report.status_extended = f"Repository {repo.name} has no GitHub Actions workflow security issues detected by zizmor." + findings.append(report) + else: + for f in repo_findings: + metadata_dict = { + "Provider": "github", + "CheckID": f.finding_id, + "CheckTitle": f"GitHub Actions workflows free of {f.ident} issues", + "CheckType": [], + "ServiceName": "githubactions", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": f.severity, + "ResourceType": "GitHubActionsWorkflow", + "ResourceGroup": "devops", + "Description": f.description[:400], + "Risk": f.description[:400], + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "", + }, + "Recommendation": { + "Text": f"Review the zizmor documentation for {f.ident}: {f.url}", + "Url": "", + }, + }, + "Categories": ["software-supply-chain"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [f.url] if f.url else [], + } + report = CheckReportGithub( + metadata=json.dumps(metadata_dict), + resource=repo, + resource_name=f.workflow_file, + resource_id=str(f.repo_id), + owner=f.repo_owner, + ) + report.status = "FAIL" + report.status_extended = ( + f"GitHub Actions security issue in {f.workflow_file} at {f.line_range}: " + f"{f.description}. " + f"Confidence: {f.confidence}. " + f"Details: {f.annotation}. " + f"URL: {f.workflow_url}" + ) + findings.append(report) + + return findings diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index e22ffac69b..d42b1482b3 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,7 +17,7 @@ prowler_command = "prowler" # capsys # https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html -prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image} ..." +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image,llm} ..." def mock_get_available_providers(): @@ -35,6 +35,7 @@ def mock_get_available_providers(): "mongodbatlas", "oraclecloud", "alibabacloud", + "llm", "cloudflare", "openstack", ] diff --git a/tests/providers/github/lib/arguments/github_arguments_test.py b/tests/providers/github/lib/arguments/github_arguments_test.py index 20fd3f18be..dde1ccbfbb 100644 --- a/tests/providers/github/lib/arguments/github_arguments_test.py +++ b/tests/providers/github/lib/arguments/github_arguments_test.py @@ -12,6 +12,7 @@ class Test_GitHubArguments: self.mock_github_parser = MagicMock() self.mock_auth_group = MagicMock() self.mock_scoping_group = MagicMock() + self.mock_actions_group = MagicMock() # Setup the mock chain self.mock_parser.add_subparsers.return_value = self.mock_subparsers @@ -19,6 +20,7 @@ class Test_GitHubArguments: self.mock_github_parser.add_argument_group.side_effect = [ self.mock_auth_group, self.mock_scoping_group, + self.mock_actions_group, ] def test_init_parser_creates_subparser(self): @@ -47,10 +49,11 @@ class Test_GitHubArguments: arguments.init_parser(mock_github_args) # Verify argument groups were created - assert self.mock_github_parser.add_argument_group.call_count == 2 + assert self.mock_github_parser.add_argument_group.call_count == 3 calls = self.mock_github_parser.add_argument_group.call_args_list assert calls[0][0][0] == "Authentication Modes" assert calls[1][0][0] == "Scan Scoping" + assert calls[2][0][0] == "GitHub Actions Scanning" def test_init_parser_adds_authentication_arguments(self): """Test that init_parser adds all authentication arguments""" diff --git a/tests/providers/github/services/githubactions/githubactions_service_test.py b/tests/providers/github/services/githubactions/githubactions_service_test.py new file mode 100644 index 0000000000..8f38de1b16 --- /dev/null +++ b/tests/providers/github/services/githubactions/githubactions_service_test.py @@ -0,0 +1,367 @@ +import io +import json +import sys +from unittest.mock import ANY, MagicMock, patch + +from prowler.providers.github.services.githubactions.githubactions_service import ( + GithubActions, + GithubActionsWorkflowFinding, +) + + +class TestGithubActionsService: + def test_should_exclude_workflow_no_patterns(self): + assert not GithubActions._should_exclude_workflow("test.yml", []) + + def test_should_exclude_workflow_exact_filename(self): + assert GithubActions._should_exclude_workflow( + ".github/workflows/test.yml", ["test.yml"] + ) + + def test_should_exclude_workflow_wildcard_filename(self): + assert GithubActions._should_exclude_workflow( + ".github/workflows/test-api.yml", ["test-*.yml"] + ) + + def test_should_exclude_workflow_full_path(self): + assert GithubActions._should_exclude_workflow( + ".github/workflows/test.yml", [".github/workflows/test.yml"] + ) + + def test_should_exclude_workflow_full_path_wildcard(self): + assert GithubActions._should_exclude_workflow( + ".github/workflows/api-tests.yml", [".github/workflows/api-*.yml"] + ) + + def test_should_exclude_workflow_no_match(self): + assert not GithubActions._should_exclude_workflow( + ".github/workflows/deploy.yml", ["test-*.yml", "api-*.yml"] + ) + + def test_should_exclude_workflow_multiple_patterns(self): + assert GithubActions._should_exclude_workflow( + ".github/workflows/api-test.yml", ["test-*.yml", "api-*.yml"] + ) + + def test_should_exclude_workflow_filename_in_subdir(self): + assert GithubActions._should_exclude_workflow( + "workflows/subdir/test-deploy.yml", ["test-*.yml"] + ) + + def test_extract_workflow_file_from_location_v1(self): + location = { + "symbolic": { + "key": {"Local": {"given_path": ".github/workflows/test.yml"}}, + } + } + result = GithubActions._extract_workflow_file_from_location(location) + assert result == ".github/workflows/test.yml" + + def test_extract_workflow_file_from_location_missing_key(self): + location = {"symbolic": {}} + result = GithubActions._extract_workflow_file_from_location(location) + assert result is None + + def test_extract_workflow_file_from_location_empty(self): + result = GithubActions._extract_workflow_file_from_location({}) + assert result is None + + def test_parse_finding_valid(self): + finding = { + "ident": "template-injection", + "desc": "Template Injection Vulnerability", + "determinations": {"severity": "high", "confidence": "High"}, + "url": "https://example.com/docs", + } + location = { + "symbolic": { + "annotation": "High risk of code execution", + "key": {"Local": {"given_path": ".github/workflows/test.yml"}}, + }, + "concrete": { + "location": { + "start_point": {"row": 10, "column": 5}, + "end_point": {"row": 10, "column": 15}, + } + }, + } + repo = MagicMock() + repo.id = 1 + repo.name = "test-repo" + repo.full_name = "owner/test-repo" + repo.owner = "owner" + + result = GithubActions._parse_finding( + finding, ".github/workflows/test.yml", location, repo + ) + + assert isinstance(result, GithubActionsWorkflowFinding) + assert result.finding_id == "githubactions_template_injection" + assert result.ident == "template-injection" + assert result.severity == "high" + assert result.line_range == "line 10" + assert result.workflow_file == ".github/workflows/test.yml" + assert result.repo_name == "test-repo" + assert result.confidence == "High" + + def test_parse_finding_multiline_range(self): + finding = { + "ident": "excessive-permissions", + "desc": "Excessive permissions", + "determinations": {"severity": "medium", "confidence": "Medium"}, + "url": "https://example.com", + } + location = { + "symbolic": {"annotation": "Excessive permissions detected"}, + "concrete": { + "location": { + "start_point": {"row": 5, "column": 1}, + "end_point": {"row": 10, "column": 20}, + } + }, + } + repo = MagicMock() + repo.id = 1 + repo.name = "repo" + repo.full_name = "owner/repo" + repo.owner = "owner" + + result = GithubActions._parse_finding(finding, "wf.yml", location, repo) + assert result.line_range == "lines 5-10" + + def test_parse_finding_unknown_severity(self): + finding = { + "ident": "test", + "desc": "Test", + "determinations": {"severity": "Unknown", "confidence": "Low"}, + } + location = { + "symbolic": {}, + "concrete": {"location": {}}, + } + repo = MagicMock() + repo.id = 1 + repo.name = "repo" + repo.full_name = "owner/repo" + repo.owner = "owner" + + result = GithubActions._parse_finding(finding, "wf.yml", location, repo) + assert result.severity == "medium" + assert result.line_range == "location unknown" + + def test_run_zizmor_no_output(self): + mock_process = MagicMock() + mock_process.stdout = "" + mock_process.stderr = "" + + with patch("subprocess.run", return_value=mock_process): + service = GithubActions.__new__(GithubActions) + result = service._run_zizmor("/tmp/test") + assert result == [] + + def test_run_zizmor_empty_array(self): + mock_process = MagicMock() + mock_process.stdout = "[]" + mock_process.stderr = "" + + with patch("subprocess.run", return_value=mock_process): + service = GithubActions.__new__(GithubActions) + result = service._run_zizmor("/tmp/test") + assert result == [] + + def test_run_zizmor_with_findings(self): + mock_output = [ + { + "ident": "excessive-permissions", + "desc": "Workflow has write-all permissions", + "determinations": {"severity": "medium", "confidence": "High"}, + "locations": [ + { + "symbolic": { + "key": {"Local": {"given_path": ".github/workflows/ci.yml"}} + }, + "concrete": { + "location": { + "start_point": {"row": 5, "column": 1}, + "end_point": {"row": 5, "column": 20}, + } + }, + } + ], + } + ] + mock_process = MagicMock() + mock_process.stdout = json.dumps(mock_output) + mock_process.stderr = "" + + with patch("subprocess.run", return_value=mock_process): + service = GithubActions.__new__(GithubActions) + result = service._run_zizmor("/tmp/test") + assert len(result) == 1 + assert result[0]["ident"] == "excessive-permissions" + + def test_run_zizmor_invalid_json(self): + mock_process = MagicMock() + mock_process.stdout = "not valid json" + mock_process.stderr = "" + + with patch("subprocess.run", return_value=mock_process): + service = GithubActions.__new__(GithubActions) + result = service._run_zizmor("/tmp/test") + assert result == [] + + def test_clone_repository_with_token(self): + with ( + patch("tempfile.mkdtemp", return_value="/tmp/test"), + patch("dulwich.porcelain.clone") as mock_clone, + ): + service = GithubActions.__new__(GithubActions) + result = service._clone_repository( + "https://github.com/owner/repo", token="mytoken" + ) + + assert result == "/tmp/test" + mock_clone.assert_called_once_with( + "https://mytoken@github.com/owner/repo", + "/tmp/test", + depth=1, + errstream=ANY, + ) + call_kwargs = mock_clone.call_args + assert isinstance(call_kwargs.kwargs["errstream"], io.BytesIO) + + def test_clone_repository_without_token(self): + with ( + patch("tempfile.mkdtemp", return_value="/tmp/test"), + patch("dulwich.porcelain.clone") as mock_clone, + ): + service = GithubActions.__new__(GithubActions) + result = service._clone_repository("https://github.com/owner/repo") + + assert result == "/tmp/test" + mock_clone.assert_called_once_with( + "https://github.com/owner/repo", + "/tmp/test", + depth=1, + errstream=ANY, + ) + call_kwargs = mock_clone.call_args + assert isinstance(call_kwargs.kwargs["errstream"], io.BytesIO) + + def test_clone_repository_failure(self): + with ( + patch("tempfile.mkdtemp", return_value="/tmp/test"), + patch("dulwich.porcelain.clone", side_effect=Exception("clone failed")), + ): + service = GithubActions.__new__(GithubActions) + result = service._clone_repository("https://github.com/owner/repo") + assert result is None + + def test_init_zizmor_missing(self): + mock_provider = MagicMock() + mock_provider.session = MagicMock() + mock_provider.session.token = "test-token" + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_provider.github_actions_enabled = True + + with ( + patch.object(GithubActions, "__init__", lambda self, provider: None), + patch("shutil.which", return_value=None), + ): + service = GithubActions.__new__(GithubActions) + service.provider = mock_provider + service.clients = [] + service.audit_config = {} + service.fixer_config = {} + service.findings = {} + + # Manually call the part after super().__init__ + # Since zizmor is missing, _scan_repositories should not be called + assert service.findings == {} + + def test_scan_repositories_strips_temp_dir_prefix(self): + temp_dir = "/var/folders/xx/tmp48xjp_g0" + zizmor_output = [ + { + "ident": "template-injection", + "desc": "Template Injection", + "determinations": {"severity": "high", "confidence": "High"}, + "url": "https://example.com", + "locations": [ + { + "symbolic": { + "key": { + "Local": { + "given_path": f"{temp_dir}/.github/workflows/release.yml" + } + }, + "annotation": "Injection risk", + }, + "concrete": { + "location": { + "start_point": {"row": 5, "column": 1}, + "end_point": {"row": 5, "column": 20}, + } + }, + } + ], + } + ] + + mock_repo = MagicMock() + mock_repo.id = 1 + mock_repo.name = "repo" + mock_repo.full_name = "owner/repo" + mock_repo.owner = "owner" + mock_repo.default_branch = MagicMock() + mock_repo.default_branch.name = "main" + + mock_repo_client = MagicMock() + mock_repo_client.repositories = {1: mock_repo} + + mock_provider = MagicMock() + mock_provider.session.token = "test-token" + mock_provider.exclude_workflows = [] + + service = GithubActions.__new__(GithubActions) + service.findings = {} + + mock_repo_module = MagicMock() + mock_repo_module.repository_client = mock_repo_client + + with ( + patch.object(service, "_clone_repository", return_value=temp_dir), + patch.object(service, "_run_zizmor", return_value=zizmor_output), + patch.dict( + sys.modules, + { + "prowler.providers.github.services.repository.repository_client": mock_repo_module, + }, + ), + patch("shutil.rmtree"), + ): + service._scan_repositories(mock_provider) + + assert 1 in service.findings + assert len(service.findings[1]) == 1 + finding = service.findings[1][0] + assert finding.workflow_file == ".github/workflows/release.yml" + assert ( + finding.workflow_url + == "https://github.com/owner/repo/blob/main/.github/workflows/release.yml" + ) + + def test_init_github_actions_disabled(self): + mock_provider = MagicMock() + mock_provider.github_actions_enabled = False + mock_provider.session = MagicMock() + mock_provider.session.token = "test-token" + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + + with patch.object(GithubActions, "__init__", lambda self, provider: None): + service = GithubActions.__new__(GithubActions) + service.findings = {} + # Service created, no scanning happened + assert service.findings == {} diff --git a/tests/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan_test.py b/tests/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan_test.py new file mode 100644 index 0000000000..33efa53969 --- /dev/null +++ b/tests/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan_test.py @@ -0,0 +1,375 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.githubactions.githubactions_service import ( + GithubActionsWorkflowFinding, +) +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +def _make_repo(repo_id=1, name="repo1", owner="account-name"): + return Repo( + id=repo_id, + name=name, + owner=owner, + full_name=f"{owner}/{name}", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=True, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, + ) + + +def _make_finding( + repo_id=1, + repo_name="repo1", + repo_owner="account-name", + workflow_file=".github/workflows/ci.yml", +): + return GithubActionsWorkflowFinding( + repo_id=repo_id, + repo_name=repo_name, + repo_full_name=f"{repo_owner}/{repo_name}", + repo_owner=repo_owner, + workflow_file=workflow_file, + workflow_url=f"https://github.com/{repo_owner}/{repo_name}/blob/main/{workflow_file}", + line_range="line 10", + finding_id="githubactions_template_injection", + ident="template-injection", + description="Template Injection Vulnerability", + severity="high", + confidence="High", + annotation="High risk of code execution", + url="https://docs.zizmor.sh/", + ) + + +class Test_githubactions_workflow_security_scan: + def test_scan_disabled(self): + repo = _make_repo() + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo} + + githubactions_client = mock.MagicMock() + githubactions_client.scan_enabled = False + githubactions_client.findings = {1: [_make_finding()]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 0 + + def test_no_repositories(self): + repository_client = mock.MagicMock() + repository_client.repositories = {} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 0 + + def test_repository_no_findings_pass(self): + repo = _make_repo() + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "repo1" + assert ( + result[0].check_metadata.CheckID + == "githubactions_workflow_security_scan" + ) + assert ( + "no GitHub Actions workflow security issues" + in result[0].status_extended + ) + + def test_repository_with_findings_fail(self): + repo = _make_repo() + finding = _make_finding() + + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {1: [finding]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == ".github/workflows/ci.yml" + assert "Template Injection Vulnerability" in result[0].status_extended + assert "line 10" in result[0].status_extended + assert "High" in result[0].status_extended + assert ( + "https://github.com/account-name/repo1/blob/main/.github/workflows/ci.yml" + in result[0].status_extended + ) + assert ( + result[0].check_metadata.CheckID == "githubactions_template_injection" + ) + assert ( + result[0].check_metadata.CheckTitle + == "GitHub Actions workflows free of template-injection issues" + ) + assert result[0].check_metadata.Severity == "high" + assert result[0].check_metadata.Risk == "Template Injection Vulnerability" + assert "https://docs.zizmor.sh/" in result[0].check_metadata.AdditionalURLs + + def test_repository_with_multiple_findings(self): + repo = _make_repo() + finding1 = _make_finding(workflow_file=".github/workflows/ci.yml") + finding2 = _make_finding(workflow_file=".github/workflows/deploy.yml") + + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {1: [finding1, finding2]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 2 + assert all(r.status == "FAIL" for r in result) + workflow_files = [r.resource_name for r in result] + assert ".github/workflows/ci.yml" in workflow_files + assert ".github/workflows/deploy.yml" in workflow_files + + def test_findings_have_independent_metadata(self): + repo = _make_repo() + finding1 = GithubActionsWorkflowFinding( + repo_id=1, + repo_name="repo1", + repo_full_name="account-name/repo1", + repo_owner="account-name", + workflow_file=".github/workflows/ci.yml", + workflow_url="https://github.com/account-name/repo1/blob/main/.github/workflows/ci.yml", + line_range="line 10", + finding_id="githubactions_template_injection", + ident="template-injection", + description="Template Injection", + severity="high", + confidence="High", + annotation="Attacker-controllable code", + url="https://docs.zizmor.sh/audits/#template-injection", + ) + finding2 = GithubActionsWorkflowFinding( + repo_id=1, + repo_name="repo1", + repo_full_name="account-name/repo1", + repo_owner="account-name", + workflow_file=".github/workflows/deploy.yml", + workflow_url="https://github.com/account-name/repo1/blob/main/.github/workflows/deploy.yml", + line_range="line 5", + finding_id="githubactions_excessive_permissions", + ident="excessive-permissions", + description="Excessive Permissions", + severity="medium", + confidence="Medium", + annotation="Workflow has overly broad permissions", + url="https://docs.zizmor.sh/audits/#excessive-permissions", + ) + + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {1: [finding1, finding2]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 2 + + r1 = next( + r for r in result if r.resource_name == ".github/workflows/ci.yml" + ) + r2 = next( + r for r in result if r.resource_name == ".github/workflows/deploy.yml" + ) + + assert r1.check_metadata.CheckID == "githubactions_template_injection" + assert r1.check_metadata.Severity == "high" + assert r1.check_metadata.Risk == "Template Injection" + assert ( + "https://docs.zizmor.sh/audits/#template-injection" + in r1.check_metadata.AdditionalURLs + ) + + assert r2.check_metadata.CheckID == "githubactions_excessive_permissions" + assert r2.check_metadata.Severity == "medium" + assert r2.check_metadata.Risk == "Excessive Permissions" + assert ( + "https://docs.zizmor.sh/audits/#excessive-permissions" + in r2.check_metadata.AdditionalURLs + ) + + def test_multiple_repos_mixed(self): + repo1 = _make_repo(repo_id=1, name="repo1") + repo2 = _make_repo(repo_id=2, name="repo2") + finding = _make_finding(repo_id=1) + + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo1, 2: repo2} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {1: [finding]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 2 + statuses = {r.resource_name: r.status for r in result} + assert statuses[".github/workflows/ci.yml"] == "FAIL" + assert statuses["repo2"] == "PASS"