-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ Check for correct action pinning (#285)
* ✨ 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
1 parent
cbe8510
commit 2f98b72
Showing
6 changed files
with
250 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }}"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
pyyaml == 6.0.2 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |