Skip to content

fix(ci): scope write tokens to jobs and pin actions and base images - #616

Merged
julianknutsen merged 3 commits into
mainfrom
fix/security-scorecard-hardening
Oct 25, 2026
Merged

julianknutsen merged 3 commits into
mainfrom
fix/security-scorecard-hardening

Conversation

@julianknutsen

@julianknutsen julianknutsen commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the actionable OpenSSF Scorecard code-scanning alerts in this repo. No GHSA/CVE IDs: these are Scorecard supply-chain hardening findings.

Alerts fixed

Test

scripts/supply-chain-pins.test.ts runs as part of test:unit. It fails if a workflow has a top-level write permission, a non-local action isn't pinned to a 40-hex SHA, or a Dockerfile base image has no @sha256: digest. Against origin/main it fails 6 tests, one per alert group above. With this change it passes.

Verified locally

  • bun run test:unit: 480 pass, 0 fail
  • bun run typecheck and bun run generate:check are clean
  • actionlint shows no new findings. The only output is the existing runner-label and SC2129 notes.
  • docker build of the full Dockerfile succeeds with the pinned digests, and the image runs Bun 1.4.4

Not in this PR (process/false-positive items, see the triage report): #7 CodeReview, #8 CII badge, #9 Fuzzing, and the CodeQL js/insufficient-password-hash alerts #1 and #10. Those two are false positives: they flag SHA-256 over 256-bit random API/session tokens and an HMAC over signed state.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Clears the OpenSSF Scorecard TokenPermissions and PinnedDependencies
code-scanning alerts:

- update-registry, promote, image and codeql now grant write scopes on
  the job that needs them; the workflow top level is read-only.
