|  | 
|  | 1 | +# Copyright (c) 2025 - 2025, Oracle and/or its affiliates. All rights reserved. | 
|  | 2 | +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. | 
|  | 3 | + | 
|  | 4 | +"""This module contains the implementation of the GitHub Actions vulnerabilities check.""" | 
|  | 5 | + | 
|  | 6 | +import logging | 
|  | 7 | +import os | 
|  | 8 | + | 
|  | 9 | +from sqlalchemy import ForeignKey, String | 
|  | 10 | +from sqlalchemy.orm import Mapped, mapped_column | 
|  | 11 | + | 
|  | 12 | +from macaron.database.db_custom_types import DBJsonList | 
|  | 13 | +from macaron.database.table_definitions import CheckFacts | 
|  | 14 | +from macaron.errors import APIAccessError | 
|  | 15 | +from macaron.json_tools import json_extract | 
|  | 16 | +from macaron.slsa_analyzer.analyze_context import AnalyzeContext | 
|  | 17 | +from macaron.slsa_analyzer.checks.base_check import BaseCheck, CheckResultType | 
|  | 18 | +from macaron.slsa_analyzer.checks.check_result import CheckResultData, Confidence, JustificationType | 
|  | 19 | +from macaron.slsa_analyzer.ci_service.github_actions.analyzer import GitHubWorkflowNode, GitHubWorkflowType | 
|  | 20 | +from macaron.slsa_analyzer.package_registry.osv_dev import OSVDevService | 
|  | 21 | +from macaron.slsa_analyzer.registry import registry | 
|  | 22 | +from macaron.slsa_analyzer.slsa_req import ReqName | 
|  | 23 | + | 
|  | 24 | +logger: logging.Logger = logging.getLogger(__name__) | 
|  | 25 | + | 
|  | 26 | + | 
|  | 27 | +class GitHubActionsVulnsFacts(CheckFacts): | 
|  | 28 | +    """The ORM mapping for justifications in the GitHub Actions vulnerabilities check.""" | 
|  | 29 | + | 
|  | 30 | +    __tablename__ = "_github_actions_vulnerabilities_check" | 
|  | 31 | + | 
|  | 32 | +    #: The primary key. | 
|  | 33 | +    id: Mapped[int] = mapped_column(ForeignKey("_check_facts.id"), primary_key=True)  # noqa: A003 | 
|  | 34 | + | 
|  | 35 | +    #: The list of vulnerability URLs. | 
|  | 36 | +    vulnerability_urls: Mapped[list[str]] = mapped_column( | 
|  | 37 | +        DBJsonList, nullable=False, info={"justification": JustificationType.TEXT} | 
|  | 38 | +    ) | 
|  | 39 | + | 
|  | 40 | +    #: The GitHub Action Identifier. | 
|  | 41 | +    github_actions_id: Mapped[str] = mapped_column( | 
|  | 42 | +        String, nullable=False, info={"justification": JustificationType.TEXT} | 
|  | 43 | +    ) | 
|  | 44 | + | 
|  | 45 | +    #: The GitHub Action version. | 
|  | 46 | +    github_actions_version: Mapped[str] = mapped_column( | 
|  | 47 | +        String, nullable=False, info={"justification": JustificationType.TEXT} | 
|  | 48 | +    ) | 
|  | 49 | + | 
|  | 50 | +    #: The GitHub Action workflow that calls the vulnerable GitHub Action. | 
|  | 51 | +    caller_workflow: Mapped[str] = mapped_column(String, nullable=False, info={"justification": JustificationType.HREF}) | 
|  | 52 | + | 
|  | 53 | +    __mapper_args__ = { | 
|  | 54 | +        "polymorphic_identity": "_github_actions_vulnerabilities_check", | 
|  | 55 | +    } | 
|  | 56 | + | 
|  | 57 | + | 
|  | 58 | +class GitHubActionsVulnsCheck(BaseCheck): | 
|  | 59 | +    """This Check checks whether the GitHub Actions called from the corresponding repo have known vulnerabilities. | 
|  | 60 | +
 | 
|  | 61 | +    Note: This check analyzes the direct GitHub Actions dependencies only. | 
|  | 62 | +    TODO: Check GitHub Actions dependencies recursively. | 
|  | 63 | +    """ | 
|  | 64 | + | 
|  | 65 | +    def __init__(self) -> None: | 
|  | 66 | +        """Initialize instance.""" | 
|  | 67 | +        check_id = "mcn_githubactions_vulnerabilities_1" | 
|  | 68 | +        description = "Check whether the GitHub Actions called from the corresponding repo have known vulnerabilities.." | 
|  | 69 | +        depends_on: list[tuple[str, CheckResultType]] = [("mcn_version_control_system_1", CheckResultType.PASSED)] | 
|  | 70 | +        eval_reqs = [ReqName.SECURITY] | 
|  | 71 | +        super().__init__(check_id=check_id, description=description, depends_on=depends_on, eval_reqs=eval_reqs) | 
|  | 72 | + | 
|  | 73 | +    def run_check(self, ctx: AnalyzeContext) -> CheckResultData: | 
|  | 74 | +        """Implement the check in this method. | 
|  | 75 | +
 | 
|  | 76 | +        Parameters | 
|  | 77 | +        ---------- | 
|  | 78 | +        ctx : AnalyzeContext | 
|  | 79 | +            The object containing processed data for the target repo. | 
|  | 80 | +
 | 
|  | 81 | +        Returns | 
|  | 82 | +        ------- | 
|  | 83 | +        CheckResultData | 
|  | 84 | +            The result of the check. | 
|  | 85 | +        """ | 
|  | 86 | +        result_tables: list[CheckFacts] = [] | 
|  | 87 | + | 
|  | 88 | +        ci_services = ctx.dynamic_data["ci_services"] | 
|  | 89 | + | 
|  | 90 | +        external_workflows: dict[str, list] = {} | 
|  | 91 | +        for ci_info in ci_services: | 
|  | 92 | +            for callee in ci_info["callgraph"].bfs(): | 
|  | 93 | +                if isinstance(callee, GitHubWorkflowNode) and callee.node_type in [ | 
|  | 94 | +                    GitHubWorkflowType.EXTERNAL, | 
|  | 95 | +                    GitHubWorkflowType.REUSABLE, | 
|  | 96 | +                ]: | 
|  | 97 | +                    if "@" in callee.name: | 
|  | 98 | +                        workflow_name, workflow_version = callee.name.split("@") | 
|  | 99 | +                    else: | 
|  | 100 | +                        # Most likely we have encountered an internal reusable workflow, which | 
|  | 101 | +                        # can be skipped. | 
|  | 102 | +                        logger.debug("GitHub Actions workflow %s misses a version. Skipping...", callee.name) | 
|  | 103 | +                        continue | 
|  | 104 | + | 
|  | 105 | +                    caller_path = callee.caller.source_path if callee.caller else None | 
|  | 106 | + | 
|  | 107 | +                    if not workflow_name: | 
|  | 108 | +                        logger.debug("Workflow %s is not relevant. Skipping...", callee.name) | 
|  | 109 | +                        continue | 
|  | 110 | + | 
|  | 111 | +                    ext_workflow: list = external_workflows.get(workflow_name, []) | 
|  | 112 | +                    ext_workflow.append( | 
|  | 113 | +                        { | 
|  | 114 | +                            "version": workflow_version, | 
|  | 115 | +                            "caller_path": ci_info["service"].api_client.get_file_link( | 
|  | 116 | +                                ctx.component.repository.full_name, | 
|  | 117 | +                                ctx.component.repository.commit_sha, | 
|  | 118 | +                                file_path=( | 
|  | 119 | +                                    ci_info["service"].api_client.get_relative_path_of_workflow( | 
|  | 120 | +                                        os.path.basename(caller_path) | 
|  | 121 | +                                    ) | 
|  | 122 | +                                    if caller_path | 
|  | 123 | +                                    else "" | 
|  | 124 | +                                ), | 
|  | 125 | +                            ), | 
|  | 126 | +                        } | 
|  | 127 | +                    ) | 
|  | 128 | +                    external_workflows[workflow_name] = ext_workflow | 
|  | 129 | + | 
|  | 130 | +        # We first send a batch query to see which GitHub Actions are potentially vulnerable. | 
|  | 131 | +        # OSV's querybatch returns minimal results but this allows us to only make subsequent | 
|  | 132 | +        # queries to get vulnerability details when needed. | 
|  | 133 | +        batch_query = [{"name": k, "ecosystem": "GitHub Actions"} for k, _ in external_workflows.items()] | 
|  | 134 | +        batch_vulns = [] | 
|  | 135 | +        try: | 
|  | 136 | +            batch_vulns = OSVDevService.get_vulnerabilities_package_name_batch(batch_query) | 
|  | 137 | +        except APIAccessError as error: | 
|  | 138 | +            logger.debug(error) | 
|  | 139 | + | 
|  | 140 | +        for vuln_res in batch_vulns: | 
|  | 141 | +            vulns: list = [] | 
|  | 142 | +            workflow_name = vuln_res["name"] | 
|  | 143 | +            try: | 
|  | 144 | +                vulns = OSVDevService.get_vulnerabilities_package_name(ecosystem="GitHub Actions", name=workflow_name) | 
|  | 145 | +            except APIAccessError as error: | 
|  | 146 | +                logger.debug(error) | 
|  | 147 | +                continue | 
|  | 148 | +            for workflow_inv in external_workflows[workflow_name]: | 
|  | 149 | +                vuln_mapping = [] | 
|  | 150 | +                for vuln in vulns: | 
|  | 151 | +                    if v_id := json_extract(vuln, ["id"], str): | 
|  | 152 | +                        try: | 
|  | 153 | +                            if OSVDevService.is_version_affected( | 
|  | 154 | +                                vuln, workflow_name, workflow_inv["version"], "GitHub Actions" | 
|  | 155 | +                            ): | 
|  | 156 | +                                vuln_mapping.append(f"https://osv.dev/vulnerability/{v_id}") | 
|  | 157 | +                        except APIAccessError as error: | 
|  | 158 | +                            logger.debug(error) | 
|  | 159 | +                if vuln_mapping: | 
|  | 160 | +                    result_tables.append( | 
|  | 161 | +                        GitHubActionsVulnsFacts( | 
|  | 162 | +                            vulnerability_urls=vuln_mapping, | 
|  | 163 | +                            github_actions_id=workflow_name, | 
|  | 164 | +                            github_actions_version=workflow_inv["version"], | 
|  | 165 | +                            caller_workflow=workflow_inv["caller_path"], | 
|  | 166 | +                            confidence=Confidence.HIGH, | 
|  | 167 | +                        ) | 
|  | 168 | +                    ) | 
|  | 169 | + | 
|  | 170 | +        if result_tables: | 
|  | 171 | +            return CheckResultData( | 
|  | 172 | +                result_tables=result_tables, | 
|  | 173 | +                result_type=CheckResultType.FAILED, | 
|  | 174 | +            ) | 
|  | 175 | + | 
|  | 176 | +        return CheckResultData( | 
|  | 177 | +            result_tables=[], | 
|  | 178 | +            result_type=CheckResultType.PASSED, | 
|  | 179 | +        ) | 
|  | 180 | + | 
|  | 181 | + | 
|  | 182 | +registry.register(GitHubActionsVulnsCheck()) | 
0 commit comments