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 == {}