diff --git a/.github/actions/start-app/action.yml b/.github/actions/start-app/action.yml index aadff411..7d8a0e96 100644 --- a/.github/actions/start-app/action.yml +++ b/.github/actions/start-app/action.yml @@ -28,9 +28,9 @@ runs: using: composite steps: - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: '20' + node-version-file: '.node-version' cache: 'npm' cache-dependency-path: | package-lock.json @@ -101,7 +101,7 @@ runs: - name: Cache Playwright browsers id: playwright-cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 014a4c50..f1e8f880 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,8 @@ version: 2 updates: + # npm (root) - playwright/husky/commitlint + # Security advisories batch into their own PR (labeled "security") so they + # can be reviewed and merged ahead of the noisier weekly version-update PR. - package-ecosystem: "npm" directory: "/" schedule: @@ -9,6 +12,10 @@ updates: open-pull-requests-limit: 10 groups: all-npm-root: + applies-to: version-updates + patterns: ["*"] + all-npm-root-security: + applies-to: security-updates patterns: ["*"] - package-ecosystem: "npm" @@ -20,6 +27,10 @@ updates: open-pull-requests-limit: 10 groups: all-npm-backend: + applies-to: version-updates + patterns: ["*"] + all-npm-backend-security: + applies-to: security-updates patterns: ["*"] - package-ecosystem: "npm" @@ -31,21 +42,42 @@ updates: open-pull-requests-limit: 10 groups: all-npm-frontend: + applies-to: version-updates + patterns: ["*"] + all-npm-frontend-security: + applies-to: security-updates patterns: ["*"] + # Docker base images. No version-update group: keep base-image bumps as + # individual PRs so each digest change gets its own review. Security + # updates still batch into one PR for quick triage. - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" labels: - "dependencies" + groups: + docker-security: + applies-to: security-updates + patterns: ["*"] + # github-actions: scan both top-level workflows (.github/workflows/) and the + # local composite action (.github/actions/start-app/action.yml). Dependabot + # only walks each directory it is given, so the composite must be listed + # explicitly for its uses: pins to be tracked. - package-ecosystem: "github-actions" - directory: "/" + directories: + - "/" + - "/.github/actions/start-app" schedule: interval: "weekly" labels: - "ci" groups: all-actions: + applies-to: version-updates + patterns: ["*"] + all-actions-security: + applies-to: security-updates patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19f3e057..fb71eed5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: '20' + node-version-file: '.node-version' cache: 'npm' cache-dependency-path: backend/package-lock.json @@ -85,7 +85,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: '20' + node-version-file: '.node-version' cache: 'npm' cache-dependency-path: frontend/package-lock.json @@ -97,6 +97,10 @@ jobs: working-directory: ./frontend run: npm run build + - name: Unit Tests (Vitest) + working-directory: ./frontend + run: npm test + - name: Lint (ESLint) working-directory: ./frontend run: npm run lint @@ -153,7 +157,6 @@ jobs: exit-code: '1' severity: 'CRITICAL,HIGH' format: 'table' - trivy-version: 'latest' trivy-config: trivy.yaml # Mirror of the release-time pre-publish smoke gate in docker-publish.yml. diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index be21f529..0b97a79a 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -5,8 +5,13 @@ on: pull_request_target: types: [opened,closed,synchronize] +# Minimum scopes contributor-assistant/github-action needs to operate: +# contents: write - commit signatures to signatures/version1/cla.json +# pull-requests: write - comment on the PR with the CLA prompt +# statuses: write - set the commit status (success / pending) +# actions: write is intentionally NOT granted; this workflow does not manage +# other workflow runs. permissions: - actions: write contents: write pull-requests: write statuses: write @@ -16,7 +21,16 @@ jobs: runs-on: ubuntu-latest steps: - name: "CLA Assistant" - if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + # Only run on pull_request_target events, or on issue_comment events + # that are (a) on a PR (not a plain Issue) and (b) match one of the two + # documented CLA phrases. github.event.issue.pull_request is truthy + # only when the issue is a PR, so plain-Issue comments are ignored. + if: | + github.event_name == 'pull_request_target' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + (github.event.comment.body == 'recheck' || + github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA')) uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1a70f550..41f7d29f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,7 +7,10 @@ on: workflow_dispatch: concurrency: - group: docker-publish + # Ref-scoped so two different release tags (e.g. v0.86.4 and v0.86.5) cannot + # cancel each other. Reruns of the exact same ref still cancel the prior + # run for that ref, which is the cancel behavior we actually want. + group: docker-publish-${{ github.ref }} cancel-in-progress: true jobs: @@ -132,7 +135,6 @@ jobs: exit-code: '1' severity: 'CRITICAL,HIGH' format: 'table' - trivy-version: 'latest' trivy-config: trivy.yaml # Start the scanned image headless on the runner and poll /api/health diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 00000000..9f707107 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,104 @@ +name: Scheduled Security Scan + +# Visibility-only re-scan of the published image and a fresh build of main. +# Surfaces CVEs disclosed between releases by uploading SARIF to GitHub Code +# Scanning. The PR-blocking gate in ci.yml and the release-blocking gate in +# docker-publish.yml are unchanged; this workflow only writes findings. +on: + schedule: + - cron: '0 0 * * *' # Daily 00:00 UTC + workflow_dispatch: + +# Prevent overlap when a manual workflow_dispatch fires while the daily cron +# run is still in flight. cancel-in-progress is safe here: the workflow only +# uploads SARIF for visibility and has no side effects on the registry. +concurrency: + group: security-scan + cancel-in-progress: true + +permissions: + contents: read + +jobs: + scan-published: + name: Trivy SARIF (published :latest) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + security-events: write + steps: + - name: Checkout (trivy.yaml + VEX) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + # Same SHA-pinned action used by the PR-blocking and release-blocking + # scans. The trivy binary version is whatever this action SHA bundles; + # Dependabot's all-actions group moves it forward when the action is + # bumped, keeping a single source of truth. + - name: Trivy scan of saelix/sencho:latest + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: saelix/sencho:latest + format: sarif + output: trivy-published.sarif + severity: 'CRITICAL,HIGH,MEDIUM' + trivy-config: trivy.yaml + ignore-unfixed: false + + # if: always() so findings still upload when trivy itself failed mid-run + # (e.g. CVE DB pull timeout). The categorical separation from the + # scan-main job below keeps the two result sets distinct in the UI. + - name: Upload SARIF to code scanning + if: always() + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + with: + sarif_file: trivy-published.sarif + category: trivy-published-image + + scan-main: + name: Trivy SARIF (main HEAD build) + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + + # Mirrors the daily-cache-bust logic from ci.yml / docker-publish.yml so + # the apk upgrade layer rebuilds at least once per calendar day. + - name: Compute daily apk cache bust value + id: apk-bust + run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" + + - name: Build image from main HEAD + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 + with: + context: . + push: false + load: true + tags: localhost/sencho:scheduled-scan + cache-from: type=gha + build-args: | + APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }} + + - name: Trivy scan of fresh main build + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: localhost/sencho:scheduled-scan + format: sarif + output: trivy-main.sarif + severity: 'CRITICAL,HIGH,MEDIUM' + trivy-config: trivy.yaml + ignore-unfixed: false + + - name: Upload SARIF to code scanning + if: always() + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + with: + sarif_file: trivy-main.sarif + category: trivy-main-head diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..6f4247a6 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +26 diff --git a/Dockerfile b/Dockerfile index bb422138..5cbd73a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -99,17 +99,26 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \ # static binary. # # Runs on the BUILD platform; GOARCH cross-compiles the static binary for TARGET. -# The --depth 1 clone fetches only the v29.4.1 tag commit, minimising transfer size. +# The fetch pulls only the v29.4.1 commit, minimising transfer size. # docker/cli uses CalVer and ships vendor.mod instead of go.mod to avoid SemVer # compliance requirements. We copy vendor.mod -> go.mod and build with -mod=vendor # so all deps come from the vendored tree (no network access needed). -FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS cli-builder +# Base image pinned by digest so the Go toolchain that compiles the static +# Docker CLI binary cannot change without an explicit Dependabot bump. +FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine@sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d AS cli-builder ARG TARGETARCH RUN apk add --no-cache git -RUN git clone --depth 1 --branch v29.4.1 https://github.com/docker/cli.git /src/docker-cli +# Fetch by commit SHA rather than by mutable tag. The SHA below resolves to +# the docker/cli v29.4.1 release; recorded here for traceability since the +# raw SHA does not carry semantic information. +RUN git init /src/docker-cli && \ + cd /src/docker-cli && \ + git remote add origin https://github.com/docker/cli.git && \ + git fetch --depth=1 origin 407f3428e5c5a3a4088f9268bc7159f5e0f95bea && \ + git checkout FETCH_HEAD WORKDIR /src/docker-cli @@ -135,13 +144,22 @@ RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \ # v0.29.0. The go get step below bumps otel to v1.43.0 to resolve # CVE-2026-39883 (BSD kenv) and CVE-2026-39882 (OTLP response OOM) so that # the compose binary scans completely clean. -FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS compose-builder +# Base image pinned by digest (same image as cli-builder above) so both +# source builds share an identical, immutable Go toolchain. +FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine@sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d AS compose-builder ARG TARGETARCH RUN apk add --no-cache git -RUN git clone --depth 1 --branch v5.1.3 https://github.com/docker/compose.git /src/docker-compose +# Fetch by commit SHA. The SHA below resolves to the docker/compose v5.1.3 +# release; recorded here for traceability since the raw SHA does not carry +# semantic information. +RUN git init /src/docker-compose && \ + cd /src/docker-compose && \ + git remote add origin https://github.com/docker/compose.git && \ + git fetch --depth=1 origin 5b2badbda44f3410b2a6c58dff79def21fe8b13e && \ + git checkout FETCH_HEAD WORKDIR /src/docker-compose diff --git a/backend/package.json b/backend/package.json index b63b7b37..22fe2f57 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,9 @@ "author": "", "license": "SEE LICENSE IN LICENSE", "type": "commonjs", + "engines": { + "node": ">=26.0.0" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/bcrypt": "^6.0.0", diff --git a/frontend/package.json b/frontend/package.json index 39c8c894..063fd652 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,9 @@ "license": "SEE LICENSE IN LICENSE", "version": "0.0.0", "type": "module", + "engines": { + "node": ">=26.0.0" + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 554bd743..576aa82c 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -11,6 +11,15 @@ export default defineConfig({ }, test: { environment: 'jsdom', + // jsdom only instantiates window.localStorage / sessionStorage when the + // document has a real same-origin URL. The default about:blank origin is + // opaque and can leave both APIs undefined depending on the jsdom build, + // causing any test that touches localStorage in beforeEach to throw. + // Setting an explicit URL is the documented jsdom workaround and changes + // no test or app code. + environmentOptions: { + jsdom: { url: 'http://localhost' }, + }, globals: true, setupFiles: ['./src/__tests__/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], diff --git a/package.json b/package.json index a2365b6e..13403978 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "author": "Studio Saelix", "license": "SEE LICENSE IN LICENSE", "type": "commonjs", + "engines": { + "node": ">=26.0.0" + }, "bugs": { "url": "https://github.com/studio-saelix/sencho/issues" },