diff --git a/.github/manifest.yaml b/.github/manifest.yaml index 7da365e1..6622023b 100644 --- a/.github/manifest.yaml +++ b/.github/manifest.yaml @@ -9,6 +9,7 @@ ci: - go.mod - go.sum cli_version: v0.1.0 + pin_mode: sha manifest_file: .github/manifest.yaml manifest_key: ci validate: diff --git a/.github/workflows/hotfix.yaml b/.github/workflows/hotfix.yaml index 1ad71362..f678dd97 100644 --- a/.github/workflows/hotfix.yaml +++ b/.github/workflows/hotfix.yaml @@ -4,8 +4,11 @@ name: Hotfix # by trusted calling workflows, not by external users. All dynamic values are # passed through env variables where they are used in run commands. +# Least-privilege default: read-only at the top level. Only the finalize job, +# which commits the manifest, pushes the version tag, publishes the release, and +# merges back to trunk, is granted contents: write. permissions: - contents: write + contents: read on: workflow_call: @@ -58,6 +61,8 @@ jobs: validate: name: Validate Hotfix runs-on: ubuntu-latest + permissions: + contents: read outputs: sha: ${{ steps.validate.outputs.sha }} version: ${{ steps.version.outputs.final_version }} @@ -171,6 +176,8 @@ jobs: needs: validate if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest + permissions: + contents: read outputs: image_tag: ${{ steps.tag.outputs.image_tag }} strategy: @@ -219,6 +226,8 @@ jobs: needs: [validate, build] if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: deploy: ${{ fromJson(needs.validate.outputs.deploy_names) }} @@ -258,6 +267,8 @@ jobs: always() && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped') runs-on: ubuntu-latest + permissions: + contents: write outputs: deployed_sha: ${{ needs.validate.outputs.sha }} changelog: ${{ steps.changelog.outputs.changelog }} @@ -392,6 +403,8 @@ jobs: needs: validate if: inputs.dry_run runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Dry Run Summary env: diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index 61eba56a..d9feebca 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -40,7 +40,7 @@ jobs: changelog_base_sha: ${{ steps.setup.outputs.changelog_base_sha }} base_build_cli: ${{ steps.setup.outputs.base_build_cli }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Setup CLI @@ -82,7 +82,7 @@ jobs: cli_result: ${{ needs.build-cli.outputs.result }} validate_result: ${{ needs.validate.outputs.result }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Generate Summary diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml index e9022306..e82ca91d 100644 --- a/.github/workflows/promote.yaml +++ b/.github/workflows/promote.yaml @@ -89,7 +89,7 @@ jobs: can_proceed: ${{ steps.preflight.outputs.can_proceed }} promotion_result: ${{ steps.preflight.outputs.promotion_result }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Setup CLI @@ -126,7 +126,7 @@ jobs: if: ${{ github.event.inputs.dry_run != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup CLI uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 with: @@ -151,7 +151,7 @@ jobs: contents: write actions: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Setup CLI diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1fcfd9d4..aa3ecd40 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -22,8 +22,10 @@ on: # fire when releases are managed via API calls in a workflow run. workflow_dispatch: +# Least-privilege default: read-only at the top level. Only the release job, +# which cuts the GitHub Release and uploads built assets, is granted write. permissions: - contents: write + contents: read jobs: # Integration protection: because Integration now gates merge to main via branch @@ -34,6 +36,8 @@ jobs: test: name: Test runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -54,6 +58,8 @@ jobs: name: Release needs: test runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: diff --git a/Dockerfile b/Dockerfile index 734fa1d2..534aa2de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.25-alpine AS builder +FROM golang:1.25-alpine@sha256:523c3effe300580ed375e43f43b1c9b091b68e935a7c3a92bfcc4e7ed55b18c2 AS builder WORKDIR /build @@ -24,7 +24,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ ./cmd/cascade # Runtime stage - minimal image with git -FROM alpine:3.19 +FROM alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1 RUN apk add --no-cache git ca-certificates diff --git a/e2e/go.mod b/e2e/go.mod index 7c0311c6..ed05ff28 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -61,6 +61,6 @@ require ( go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect ) diff --git a/e2e/go.sum b/e2e/go.sum index 08e10b5b..ae57ba53 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -123,16 +123,16 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5w go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/config/fuzz_test.go b/internal/config/fuzz_test.go new file mode 100644 index 00000000..9f9ccd2e --- /dev/null +++ b/internal/config/fuzz_test.go @@ -0,0 +1,73 @@ +package config + +import "testing" + +// FuzzLoadManifest exercises the manifest parse-and-validate path with mutated +// bytes to assert the parser and validator never panic. Malformed input must +// surface as an error, never a crash. Errors are an acceptable outcome; a panic +// (or any other non-error abort) is a failure. +// +// Run the seed corpus as a normal test: go test -run FuzzLoadManifest ./internal/config/ +// Run active fuzzing locally: go test -fuzz=FuzzLoadManifest ./internal/config/ +func FuzzLoadManifest(f *testing.F) { + seeds := []string{ + // Minimal valid manifest. + `ci: + config: + trunk_branch: main + environments: [dev, prod] +`, + // Manifest exercising config-level fields, pins, and rollout. + `ci: + config: + trunk_branch: main + environments: [dev, staging, prod] + runs_on: ubuntu-latest + job_timeout_minutes: 30 + pin_mode: sha + action_pins: + actions/checkout: v4.2.2 + validate: + workflow: .github/workflows/validate.yaml + builds: + - name: cli + workflow: .github/workflows/build-cli.yaml + changelog: + contributors: true +`, + // Manifest carrying a state section alongside config. + `ci: + config: + trunk_branch: main + environments: [dev, prod] + state: + prerelease: + sha: a47eb32931502bf345db5c5eba67d17f7ef6b886 + version: v0.3.0-rc.1 +`, + // Empty body and a body missing the required key: both must error, not panic. + ``, + `not_ci: + config: + trunk_branch: main +`, + } + for _, s := range seeds { + f.Add([]byte(s)) + } + + f.Fuzz(func(t *testing.T, data []byte) { + // ParseManifestBytes is the entry point for a manifest read from a + // branch ref. A nil error implies a config we can hand to Validate. + file, err := ParseManifestBytes(data, DefaultManifestKey) + if err != nil { + return + } + if file == nil || file.Config == nil { + return + } + // Validate must tolerate any parsed config without panicking. The + // returned messages are not asserted; only the absence of a panic is. + _ = Validate(file.Config) + }) +}