From 560c2de02670ef11fc6a5cccf40c416a963fc9d6 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:57:11 +0100 Subject: [PATCH] Automate dependency trust maintenance --- .github/dependabot.yml | 112 ++++++++++++++++ .github/workflows/security-scan.yml | 46 ++++--- .../cloudcp/portal/frontend/package-lock.json | 6 +- .../installtests/build_release_assets_test.go | 11 +- .../installtests/dependency_update_test.go | 16 +++ .../installtests/provider_msp_deploy_test.go | 9 +- scripts/tests/test_dependabot_config.py | 125 ++++++++++++++++++ 7 files changed, 298 insertions(+), 27 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 scripts/installtests/dependency_update_test.go create mode 100644 scripts/tests/test_dependabot_config.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..c633f55d0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,112 @@ +version: 2 + +# Keep the version-update review queue bounded. Minor and patch updates are +# grouped by ecosystem; major updates remain isolated so CI and review expose +# their risk. Security updates use separate ecosystem groups. +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + time: "04:00" + timezone: "Europe/London" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "github_actions" + groups: + actions-minor-patch: + applies-to: "version-updates" + patterns: + - "*" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "gomod" + directories: + - "/" + - "/tests/integration/mock-github-server" + schedule: + interval: "weekly" + day: "tuesday" + time: "04:10" + timezone: "Europe/London" + open-pull-requests-limit: 3 + labels: + - "dependencies" + groups: + go-minor-patch: + applies-to: "version-updates" + patterns: + - "*" + update-types: + - "minor" + - "patch" + go-security: + applies-to: "security-updates" + patterns: + - "*" + + - package-ecosystem: "npm" + directories: + - "/" + - "/frontend-modern" + - "/internal/cloudcp/portal/frontend" + - "/tests/integration" + schedule: + interval: "weekly" + day: "tuesday" + time: "04:20" + timezone: "Europe/London" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "javascript" + groups: + npm-minor-patch: + applies-to: "version-updates" + patterns: + - "*" + update-types: + - "minor" + - "patch" + npm-security: + applies-to: "security-updates" + patterns: + - "*" + + - package-ecosystem: "docker" + directories: + - "/" + - "/deploy/provider-msp" + schedule: + interval: "weekly" + day: "tuesday" + time: "04:30" + timezone: "Europe/London" + open-pull-requests-limit: 3 + labels: + - "dependencies" + groups: + shared-container-images: + group-by: "dependency-name" + # Dockerfiles intentionally track governed runtime/toolchain lines. Refresh + # immutable digests automatically, but keep tag upgrades in explicit work. + ignore: + - dependency-name: "node" + update-types: + - "version-update:semver-major" + - "version-update:semver-minor" + - "version-update:semver-patch" + - dependency-name: "golang" + update-types: + - "version-update:semver-major" + - "version-update:semver-minor" + - "version-update:semver-patch" + - dependency-name: "alpine" + update-types: + - "version-update:semver-major" + - "version-update:semver-minor" + - "version-update:semver-patch" diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 2c97c94be..bed76583d 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -2,9 +2,8 @@ name: Security Scan # Build and Test audits dependencies on every push, but a quiet week with no # pushes means no run — and newly disclosed vulnerabilities land against -# unchanged code. This schedule is the backstop: it re-scans the Go module -# (which has no push-time vulnerability gate at all) and the frontend -# dependency graph against the current advisory databases. A failed scheduled +# unchanged code. This schedule is the backstop: it re-scans every Go module +# and npm lockfile against the current advisory databases. A failed scheduled # run emails the repository owner. on: schedule: @@ -16,8 +15,14 @@ permissions: jobs: govulncheck: - name: Go Vulnerability Scan + name: Go Vulnerability Scan (${{ matrix.directory }}) runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + directory: + - '.' + - 'tests/integration/mock-github-server' steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -31,11 +36,24 @@ jobs: run: go install golang.org/x/vuln/cmd/govulncheck@v1.7.0 - name: Scan Go module for reachable vulnerabilities + working-directory: ${{ matrix.directory }} run: govulncheck ./... npm-audit: - name: Frontend Dependency Audit + name: npm Dependency Audit (${{ matrix.name }}) runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - name: root tooling + directory: '.' + - name: product frontend + directory: 'frontend-modern' + - name: account frontend + directory: 'internal/cloudcp/portal/frontend' + - name: integration tests + directory: 'tests/integration' steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -44,17 +62,11 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '20' - cache: npm - cache-dependency-path: frontend-modern/package-lock.json - - name: Install frontend dependencies - working-directory: frontend-modern - run: npm ci + - name: Audit complete dependency graph + working-directory: ${{ matrix.directory }} + run: npm audit --package-lock-only - - name: Audit complete frontend dependency graph - working-directory: frontend-modern - run: npm audit - - - name: Audit production frontend dependencies - working-directory: frontend-modern - run: npm audit --omit=dev + - name: Audit production dependencies + working-directory: ${{ matrix.directory }} + run: npm audit --package-lock-only --omit=dev diff --git a/internal/cloudcp/portal/frontend/package-lock.json b/internal/cloudcp/portal/frontend/package-lock.json index 4c9527731..871c3dcb9 100644 --- a/internal/cloudcp/portal/frontend/package-lock.json +++ b/internal/cloudcp/portal/frontend/package-lock.json @@ -1764,9 +1764,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 4a4c5e29e..09a4d7bc8 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -1443,11 +1443,11 @@ func TestDockerBuildUsesCanonicalReleaseLdflags(t *testing.T) { } dockerfile := string(dockerfileBytes) dockerRequired := []string{ - `FROM --platform=linux/amd64 node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend-builder`, - `FROM --platform=linux/amd64 golang:1.26.7-alpine@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS backend-builder`, + `FROM --platform=linux/amd64 node:20-alpine@sha256:`, + `FROM --platform=linux/amd64 golang:1.26.7-alpine@sha256:`, `FROM backend-builder AS release-assets-builder`, - `FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS agent_runtime`, - `FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS pulse-runtime-foundation`, + `AS agent_runtime`, + `AS pulse-runtime-foundation`, `FROM pulse-runtime-foundation AS pulse-runtime-base`, `FROM pulse-runtime-foundation AS prebuilt-runtime-base`, `FROM pulse-runtime-base AS hosted_runtime`, @@ -1478,6 +1478,9 @@ func TestDockerBuildUsesCanonicalReleaseLdflags(t *testing.T) { t.Fatalf("Dockerfile missing canonical release ldflags usage: %s", needle) } } + assertDigestPinnedDockerBase(t, dockerfile, `FROM --platform=linux/amd64 node:20-alpine@sha256:`) + assertDigestPinnedDockerBase(t, dockerfile, `FROM --platform=linux/amd64 golang:1.26.7-alpine@sha256:`) + assertDigestPinnedDockerBase(t, dockerfile, `FROM alpine:3.20@sha256:`) hostedStart := strings.Index(dockerfile, `FROM pulse-runtime-base AS hosted_runtime`) runtimeStart := strings.Index(dockerfile, `FROM pulse-runtime-base AS runtime`) if hostedStart == -1 || runtimeStart == -1 || hostedStart > runtimeStart { diff --git a/scripts/installtests/dependency_update_test.go b/scripts/installtests/dependency_update_test.go new file mode 100644 index 000000000..597c64ac6 --- /dev/null +++ b/scripts/installtests/dependency_update_test.go @@ -0,0 +1,16 @@ +package installtests + +import ( + "regexp" + "testing" +) + +// assertDigestPinnedDockerBase allows automated digest refreshes while still +// rejecting shortened, malformed, or mutable base-image references. +func assertDigestPinnedDockerBase(t *testing.T, dockerfile, prefix string) { + t.Helper() + pattern := regexp.MustCompile(regexp.QuoteMeta(prefix) + `[0-9a-f]{64}(?:\s|$)`) + if !pattern.MatchString(dockerfile) { + t.Fatalf("Dockerfile base image must use a full immutable digest: %s<64 lowercase hex characters>", prefix) + } +} diff --git a/scripts/installtests/provider_msp_deploy_test.go b/scripts/installtests/provider_msp_deploy_test.go index 1e198e6bd..96242d9ce 100644 --- a/scripts/installtests/provider_msp_deploy_test.go +++ b/scripts/installtests/provider_msp_deploy_test.go @@ -303,11 +303,11 @@ func TestProviderMSPControlPlaneDockerfileBuildsReleaseLicenseBinary(t *testing. text := string(dockerfileBytes) assertContainsAll(t, text, "# syntax=docker/dockerfile:1.7", - "FROM --platform=linux/amd64 node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend-builder", + "FROM --platform=linux/amd64 node:20-alpine@sha256:", "npm ci", "npm run build", - "FROM --platform=$BUILDPLATFORM golang:1.26.7-alpine@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder", - "FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc", + "FROM --platform=$BUILDPLATFORM golang:1.26.7-alpine@sha256:", + "FROM alpine:3.20@sha256:", "ARG PULSE_LICENSE_PUBLIC_KEY_SHA256", "ARG TARGETOS", "ARG TARGETARCH", @@ -332,6 +332,9 @@ func TestProviderMSPControlPlaneDockerfileBuildsReleaseLicenseBinary(t *testing. "FROM alpine:3.21", "CGO_ENABLED=0 go build -o /pulse-control-plane ./cmd/pulse-control-plane", ) + assertDigestPinnedDockerBase(t, text, `FROM --platform=linux/amd64 node:20-alpine@sha256:`) + assertDigestPinnedDockerBase(t, text, `FROM --platform=$BUILDPLATFORM golang:1.26.7-alpine@sha256:`) + assertDigestPinnedDockerBase(t, text, `FROM alpine:3.20@sha256:`) } func assertContainsAll(t *testing.T, text string, required ...string) { diff --git a/scripts/tests/test_dependabot_config.py b/scripts/tests/test_dependabot_config.py new file mode 100644 index 000000000..f3f112272 --- /dev/null +++ b/scripts/tests/test_dependabot_config.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Guard the low-noise dependency update policy and its covered manifests.""" + +from pathlib import Path +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] +CONFIG = ROOT / ".github" / "dependabot.yml" +SECURITY_SCAN = ROOT / ".github" / "workflows" / "security-scan.yml" + + +class DependabotConfigTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.config = yaml.safe_load(CONFIG.read_text(encoding="utf-8")) + cls.updates = { + entry["package-ecosystem"]: entry for entry in cls.config["updates"] + } + + def test_covers_every_shipped_dependency_ecosystem(self) -> None: + self.assertEqual( + set(self.updates), {"github-actions", "gomod", "npm", "docker"} + ) + self.assertEqual(self.updates["github-actions"]["directory"], "/") + self.assertEqual( + self.updates["gomod"]["directories"], + ["/", "/tests/integration/mock-github-server"], + ) + self.assertEqual( + self.updates["npm"]["directories"], + [ + "/", + "/frontend-modern", + "/internal/cloudcp/portal/frontend", + "/tests/integration", + ], + ) + self.assertEqual( + self.updates["docker"]["directories"], + ["/", "/deploy/provider-msp"], + ) + + def test_updates_are_weekly_staggered_and_bounded(self) -> None: + self.assertEqual(self.config["version"], 2) + times = [] + for entry in self.config["updates"]: + schedule = entry["schedule"] + self.assertEqual(schedule["interval"], "weekly") + self.assertEqual(schedule["day"], "tuesday") + self.assertEqual(schedule["timezone"], "Europe/London") + self.assertLessEqual(entry["open-pull-requests-limit"], 3) + self.assertIn("dependencies", entry["labels"]) + times.append(schedule["time"]) + self.assertEqual(len(times), len(set(times)), "update jobs must stay staggered") + + def test_language_updates_group_reviewable_changes(self) -> None: + for ecosystem, version_group, security_group in ( + ("gomod", "go-minor-patch", "go-security"), + ("npm", "npm-minor-patch", "npm-security"), + ): + groups = self.updates[ecosystem]["groups"] + self.assertEqual( + groups[version_group], + { + "applies-to": "version-updates", + "patterns": ["*"], + "update-types": ["minor", "patch"], + }, + ) + self.assertEqual( + groups[security_group], + {"applies-to": "security-updates", "patterns": ["*"]}, + ) + + def test_docker_updates_preserve_governed_tags(self) -> None: + docker = self.updates["docker"] + self.assertEqual( + docker["groups"]["shared-container-images"], + {"group-by": "dependency-name"}, + ) + ignored = { + item["dependency-name"]: set(item["update-types"]) + for item in docker["ignore"] + } + all_semver = { + "version-update:semver-major", + "version-update:semver-minor", + "version-update:semver-patch", + } + self.assertEqual(set(ignored), {"node", "golang", "alpine"}) + self.assertTrue(all(types == all_semver for types in ignored.values())) + + def test_weekly_scan_covers_the_same_lockfiles(self) -> None: + workflow = yaml.safe_load(SECURITY_SCAN.read_text(encoding="utf-8")) + jobs = workflow["jobs"] + self.assertEqual( + set(jobs["govulncheck"]["strategy"]["matrix"]["directory"]), + {".", "tests/integration/mock-github-server"}, + ) + npm_sets = jobs["npm-audit"]["strategy"]["matrix"]["include"] + self.assertEqual( + {item["directory"] for item in npm_sets}, + { + ".", + "frontend-modern", + "internal/cloudcp/portal/frontend", + "tests/integration", + }, + ) + scan_steps = jobs["npm-audit"]["steps"] + audit_commands = [step["run"] for step in scan_steps if "run" in step] + self.assertEqual( + audit_commands, + [ + "npm audit --package-lock-only", + "npm audit --package-lock-only --omit=dev", + ], + ) + + +if __name__ == "__main__": + unittest.main()