Skip to content

Commit

Permalink
✨ Check for correct action pinning (#285)
Browse files Browse the repository at this point in the history
* ✨ Check for untrusted action pinning

- 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

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

* 📝 Use the sha commit hashing in documentation
  • Loading branch information
jasonBirchall authored Oct 29, 2024
1 parent cbe8510 commit 2f98b72
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 0 deletions.
12 changes: 12 additions & 0 deletions check-version-pinning/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 }}"]
32 changes: 32 additions & 0 deletions check-version-pinning/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 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`.

### `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`
A boolean indicating if any unpinned actions were found.

## Example usage
```yaml
jobs:
check-version-pinning:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for unpinned Actions
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
```
23 changes: 23 additions & 0 deletions check-version-pinning/action.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
90 changes: 90 additions & 0 deletions check-version-pinning/check_version_pinning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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:
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 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)
1 change: 1 addition & 0 deletions check-version-pinning/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pyyaml == 6.0.2
92 changes: 92 additions & 0 deletions check-version-pinning/test_check_version_pinning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import unittest
from unittest.mock import mock_open, patch

from 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/[email protected]"}]}}
}

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/[email protected]"
)
self.assertEqual(cm.exception.code, 1)

@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/[email protected]"},
{"uses": "ministryofjustice/[email protected]"},
]
}
}
}

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/[email protected]"
)
self.assertEqual(cm.exception.code, 1)


if __name__ == "__main__":
unittest.main()

0 comments on commit 2f98b72

Please sign in to comment.