From 6d44d738b7a533bbc3c8389dbcb27e25297bb2b0 Mon Sep 17 00:00:00 2001 From: jasonBirchall Date: Tue, 29 Oct 2024 10:39:52 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Check=20for=20untrusted=20actio?= =?UTF-8?q?n=20pinning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added `check_version_pinning.py` script to detect untrusted GitHub Actions not pinned to a SHA - Configured `action.yaml` with inputs for `workflow_directory` and `scan_mode` - Implemented Dockerfile to support Python-based scanning with required dependencies - Enabled user-configurable scan modes (`full` repository scan or `pr_changes` for pull requests only) - Documented usage and examples in README.md for easy adoption by others This commit introduces a flexible and reusable GitHub Action to help ensure secure, SHA-pinned dependencies in workflows. Please see the following documentation for more information about GitHub Action hardening. https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions --- check-version-pinning/Dockerfile | 12 ++ check-version-pinning/README.md | 28 +++++ check-version-pinning/action.yaml | 23 ++++ .../check_version_pinning.py | 92 ++++++++++++++ check-version-pinning/requirements.txt | 1 + .../test_check_version_pinning.py | 118 ++++++++++++++++++ 6 files changed, 274 insertions(+) create mode 100644 check-version-pinning/Dockerfile create mode 100644 check-version-pinning/README.md create mode 100644 check-version-pinning/action.yaml create mode 100644 check-version-pinning/check_version_pinning.py create mode 100644 check-version-pinning/requirements.txt create mode 100644 check-version-pinning/test_check_version_pinning.py diff --git a/check-version-pinning/Dockerfile b/check-version-pinning/Dockerfile new file mode 100644 index 0000000..52489d3 --- /dev/null +++ b/check-version-pinning/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get upgrade -y && apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY check_version_pinning.py /app/check_version_pinning.py + +ENTRYPOINT ["python", "/app/check_version_pinning.py", "${{ inputs.workflow_directory }}", "${{ inputs.scan_mode }}"] diff --git a/check-version-pinning/README.md b/check-version-pinning/README.md new file mode 100644 index 0000000..d1b841c --- /dev/null +++ b/check-version-pinning/README.md @@ -0,0 +1,28 @@ +# Check Version Pinning GitHub Action + +This Action scans your workflow files for untrusted GitHub Actions that are pinned to a version (`@v`) rather than a SHA hash. + +## Inputs + +### `workflow_directory` +The directory to scan for workflow files. Default is `.github/workflows`. + +## Outputs + +### `found_unpinned_actions` +A boolean indicating if any unpinned actions were found. + +## Example usage +```yaml +jobs: + check-version-pinning: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check for unpinned Actions + uses: your-github-username/check-version-pinning-action@v1 + with: + workflow_directory: ".github/workflows" + scan_mode: "pr_changes" # Use "full" for a full repo scan +``` +``` diff --git a/check-version-pinning/action.yaml b/check-version-pinning/action.yaml new file mode 100644 index 0000000..d3c8327 --- /dev/null +++ b/check-version-pinning/action.yaml @@ -0,0 +1,23 @@ +name: "Check Version Pinning" +description: "GitHub Action to check for untrusted GitHub Actions not pinned to a SHA hash." + +inputs: + workflow_directory: + description: "Directory to scan for GitHub workflow files." + required: false + default: ".github/workflows" + scan_mode: + description: "Mode to run the scan: 'full' (scan whole repo) or 'pr_changes' (scan only PR changes)." + required: false + default: "full" + +outputs: + found_unpinned_actions: + description: "Indicates if unpinned actions were found." + +runs: + using: "docker" + image: "Dockerfile" + args: + - ${{ inputs.workflow_directory }} + - ${{ inputs.scan_mode }} diff --git a/check-version-pinning/check_version_pinning.py b/check-version-pinning/check_version_pinning.py new file mode 100644 index 0000000..6d19d39 --- /dev/null +++ b/check-version-pinning/check_version_pinning.py @@ -0,0 +1,92 @@ +import json +import os +import sys + +import yaml + + +def find_workflow_files(workflow_directory): + for root, _, files in os.walk(workflow_directory): + for file in files: + if file.endswith(".yml") or file.endswith(".yaml"): + yield os.path.join(root, file) + + +def find_changed_files_in_pr(workflow_directory): + event_path = os.getenv("GITHUB_EVENT_PATH") + if not event_path: + print("Error: GITHUB_EVENT_PATH is not set.") + sys.exit(1) + + with open(event_path, "r") as f: + event_data = json.load(f) + + changed_files = [ + file["filename"] + for file in event_data.get("pull_request", {}).get("files", []) + if file["filename"].startswith(workflow_directory) + and (file["filename"].endswith(".yml") or file["filename"].endswith(".yaml")) + ] + + return changed_files + + +def parse_yaml_file(file_path): + with open(file_path, "r", encoding="utf-8") as f: + try: + return yaml.safe_load(f) + except yaml.YAMLError as e: + print(f"Error parsing {file_path}: {e}") + return None + + +def check_uses_field_in_workflow(workflows, file_path): + results = [] + if workflows: + for job in workflows.get("jobs", {}).values(): + for step in job.get("steps", []): + uses = step.get("uses", "") + if "@v" in uses and not ( + "actions/" in uses or "ministryofjustice" in uses + ): + results.append(f"{file_path}: {uses}") + return results + + +def check_version_pinning(workflow_directory=".github/workflows", scan_mode="full"): + all_results = [] + + if scan_mode == "full": + files_to_check = find_workflow_files(workflow_directory) + elif scan_mode == "pr_changes": + files_to_check = find_changed_files_in_pr(workflow_directory) + else: + print("Error: Invalid scan mode. Choose 'full' or 'pr_changes'.") + sys.exit(1) + + for file_path in files_to_check: + workflows = parse_yaml_file(file_path) + if workflows: + results = check_uses_field_in_workflow(workflows, file_path) + all_results.extend(results) + + if all_results: + print( + "The following third-party GitHub Actions are using version pinning rather than SHA hash pinning:\n" + ) + for result in all_results: + print(f" - {result}") + + print( + "\nPlease see the following documentation for more information:\n" + "https://tinyurl.com/3sev9etr" + ) + sys.exit(1) + else: + print("No workflows found with pinned versions (@v).") + + +if __name__ == "__main__": + workflow_directory = sys.argv[1] if len(sys.argv) > 1 else ".github/workflows" + scan_mode = sys.argv[2] if len(sys.argv) > 2 else "full" + check_version_pinning(workflow_directory, scan_mode) diff --git a/check-version-pinning/requirements.txt b/check-version-pinning/requirements.txt new file mode 100644 index 0000000..978e96a --- /dev/null +++ b/check-version-pinning/requirements.txt @@ -0,0 +1 @@ +pyyaml == 6.0.2 diff --git a/check-version-pinning/test_check_version_pinning.py b/check-version-pinning/test_check_version_pinning.py new file mode 100644 index 0000000..eed4a16 --- /dev/null +++ b/check-version-pinning/test_check_version_pinning.py @@ -0,0 +1,118 @@ +import unittest +from unittest.mock import mock_open, patch + +from bin.check_version_pinning import check_version_pinning + + +class TestCheckVersionPinning(unittest.TestCase): + + @patch("os.walk") + @patch("builtins.open", new_callable=mock_open) + @patch("yaml.safe_load") + def test_no_yaml_files(self, mock_yaml_load, mock_open_file, mock_os_walk): + # Simulate os.walk returning no .yml or .yaml files + _ = mock_open_file + mock_os_walk.return_value = [(".github/workflows", [], [])] + mock_yaml_load.return_value = None + + with patch("builtins.print") as mock_print: + check_version_pinning() + mock_print.assert_called_once_with( + "No workflows found with pinned versions (@v)." + ) + + @patch("os.walk") + @patch("builtins.open", new_callable=mock_open) + @patch("yaml.safe_load") + def test_yaml_file_without_uses(self, mock_yaml_load, mock_open_file, mock_os_walk): + _ = mock_open_file + mock_os_walk.return_value = [(".github/workflows", [], ["workflow.yml"])] + mock_yaml_load.return_value = { + "jobs": {"build": {"steps": [{"name": "Checkout code"}]}} + } + + with patch("builtins.print") as mock_print: + check_version_pinning() + mock_print.assert_called_once_with( + "No workflows found with pinned versions (@v)." + ) + + @patch("os.walk") + @patch("builtins.open", new_callable=mock_open) + @patch("yaml.safe_load") + def test_workflow_with_pinned_version( + self, mock_yaml_load, mock_open_file, mock_os_walk + ): + # Simulate a workflow file with a pinned version (@v) + _ = mock_open_file + mock_os_walk.return_value = [(".github/workflows", [], ["workflow.yml"])] + mock_yaml_load.return_value = { + "jobs": {"build": {"steps": [{"uses": "some-org/some-action@v1.0.0"}]}} + } + + with patch("builtins.print") as mock_print, self.assertRaises(SystemExit) as cm: + check_version_pinning() + mock_print.assert_any_call("Found workflows with pinned versions (@v):") + mock_print.assert_any_call( + ".github/workflows/workflow.yml: some-org/some-action@v1.0.0" + ) + self.assertEqual(cm.exception.code, 1) + + @patch("os.walk") + @patch("builtins.open", new_callable=mock_open) + @patch("yaml.safe_load") + def test_workflow_ignoring_actions( + self, mock_yaml_load, mock_open_file, mock_os_walk + ): + _ = mock_open_file + # Simulate a workflow file with an action to be ignored + mock_os_walk.return_value = [(".github/workflows", [], ["workflow.yml"])] + mock_yaml_load.return_value = { + "jobs": { + "build": { + "steps": [ + {"uses": "actions/setup-python@v2"}, + {"uses": "ministryofjustice/some-action@v1.0.0"}, + ] + } + } + } + + with patch("builtins.print") as mock_print: + check_version_pinning() + mock_print.assert_called_once_with( + "No workflows found with pinned versions (@v)." + ) + + @patch("os.walk") + @patch("builtins.open", new_callable=mock_open) + @patch("yaml.safe_load") + def test_workflow_with_mixed_versions( + self, mock_yaml_load, mock_open_file, mock_os_walk + ): + _ = mock_open_file + # Simulate a workflow with both ignored and non-ignored actions + mock_os_walk.return_value = [(".github/workflows", [], ["workflow.yml"])] + mock_yaml_load.return_value = { + "jobs": { + "build": { + "steps": [ + {"uses": "actions/setup-python@v2"}, + {"uses": "some-org/some-action@v1.0.0"}, + {"uses": "ministryofjustice/some-action@v1.0.0"}, + ] + } + } + } + + with patch("builtins.print") as mock_print, self.assertRaises(SystemExit) as cm: + check_version_pinning() + mock_print.assert_any_call("Found workflows with pinned versions (@v):") + mock_print.assert_any_call( + ".github/workflows/workflow.yml: some-org/some-action@v1.0.0" + ) + self.assertEqual(cm.exception.code, 1) + + +if __name__ == "__main__": + unittest.main() From 0fa8fa96e977cf9313d36dd7521aa2b96f3a9ff8 Mon Sep 17 00:00:00 2001 From: Jason Birchall <31217584+jasonBirchall@users.noreply.github.com> Date: Tue, 29 Oct 2024 10:55:20 +0000 Subject: [PATCH 2/6] :fire: Remove the additional code block close --- check-version-pinning/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/check-version-pinning/README.md b/check-version-pinning/README.md index d1b841c..0860776 100644 --- a/check-version-pinning/README.md +++ b/check-version-pinning/README.md @@ -25,4 +25,3 @@ jobs: workflow_directory: ".github/workflows" scan_mode: "pr_changes" # Use "full" for a full repo scan ``` -``` From 42ac92738214ffa7a1dd13201d3b3da85addfca6 Mon Sep 17 00:00:00 2001 From: Jason Birchall <31217584+jasonBirchall@users.noreply.github.com> Date: Tue, 29 Oct 2024 10:59:16 +0000 Subject: [PATCH 3/6] Update README.md --- check-version-pinning/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check-version-pinning/README.md b/check-version-pinning/README.md index 0860776..10917f5 100644 --- a/check-version-pinning/README.md +++ b/check-version-pinning/README.md @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Check for unpinned Actions - uses: your-github-username/check-version-pinning-action@v1 + uses: ministryofjustice/check-version-pinning-action@v1 with: workflow_directory: ".github/workflows" scan_mode: "pr_changes" # Use "full" for a full repo scan From 94b70884e045d363e5f53070077512e97166cb9f Mon Sep 17 00:00:00 2001 From: Jason Birchall <31217584+jasonBirchall@users.noreply.github.com> Date: Tue, 29 Oct 2024 11:01:19 +0000 Subject: [PATCH 4/6] Update README.md --- check-version-pinning/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/check-version-pinning/README.md b/check-version-pinning/README.md index 10917f5..b8f82d4 100644 --- a/check-version-pinning/README.md +++ b/check-version-pinning/README.md @@ -7,6 +7,11 @@ This Action scans your workflow files for untrusted GitHub Actions that are pinn ### `workflow_directory` The directory to scan for workflow files. Default is `.github/workflows`. +### `scan_mode` +The type of scan you wish to undertake: +- full = the whole repository. +- pr_changes = only changes in a pr. + ## Outputs ### `found_unpinned_actions` From 39418e440ff51174dff365af20ab432fce7e6ea2 Mon Sep 17 00:00:00 2001 From: jasonBirchall Date: Tue, 29 Oct 2024 12:36:09 +0000 Subject: [PATCH 5/6] :fire: Remove trusted organisations It has been agreed that we should use commit sha pinning on all of our actions regardless of trust. Please see the following conversation for more detail: https://mojdt.slack.com/archives/C05L0KBA7RS/p1730199255440819 --- .../check_version_pinning.py | 6 ++-- .../test_check_version_pinning.py | 28 +------------------ 2 files changed, 3 insertions(+), 31 deletions(-) diff --git a/check-version-pinning/check_version_pinning.py b/check-version-pinning/check_version_pinning.py index 6d19d39..bfcd17b 100644 --- a/check-version-pinning/check_version_pinning.py +++ b/check-version-pinning/check_version_pinning.py @@ -46,9 +46,7 @@ def check_uses_field_in_workflow(workflows, file_path): for job in workflows.get("jobs", {}).values(): for step in job.get("steps", []): uses = step.get("uses", "") - if "@v" in uses and not ( - "actions/" in uses or "ministryofjustice" in uses - ): + if "@v" in uses: results.append(f"{file_path}: {uses}") return results @@ -72,7 +70,7 @@ def check_version_pinning(workflow_directory=".github/workflows", scan_mode="ful if all_results: print( - "The following third-party GitHub Actions are using version pinning rather than SHA hash pinning:\n" + "The following GitHub Actions are using version pinning rather than SHA hash pinning:\n" ) for result in all_results: print(f" - {result}") diff --git a/check-version-pinning/test_check_version_pinning.py b/check-version-pinning/test_check_version_pinning.py index eed4a16..20a677a 100644 --- a/check-version-pinning/test_check_version_pinning.py +++ b/check-version-pinning/test_check_version_pinning.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import mock_open, patch -from bin.check_version_pinning import check_version_pinning +from check_version_pinning import check_version_pinning class TestCheckVersionPinning(unittest.TestCase): @@ -58,32 +58,6 @@ def test_workflow_with_pinned_version( ) self.assertEqual(cm.exception.code, 1) - @patch("os.walk") - @patch("builtins.open", new_callable=mock_open) - @patch("yaml.safe_load") - def test_workflow_ignoring_actions( - self, mock_yaml_load, mock_open_file, mock_os_walk - ): - _ = mock_open_file - # Simulate a workflow file with an action to be ignored - mock_os_walk.return_value = [(".github/workflows", [], ["workflow.yml"])] - mock_yaml_load.return_value = { - "jobs": { - "build": { - "steps": [ - {"uses": "actions/setup-python@v2"}, - {"uses": "ministryofjustice/some-action@v1.0.0"}, - ] - } - } - } - - with patch("builtins.print") as mock_print: - check_version_pinning() - mock_print.assert_called_once_with( - "No workflows found with pinned versions (@v)." - ) - @patch("os.walk") @patch("builtins.open", new_callable=mock_open) @patch("yaml.safe_load") From fa5449f460bef4ed1f0de9c92ddcaccab62b0c85 Mon Sep 17 00:00:00 2001 From: jasonBirchall Date: Tue, 29 Oct 2024 12:38:51 +0000 Subject: [PATCH 6/6] :memo: Use the sha commit hashing in documentation --- check-version-pinning/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/check-version-pinning/README.md b/check-version-pinning/README.md index b8f82d4..1626855 100644 --- a/check-version-pinning/README.md +++ b/check-version-pinning/README.md @@ -23,9 +23,9 @@ jobs: check-version-pinning: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Check for unpinned Actions - uses: ministryofjustice/check-version-pinning-action@v1 + uses: ministryofjustice/check-version-pinning-action@6b42224f41ee5dfe5395e27c8b2746f1f9955030 # v1.0.0 with: workflow_directory: ".github/workflows" scan_mode: "pr_changes" # Use "full" for a full repo scan