Skip to content
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
19 changes: 0 additions & 19 deletions .github/actions/trivy-iac/action.yaml

This file was deleted.

17 changes: 0 additions & 17 deletions .github/actions/trivy-package/action.yaml

This file was deleted.

22 changes: 0 additions & 22 deletions .github/actions/trivy/action.yaml

This file was deleted.

11 changes: 11 additions & 0 deletions .github/actions/validate-action-pins/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
name: "Validate action SHA pins"
description: "Verify that SHA-pinned GitHub Actions reference commits from their canonical repositories, protecting against supply chain attacks"
runs:
using: "composite"
steps:
- name: "Validate action SHA pins"
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
${{ github.action_path }}/validate-action-pins.sh
237 changes: 237 additions & 0 deletions .github/actions/validate-action-pins/validate-action-pins.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
set -euo pipefail

# WARNING: Please, DO NOT edit this file! It is maintained in the NHS Notify Shared Modules
# (https://github.com/NHSDigital/nhs-notify-shared-modules). Raise a PR instead.
#
# Version: 1.0
# Author: NHS Notify Platform Team
# Title: Validate Action SHA Pins
# Description: Verify that SHA-pinned GitHub Actions reference commits from their canonical
# repositories. Guards against supply chain attacks where a malicious actor
# substitutes a commit SHA from a forked repository rather than the canonical one.
#
# Usage:
# Locally:
# GH_TOKEN=$(gh auth token) .github/actions/validate-action-pins/validate-action-pins.sh
#
# In a GitHub Actions workflow:
# - name: "Validate action SHA pins"
# uses: NHSDigital/nhs-notify-shared-modules/.github/actions/validate-action-pins@<tag>
#
# Exit codes:
# 0 - All SHA pins verified, or no SHA pins found
# 1 - One or more SHA pins not found in their canonical repositories

API_BASE="https://api.github.com"

# Terminal colours
GREEN=$'\033[0;32m'
RED=$'\033[0;31m'
ORANGE=$'\033[0;33m'
RESET=$'\033[0m'

Failures=0
Skipped=0
Checked=0

declare -A VerifiedCache # Verified owner/repo@sha keys; avoids redundant API calls
declare -A RepoTagShaCache # Newline-separated tag commit-SHAs per repo; populated on first access
declare -a AllFiles # All .github workflow/action YAML files to scan

declare -a CURL_GH_HEADERS=(
-H "Accept: application/vnd.github.v3+json"
-H "X-GitHub-Api-Version: 2022-11-28"
)

fn_populate_repo_tags() {
# Fetches all release tag commit-SHAs for a repo and stores them in RepoTagShaCache.
# Tags are canonical-repo-scoped: fork commits never appear here, making this the
# reliable provenance check for SHAs that resolve across the fork network.
# input: LOCAL string owner_repo: The owner/repo to fetch tags for (e.g. actions/checkout)
# set: GLOBAL assoc RepoTagShaCache: Populated with newline-separated tag commit-SHAs

local owner_repo="$1"
local all_shas=""
local page=1
local tmp_file
local http_code
local page_shas

[[ -n "${RepoTagShaCache[$owner_repo]+_}" ]] && return

while [[ $page -le 5 ]]; do # Cap at 500 tags (5 pages x 100)
tmp_file=$(mktemp)

http_code=$(curl -s -o "$tmp_file" -w "%{http_code}" -L \
${GH_TOKEN:+-H "Authorization: Bearer ${GH_TOKEN}"} \
"${CURL_GH_HEADERS[@]}" \
--max-time 15 \
"${API_BASE}/repos/${owner_repo}/tags?per_page=100&page=${page}" || echo "000")

page_shas=$(grep -oE '"sha": "[0-9a-f]{40}"' "$tmp_file" 2>/dev/null \
| grep -oE '[0-9a-f]{40}' || true)

rm -f "$tmp_file"
[[ "$http_code" != "200" || -z "$page_shas" ]] && break
all_shas+=$'\n'"$page_shas"
page=$((page + 1))
done

RepoTagShaCache[$owner_repo]="$all_shas"
}

fn_verify_sha_in_canonical_repo() {
# Verifies a SHA-pinned action reference belongs to the canonical repository.
# Step 1 uses /commits to catch completely bogus SHAs (404/422).
# Step 2 uses /tags to distinguish canonical commits from fork-network commits —
# GitHub tags are canonical-repo-scoped so fork SHAs are never listed there.
# input: LOCAL string owner_repo: The owner/repo identifier (e.g. actions/checkout)
# input: LOCAL string sha: The full commit SHA to verify
# input: LOCAL string source_file: The workflow file containing the pin, for error context
# set: GLOBAL assoc VerifiedCache: Populated on successful verification
# set: GLOBAL int Failures: Incremented on verification failure
# set: GLOBAL int Skipped: Incremented when verification cannot be performed

local owner_repo="$1"
local sha="$2"
local source_file="$3"
local cache_key="${owner_repo}@${sha}"
local tmp_file
local http_code
local commit_author
local html_url

if [[ -n "${VerifiedCache[$cache_key]+_}" ]]; then
echo " ${GREEN}✓ ${owner_repo}@${sha} (cached)${RESET}"
return 0
fi

tmp_file=$(mktemp)
http_code=$(curl -s -o "$tmp_file" -w "%{http_code}" -L \
${GH_TOKEN:+-H "Authorization: Bearer ${GH_TOKEN}"} \
"${CURL_GH_HEADERS[@]}" \
--max-time 15 \
"${API_BASE}/repos/${owner_repo}/commits/${sha}" || echo "000")

commit_author=$(grep -oE '"login": "[^"]*"' "$tmp_file" 2>/dev/null | head -1 | cut -d'"' -f4 || true)

html_url=$(grep -oE '"html_url": "https://github\.com/[^"]+/commit/[^"]*"' "$tmp_file" 2>/dev/null | head -1 | cut -d'"' -f4 || true)

rm -f "$tmp_file"

case "$http_code" in
200)
fn_populate_repo_tags "$owner_repo"
if echo "${RepoTagShaCache[$owner_repo]}" | grep -qFx "$sha"; then
VerifiedCache[$cache_key]=1
echo " ${GREEN}✓ ${owner_repo}@${sha}${RESET}"
elif [[ -z "${RepoTagShaCache[$owner_repo]}" ]]; then
echo " ${ORANGE}⚠ SKIP: ${owner_repo}@${sha} — no tags found for repo, cannot verify provenance${RESET}"
echo " Source: ${source_file}"
Skipped=$((Skipped + 1))
else
echo " ${RED}✗ FAIL: ${owner_repo}@${sha}" >&2
echo " SHA not found in canonical repo's tags — possible supply chain attack" >&2
echo " Committed by: @${commit_author:-unknown} URL: ${html_url:-unknown}" >&2
echo " Fork commits resolve via the GitHub API but are absent from canonical tags" >&2
echo " Source: ${source_file}${RESET}" >&2
Failures=$((Failures + 1))
fi
;;
404 | 422)
echo " ${RED}✗ FAIL: ${owner_repo}@${sha}" >&2
echo " SHA not found in canonical repository — possible supply chain attack" >&2
echo " Source: ${source_file}${RESET}" >&2
Failures=$((Failures + 1))
;;
*)
echo " ${ORANGE}⚠ SKIP: ${owner_repo}@${sha} — HTTP ${http_code}, cannot verify${RESET}"
echo " Source: ${source_file}${RESET}"
Skipped=$((Skipped + 1))
;;
esac
}

