Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Check for correct action pinning #285

Merged
merged 6 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/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_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()