mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Automate dependency trust maintenance
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user