fn_scan_file() {
# Scans a single YAML file for SHA-pinned action references and verifies each one.
# Extracts uses: lines where the ref is all-lowercase-hex (6–40 chars) — characteristic
# of a commit SHA pin rather than a version tag (e.g. v1.2.3) or branch name.
# Handles: owner/repo@sha, owner/repo/subpath@sha, "owner/repo@sha"
# Skips: ./local-action references, docker://... image references
# input: LOCAL string file: Path to the YAML file to scan
# set: GLOBAL int Checked: Incremented for each SHA pin found
# set: GLOBAL int Failures: Incremented per failed pin (via fn_verify_sha_in_canonical_repo)
# set: GLOBAL int Skipped: Incremented per skipped pin (via fn_verify_sha_in_canonical_repo)

local file="$1"
local ref_value
local repo_path
local owner_repo
local sha

while IFS= read -r ref_value; do
repo_path="${ref_value%%@*}"
sha="${ref_value##*@}"

[[ "$repo_path" == ./* ]] && continue
[[ "$repo_path" =~ ^docker: ]] && continue

owner_repo="$(echo "$repo_path" | cut -d'/' -f1,2)" # Strip subdir from owner/repo/subdir@sha

Checked=$((Checked + 1))
fn_verify_sha_in_canonical_repo "$owner_repo" "$sha" "$file"

done < <(
grep -hEo 'uses:[[:space:]]+"?[^[:space:]"#@]+@[0-9a-f]{6,40}' "$file" 2>/dev/null \
| sed 's/uses:[[:space:]]*"*//' \
| grep -vE '^\./' \
|| true
)
}

# ----------------------------------------------------------------------

while IFS= read -r -d $'\0' f; do
AllFiles+=("$f")
done < <(
find .github/workflows .github/actions \
-type f \( -name "*.yaml" -o -name "*.yml" \) \
-print0 2>/dev/null
)

if [[ ${#AllFiles[@]} -eq 0 ]]; then
echo "No .github/workflows or .github/actions YAML files found."
exit 0
fi

echo "Scanning ${#AllFiles[@]} file(s) for SHA-pinned actions..."
echo ""

for file in "${AllFiles[@]}"; do
echo "Checking: ${file}"
fn_scan_file "$file"
done

echo ""
echo "Scanned ${Checked} SHA pin(s) across ${#AllFiles[@]} file(s)."
echo ""

if [[ $Failures -gt 0 ]]; then
echo "${RED}ERROR: ${Failures} SHA pin(s) not found in their canonical repositories.${RESET}"
exit 1
fi

if [[ $Skipped -gt 0 && $Skipped -eq $Checked ]]; then
echo "${RED}ERROR: All ${Skipped} SHA pin(s) were skipped — no SHAs could be verified."
echo "Set GH_TOKEN to authenticate API requests (anonymous calls are rate-limited to 60/hour).${RESET}"
exit 1
fi

if [[ $Skipped -gt 0 ]]; then
echo "${ORANGE}WARNING: ${Skipped} SHA pin(s) could not be verified (skipped). $((Checked - Skipped)) verified.${RESET}"
else
echo "${GREEN}All ${Checked} SHA pin(s) verified successfully.${RESET}"
fi

exit 0
9 changes: 9 additions & 0 deletions .github/workflows/stage-1-commit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ jobs:
fetch-depth: 0 # Full history is needed to scan all commits
- name: "Scan secrets"
uses: ./.github/actions/scan-secrets
validate-action-pins:
name: "Validate action SHA pins"
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: "Checkout code"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "Validate action SHA pins"
uses: ./.github/actions/validate-action-pins
check-file-format:
name: "Check file format"
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ nodejs 22.15.1
pre-commit 3.6.0
pnpm 11.15.1
terraform 1.10.1
terraform-docs 0.19.0
terraform-docs 0.24.0
trivy 0.69.2
vale 3.6.0
python 3.13.5
Expand Down
6 changes: 3 additions & 3 deletions infrastructure/terraform/modules/amp_branch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
## Requirements

| Name | Version |
|------|---------|
| ---- | ------- |
| <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) | >= 1.9.0 |

## Inputs

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| ---- | ----------- | ---- | ------- | :------: |
| <a name="input_amplify_app_id"></a> [amplify\_app\_id](#input\_amplify\_app\_id) | Amplify application ID | `string` | n/a | yes |
| <a name="input_aws_account_id"></a> [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes |
| <a name="input_branch"></a> [branch](#input\_branch) | The name of the branch being deployed | `string` | n/a | yes |
Expand All @@ -34,7 +34,7 @@
## Outputs

| Name | Description |
|------|-------------|
| ---- | ----------- |
| <a name="output_name"></a> [name](#output\_name) | Name of the Amplify branch |

<!-- vale on -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
## Requirements

| Name | Version |
|------|---------|
| ---- | ------- |
| <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) | >= 1.9.0 |

## Inputs

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| ---- | ----------- | ---- | ------- | :------: |
| <a name="input_acct_s3_buckets"></a> [acct\_s3\_buckets](#input\_acct\_s3\_buckets) | Account S3 buckets | `map(any)` | n/a | yes |
| <a name="input_apim_auth_token_schedule"></a> [apim\_auth\_token\_schedule](#input\_apim\_auth\_token\_schedule) | Schedule to renew the APIM auth token | `string` | `"rate(9 minutes)"` | no |
| <a name="input_apim_auth_token_url"></a> [apim\_auth\_token\_url](#input\_apim\_auth\_token\_url) | URL to generate an APIM auth token | `string` | n/a | yes |
Expand Down Expand Up @@ -41,7 +41,7 @@
## Outputs

| Name | Description |
|------|-------------|
| ---- | ----------- |
| <a name="output_apim_access_token_ssm_parameter"></a> [apim\_access\_token\_ssm\_parameter](#output\_apim\_access\_token\_ssm\_parameter) | APIM Access Token SSM parameter details |

<!-- vale on -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ No requirements.
## Inputs

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| ---- | ----------- | ---- | ------- | :------: |
| <a name="input_backup_copy_vault_account_id"></a> [backup\_copy\_vault\_account\_id](#input\_backup\_copy\_vault\_account\_id) | The account id of the destination backup vault for allowing restores back into the source account. | `string` | `""` | no |
| <a name="input_backup_copy_vault_arn"></a> [backup\_copy\_vault\_arn](#input\_backup\_copy\_vault\_arn) | The ARN of the destination backup vault for cross-account backup copies. | `string` | `""` | no |
| <a name="input_backup_plan_config_dynamodb"></a> [backup\_plan\_config\_dynamodb](#input\_backup\_plan\_config\_dynamodb) | Configuration for backup plans with dynamodb | <pre>object({<br/> enable = bool<br/> selection_tag = string<br/> compliance_resource_types = list(string)<br/> rules = optional(list(object({<br/> name = string<br/> schedule = string<br/> enable_continuous_backup = optional(bool)<br/> lifecycle = object({<br/> delete_after = number<br/> cold_storage_after = optional(number)<br/> })<br/> copy_action = optional(object({<br/> delete_after = optional(number)<br/> }))<br/> })))<br/> })</pre> | <pre>{<br/> "compliance_resource_types": [<br/> "DynamoDB"<br/> ],<br/> "enable": false,<br/> "rules": [<br/> {<br/> "copy_action": {<br/> "delete_after": 365<br/> },<br/> "lifecycle": {<br/> "delete_after": 35<br/> },<br/> "name": "dynamodb_daily_kept_5_weeks",<br/> "schedule": "cron(0 0 * * ? *)"<br/> },<br/> {<br/> "copy_action": {<br/> "delete_after": 365<br/> },<br/> "lifecycle": {<br/> "delete_after": 90<br/> },<br/> "name": "dynamodb_weekly_kept_3_months",<br/> "schedule": "cron(0 1 ? * SUN *)"<br/> },<br/> {<br/> "copy_action": {<br/> "delete_after": 365<br/> },<br/> "lifecycle": {<br/> "cold_storage_after": 30,<br/> "delete_after": 2555<br/> },<br/> "name": "dynamodb_monthly_kept_7_years",<br/> "schedule": "cron(0 2 1 * ? *)"<br/> }<br/> ],<br/> "selection_tag": "BackupDynamoDB"<br/>}</pre> | no |
Expand Down
Loading
Loading