|
| 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