- image.yml pins checkout and the docker/* actions to full commit SHAs
  (same major versions as before).
- The Dockerfile pins golang:1.26.10-alpine and oven/bun 1.4.4 (the
  version oven/bun:1 resolves to today and the one CI tests) by digest,
  and Dependabot now tracks the docker ecosystem so the digests stay
  current.
- scripts/supply-chain-pins.test.ts (part of test:unit) fails on any
  top-level write permission, unpinned action or undigested base image.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@julianknutsen julianknutsen added the status/needs-review-auto PR review requested with auto approval label Oct 24, 2026
@gascity-eddie

gascity-eddie Bot commented Oct 24, 2026

Copy link
Copy Markdown

This PR has been added to the review queue.

Track progress: https://factory.gascity.com/city/maintainer-city/runs/gcg--9223372036837046432

@gascity-eddie gascity-eddie Bot added status/reviewing and removed status/needs-review-auto PR review requested with auto approval labels Oct 24, 2026
julianknutsen and others added 2 commits October 25, 2026 04:39
Brings in #615 (nanoid override ^3.3.20) so the branch is up to date
for the strict "CI / required" protection on main. No conflicts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…uard

The supply-chain guard only read step-level `uses:`, so an unpinned
reusable workflow (`jobs.<id>.uses: org/repo/.github/workflows/x.yml@main`)
passed it even though Scorecard's Pinned-Dependencies check flags one. It
also rejected two correctly pinned forms: a `docker://image@sha256:...`
action and `FROM scratch`.

Extract the detection into unpinnedActions and unpinnedBaseImages, cover
job-level `uses:`, accept a digest for docker:// actions, treat `scratch`
and earlier stage names (case-insensitively) as local, and pin the
detection behavior with fixture tests. Against main the guard still fails
exactly the six alert groups.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@julianknutsen

Copy link
Copy Markdown
Contributor Author

Max-effort review: approve and merge

Verdict: clean. This PR fixes all 15 targeted Scorecard alerts and introduces no new ones. Every job's effective token is unchanged, and all pins resolve to exactly what the floating refs gave yesterday, so CI and the shipped image behave identically. I pushed two commits (listed below) and found no blocking defects. All checks are green on the final head 557b311: verify, CI / required, image, and CodeQL for actions and JS/TS. CI ran 483 unit, 169 Postgres and 33 Playwright tests.

Alerts

Permissions, traced per job

Each of the four workflows has a single job, and a job-level permissions block replaces the workflow-level one. So the effective GITHUB_TOKEN of every job is identical to before. The token block in the update-registry run log already shows exactly these scopes.

Job Scope Used by
update-registry/update contents: write checkout, git push --force (built-in-token path), gh pr close --delete-branch, gh pr merge
pull-requests: write gh pr list/create/close/merge
actions: write gh workflow run ci.yml (built-in-token path), gh run list/watch
packages: read bun install of @gascity/* via NODE_AUTH_TOKEN
promote/promote contents: read gh api .../commits/main, .../commits/$SHA
actions: read gh api .../actions/workflows/{ci,image,production-smoke}.yml/runs
packages: write docker login ghcr.io and buildx imagetools create (re-tag)
image/image contents: read checkout, metadata-action repo lookup
packages: write login and push to GHCR; the BuildKit gh_token secret reads @gascity/*
codeql/analyze security-events: write CodeQL SARIF upload
contents: read, actions: read checkout; actions: read is only required on private repos (harmless)

Pins

  • Actions: checked with git ls-remote and GraphQL. Every SHA is a commit in the canonical repo and is the target of its claimed tag: checkout v4.4.0 11d5960, setup-buildx v3.12.0 8d2750c, login v3.7.0 c94ce9f, metadata v5.10.0 c299e40, build-push v6.19.2 10e90e3. Each is the latest release of its major version, and the floating v4/v3/v5/v6 tags point at the same commits today.
  • Images: each digest equals what its tag resolves to now, and each is an OCI image index (multi-arch, not a single-platform manifest):
    • golang:1.26.10-alpine covers amd64, arm64 and 6 more platforms. It equals golang:1.26-alpine.
    • oven/bun:1.4.4 and oven/bun:1.4.4-alpine cover amd64 and arm64. They are identical to what oven/bun:1, 1-alpine, latest and alpine resolve to today.
    • The images report Bun 1.4.4 and Go 1.26.10.
  • dependabot.yml: validates against the SchemaStore dependabot-2.0 schema.

Verification (local, Bun 1.4.4 taken from the pinned image)

  • I built both Docker variants from scratch (--pull --no-cache), standalone and apex (REGISTRY_WEB_BASE=/registry/). Every FROM resolved by digest, and the apex grep guard passed.
  • CI's image smoke passes on both, and both images boot against Postgres. On the standalone image, /api/me returns authConfigured: true, devAuthEnabled: false and store: "postgres", the same assertion Production Smoke makes.
  • I ran the full ci.yml verify sequence on main merged into this branch:
    • bun install --frozen-lockfile, bun audit (clean), generate:check, typecheck, build and openapi:check all pass.
    • test:unit: 480 pass, 483 after my commit.
    • Real-Postgres tier: 169 pass.
    • CLI e2e with the real gc from the built image: 1 pass.
    • Playwright: 33 pass.
  • The new guard fails exactly 6 tests against main's workflows and Dockerfile, one per alert group, and passes here.
  • actionlint reports the same 9 pre-existing findings (runner labels and SC2129) on main and here.
  • CodeQL on the PR merge ref (actions and JS/TS) has 0 results.

Commits I pushed (fast-forward)

  1. Merge origin/main: brings in chore(deps): raise the nanoid override to ^3.3.20 #615, which strict up-to-date protection requires. No conflicts.
  2. test(ci): cover reusable workflows and docker:// digests in the pin guard (Low):
    • The guard only read step-level uses:, so an unpinned reusable workflow (jobs.<id>.uses: org/repo/.github/workflows/x.yml@main) passed it, even though Scorecard flags one (checks/raw/pinned_dependencies.go:770).
    • It also rejected two forms that are correctly pinned: docker://image@sha256:… and FROM scratch.
    • The detection now lives in unpinnedActions and unpinnedBaseImages, with fixture tests. A mutation harness confirms the old catches still fire.

Follow-ups (not blocking)

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant