diff --git a/.github/actions/pnpm-setup/action.yml b/.github/actions/pnpm-setup/action.yml new file mode 100644 index 00000000..7de5ec9a --- /dev/null +++ b/.github/actions/pnpm-setup/action.yml @@ -0,0 +1,49 @@ +name: Setup Node + pnpm +description: >- + Composite action that installs pnpm, sets up Node with the pnpm store cached, + and runs `pnpm install --frozen-lockfile` at the repo root. Used across CI, + CD, and security workflows to keep setup logic in one place. + +inputs: + node-version: + description: Node.js version + required: false + default: "22" + install: + description: Whether to run `pnpm install --frozen-lockfile` after setup + required: false + default: "true" + ignore-scripts: + description: Pass --ignore-scripts to pnpm install (faster, skips postinstalls) + required: false + default: "false" + +runs: + using: composite + steps: + - name: Install pnpm + uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ inputs.node-version }} + cache: 'pnpm' + + - name: Install dependencies + if: inputs.install == 'true' + shell: bash + # SECURITY: pass inputs through env rather than interpolating them + # into the shell body. Direct `${{ inputs.* }}` interpolation is a + # GHA command-injection vector even for composite actions (a caller + # workflow could pass a crafted value); env-var indirection is the + # standard mitigation. + env: + IGNORE_SCRIPTS: ${{ inputs.ignore-scripts }} + run: | + set -euo pipefail + if [ "${IGNORE_SCRIPTS:-false}" = "true" ]; then + pnpm install --frozen-lockfile --ignore-scripts + else + pnpm install --frozen-lockfile + fi diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000..de83e82f --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,23 @@ +name: "Enclosed CodeQL config" + +# Use extended query suites for deeper coverage than the default +queries: + - uses: security-extended + - uses: security-and-quality + +paths: + - packages + +paths-ignore: + - "**/node_modules" + - "**/dist" + - "**/dist-*" + - "**/.output" + - "**/.nuxt" + - "**/.nitro" + - "**/.wrangler" + - "**/coverage" + - "**/*.test.ts" + - "**/*.spec.ts" + - "**/*.e2e.test.ts" + - "packages/docs" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..4453eb7b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,104 @@ +version: 2 +updates: + # Keep GitHub Actions pinned and up-to-date. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "chore(deps)" + include: "scope" + groups: + actions-minor-patch: + update-types: + - "minor" + - "patch" + + # Docker base images (node:22-slim / node:22-alpine). + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "docker" + commit-message: + prefix: "chore(docker)" + include: "scope" + + # npm security-only updates across every workspace package. + # Renovate handles day-to-day version bumps; Dependabot's role here is to + # raise CVE fixes quickly (daily cadence) via GitHub's native security + # advisory integration. open-pull-requests-limit: 0 disables version PRs, + # so only vulnerability-driven PRs are created, avoiding overlap with + # Renovate. + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 0 + labels: + - "dependencies" + - "security" + commit-message: + prefix: "chore(deps)" + include: "scope" + + - package-ecosystem: "npm" + directory: "/packages/app-client" + schedule: + interval: "daily" + open-pull-requests-limit: 0 + labels: + - "dependencies" + - "security" + + - package-ecosystem: "npm" + directory: "/packages/app-server" + schedule: + interval: "daily" + open-pull-requests-limit: 0 + labels: + - "dependencies" + - "security" + + - package-ecosystem: "npm" + directory: "/packages/cli" + schedule: + interval: "daily" + open-pull-requests-limit: 0 + labels: + - "dependencies" + - "security" + + - package-ecosystem: "npm" + directory: "/packages/crypto" + schedule: + interval: "daily" + open-pull-requests-limit: 0 + labels: + - "dependencies" + - "security" + + - package-ecosystem: "npm" + directory: "/packages/lib" + schedule: + interval: "daily" + open-pull-requests-limit: 0 + labels: + - "dependencies" + - "security" + + - package-ecosystem: "npm" + directory: "/packages/docs" + schedule: + interval: "daily" + open-pull-requests-limit: 0 + labels: + - "dependencies" + - "security" diff --git a/.github/workflows/cd-app-prod.yaml b/.github/workflows/cd-app-prod.yaml index 79ee3427..212b7f33 100644 --- a/.github/workflows/cd-app-prod.yaml +++ b/.github/workflows/cd-app-prod.yaml @@ -5,6 +5,14 @@ on: branches: - main +permissions: + contents: read + +concurrency: + # Never cancel production deploys mid-flight; serialize them instead. + group: cd-app-prod + cancel-in-progress: false + jobs: publish-app-prod: runs-on: ubuntu-latest diff --git a/.github/workflows/cd-docker-release.yaml b/.github/workflows/cd-docker-release.yaml index cf25462b..5923682d 100644 --- a/.github/workflows/cd-docker-release.yaml +++ b/.github/workflows/cd-docker-release.yaml @@ -35,6 +35,11 @@ permissions: contents: read packages: write +concurrency: + # Serialize releases — never cancel a publish in-flight. + group: cd-docker-release-${{ github.ref }} + cancel-in-progress: false + jobs: publish-crypto: name: Publish @enclosed/crypto to npm diff --git a/.github/workflows/cd-preview-build.yaml b/.github/workflows/cd-preview-build.yaml index 97fa8f98..496ca74c 100644 --- a/.github/workflows/cd-preview-build.yaml +++ b/.github/workflows/cd-preview-build.yaml @@ -5,6 +5,13 @@ on: pull_request: types: [opened, synchronize] +permissions: + contents: read + +concurrency: + group: cd-preview-build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build-app-preview: runs-on: ubuntu-latest diff --git a/.github/workflows/cd-preview-deploy.yaml b/.github/workflows/cd-preview-deploy.yaml index 6287526b..6675e1e1 100644 --- a/.github/workflows/cd-preview-deploy.yaml +++ b/.github/workflows/cd-preview-deploy.yaml @@ -12,6 +12,10 @@ permissions: contents: read pull-requests: write +concurrency: + group: cd-preview-deploy-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + jobs: deploy-app-preview: runs-on: ubuntu-latest diff --git a/.github/workflows/ci-app-client.yaml b/.github/workflows/ci-app-client.yaml index 4bb40568..c82b4be5 100644 --- a/.github/workflows/ci-app-client.yaml +++ b/.github/workflows/ci-app-client.yaml @@ -2,9 +2,26 @@ name: CI - App Client on: pull_request: + paths: + - 'packages/app-client/**' + - 'packages/lib/**' + - 'packages/crypto/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.github/workflows/ci-app-client.yaml' + - '.github/actions/pnpm-setup/**' push: branches: - main + - twn-main + +permissions: + contents: read + +concurrency: + group: ci-app-client-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci-app-client: @@ -16,18 +33,11 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 with: - node-version: 22 - cache: 'pnpm' + persist-credentials: false - - name: Install dependencies - run: pnpm i - working-directory: ./ + - name: Setup Node + pnpm + uses: ./.github/actions/pnpm-setup - name: Run linters run: pnpm lint diff --git a/.github/workflows/ci-app-server.yaml b/.github/workflows/ci-app-server.yaml index 31f3f00c..30c7fd32 100644 --- a/.github/workflows/ci-app-server.yaml +++ b/.github/workflows/ci-app-server.yaml @@ -2,9 +2,26 @@ name: CI - App Server on: pull_request: + paths: + - 'packages/app-server/**' + - 'packages/lib/**' + - 'packages/crypto/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.github/workflows/ci-app-server.yaml' + - '.github/actions/pnpm-setup/**' push: branches: - main + - twn-main + +permissions: + contents: read + +concurrency: + group: ci-app-server-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci-app-server: @@ -16,18 +33,11 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 with: - node-version: 22 - cache: 'pnpm' + persist-credentials: false - - name: Install dependencies - run: pnpm i - working-directory: ./ + - name: Setup Node + pnpm + uses: ./.github/actions/pnpm-setup - name: Run linters run: pnpm lint diff --git a/.github/workflows/ci-cli.yaml b/.github/workflows/ci-cli.yaml index 3e2e4855..6713f89b 100644 --- a/.github/workflows/ci-cli.yaml +++ b/.github/workflows/ci-cli.yaml @@ -2,9 +2,26 @@ name: CI - Cli on: pull_request: + paths: + - 'packages/cli/**' + - 'packages/lib/**' + - 'packages/crypto/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.github/workflows/ci-cli.yaml' + - '.github/actions/pnpm-setup/**' push: branches: - main + - twn-main + +permissions: + contents: read + +concurrency: + group: ci-cli-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci-cli: @@ -16,18 +33,11 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 with: - node-version: 22 - cache: 'pnpm' + persist-credentials: false - - name: Install dependencies - run: pnpm i - working-directory: ./ + - name: Setup Node + pnpm + uses: ./.github/actions/pnpm-setup - name: Run linters run: pnpm lint diff --git a/.github/workflows/ci-crypto.yaml b/.github/workflows/ci-crypto.yaml index 082344ee..298d6e37 100644 --- a/.github/workflows/ci-crypto.yaml +++ b/.github/workflows/ci-crypto.yaml @@ -2,9 +2,24 @@ name: CI - Crypto on: pull_request: + paths: + - 'packages/crypto/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.github/workflows/ci-crypto.yaml' + - '.github/actions/pnpm-setup/**' push: branches: - main + - twn-main + +permissions: + contents: read + +concurrency: + group: ci-crypto-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci-lib: @@ -16,18 +31,11 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 with: - node-version: 22 - cache: 'pnpm' + persist-credentials: false - - name: Install dependencies - run: pnpm i - working-directory: ./ + - name: Setup Node + pnpm + uses: ./.github/actions/pnpm-setup - name: Run linters run: pnpm lint diff --git a/.github/workflows/ci-deploy-cloudflare.yaml b/.github/workflows/ci-deploy-cloudflare.yaml index 21ccff3a..fb74325c 100644 --- a/.github/workflows/ci-deploy-cloudflare.yaml +++ b/.github/workflows/ci-deploy-cloudflare.yaml @@ -2,9 +2,24 @@ name: CI - Deploy Cloudflare on: pull_request: + paths: + - 'packages/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.github/workflows/ci-deploy-cloudflare.yaml' + - '.github/actions/pnpm-setup/**' push: branches: - main + - twn-main + +permissions: + contents: read + +concurrency: + group: ci-deploy-cloudflare-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci-deploy-cloudflare: @@ -16,18 +31,11 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 with: - node-version: 22 - cache: 'pnpm' + persist-credentials: false - - name: Install dependencies - run: pnpm i - working-directory: ./ + - name: Setup Node + pnpm + uses: ./.github/actions/pnpm-setup - name: Build the app run: pnpm build diff --git a/.github/workflows/ci-docs.yaml b/.github/workflows/ci-docs.yaml index 7c170fc2..974dc817 100644 --- a/.github/workflows/ci-docs.yaml +++ b/.github/workflows/ci-docs.yaml @@ -2,9 +2,24 @@ name: CI - Docs on: pull_request: + paths: + - 'packages/docs/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.github/workflows/ci-docs.yaml' + - '.github/actions/pnpm-setup/**' push: branches: - main + - twn-main + +permissions: + contents: read + +concurrency: + group: ci-docs-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci-docs: @@ -16,18 +31,11 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 with: - node-version: 22 - cache: 'pnpm' + persist-credentials: false - - name: Install dependencies - run: pnpm i - working-directory: ./ + - name: Setup Node + pnpm + uses: ./.github/actions/pnpm-setup - name: Run linters run: pnpm lint diff --git a/.github/workflows/ci-lib.yaml b/.github/workflows/ci-lib.yaml index 7d11103a..87cddd6f 100644 --- a/.github/workflows/ci-lib.yaml +++ b/.github/workflows/ci-lib.yaml @@ -2,9 +2,25 @@ name: CI - Lib on: pull_request: + paths: + - 'packages/lib/**' + - 'packages/crypto/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.github/workflows/ci-lib.yaml' + - '.github/actions/pnpm-setup/**' push: branches: - main + - twn-main + +permissions: + contents: read + +concurrency: + group: ci-lib-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci-lib: @@ -16,18 +32,11 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 with: - node-version: 22 - cache: 'pnpm' + persist-credentials: false - - name: Install dependencies - run: pnpm i - working-directory: ./ + - name: Setup Node + pnpm + uses: ./.github/actions/pnpm-setup - name: Run linters run: pnpm lint diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index 70698676..2621ab9a 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -2,9 +2,27 @@ name: CI - App Client E2E on: pull_request: + paths: + - 'packages/app-client/**' + - 'packages/app-server/**' + - 'packages/lib/**' + - 'packages/crypto/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.github/workflows/ci-test-e2e.yml' + - '.github/actions/pnpm-setup/**' push: branches: - main + - twn-main + +permissions: + contents: read + +concurrency: + group: ci-e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: run-e2es: @@ -16,23 +34,16 @@ jobs: working-directory: packages/app-client steps: - - uses: actions/checkout@v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: - node-version: 22 - cache: 'pnpm' + persist-credentials: false + + - name: Setup Node + pnpm + uses: ./.github/actions/pnpm-setup - name: Get Playwright version id: playwright-version run: echo "PLAYWRIGHT_VERSION=$(pnpm list --json | jq -r .[0].devDependencies.\"@playwright/test\".version)" >> "$GITHUB_OUTPUT" - - - name: Install dependencies - run: pnpm i - working-directory: ./ - name: Build iso-prod app run: | diff --git a/.github/workflows/code-coverage.yaml b/.github/workflows/code-coverage.yaml new file mode 100644 index 00000000..58ce798a --- /dev/null +++ b/.github/workflows/code-coverage.yaml @@ -0,0 +1,81 @@ +name: CI - Code Coverage + +on: + pull_request: + branches: [main, twn-main] + push: + branches: [main, twn-main] + +permissions: + contents: read + +concurrency: + group: code-coverage-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + coverage: + name: Coverage (${{ matrix.pkg.name }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + # Packages with @vitest/coverage-v8 installed. app-client lacks the + # coverage provider (Playwright is used for e2e there) so is excluded. + # `slug` must be filesystem-safe for use in artifact names - no `@` + # or `/` chars, which actions/upload-artifact@v4 rejects. + pkg: + - { name: "@enclosed/crypto", slug: "crypto", dir: "packages/crypto" } + - { name: "@enclosed/lib", slug: "lib", dir: "packages/lib" } + - { name: "@enclosed/app-server", slug: "app-server", dir: "packages/app-server" } + - { name: "@enclosed/cli", slug: "cli", dir: "packages/cli" } + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: Setup Node + pnpm + uses: ./.github/actions/pnpm-setup + + - name: Run tests with coverage + continue-on-error: true + working-directory: ${{ matrix.pkg.dir }} + run: | + pnpm exec vitest run \ + --coverage \ + --coverage.reporter=text \ + --coverage.reporter=lcov \ + --coverage.reporter=json-summary + + - name: Upload coverage artifact + if: always() + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.pkg.slug }} + path: ${{ matrix.pkg.dir }}/coverage + if-no-files-found: ignore + retention-days: 14 + + - name: Upload coverage to Codecov + # Soft-fail: a missing CODECOV_TOKEN or a transient Codecov outage + # should not break CI - the coverage artifact above is always + # uploaded regardless. + if: always() && hashFiles(format('{0}/coverage/lcov.info', matrix.pkg.dir)) != '' + continue-on-error: true + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ matrix.pkg.dir }}/coverage/lcov.info + # Codecov flags must match ^[\w\.\-]+$ - use the slug, not the + # @scoped package name. + flags: ${{ matrix.pkg.slug }} + name: ${{ matrix.pkg.slug }} + fail_ci_if_error: false + verbose: true diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 00000000..b3d28ad5 --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,48 @@ +name: Security - CodeQL + +on: + push: + branches: [main, twn-main] + pull_request: + branches: [main, twn-main] + schedule: + # Weekly re-scan so advisories published after a merge still surface + - cron: '17 4 * * 1' + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + analyze: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + security-events: write + actions: read + + strategy: + fail-fast: false + matrix: + language: [javascript-typescript] + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + config-file: ./.github/codeql/codeql-config.yml + queries: security-extended,security-and-quality + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dast-zap.yaml b/.github/workflows/dast-zap.yaml new file mode 100644 index 00000000..e5d044f1 --- /dev/null +++ b/.github/workflows/dast-zap.yaml @@ -0,0 +1,129 @@ +name: Security - DAST (OWASP ZAP) + +on: + schedule: + # Nightly against the running app built from main + - cron: '27 3 * * *' + workflow_dispatch: + inputs: + target_url: + description: 'Override target URL (defaults to local ephemeral container)' + required: false + default: '' + +permissions: + contents: read + +concurrency: + group: dast-zap + cancel-in-progress: false + +jobs: + zap-baseline: + name: OWASP ZAP baseline scan + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + issues: write + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build enclosed image + if: ${{ github.event.inputs.target_url == '' }} + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + load: true + tags: enclosed:dast + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Start enclosed container + if: ${{ github.event.inputs.target_url == '' }} + run: | + docker run -d --name enclosed-dast -p 8787:8787 enclosed:dast + # Wait for readiness (up to 60s) + for i in $(seq 1 30); do + if curl -fsS http://localhost:8787 >/dev/null 2>&1; then + echo "enclosed is up" + exit 0 + fi + sleep 2 + done + echo "enclosed failed to start" >&2 + docker logs enclosed-dast || true + exit 1 + + - name: Resolve target URL + id: target + # SECURITY: pass the workflow_dispatch input through an env var rather + # than interpolating it directly into the shell script body. Direct + # `${{ github.event.inputs.* }}` interpolation in `run:` is a + # classic GHA command-injection vector (a user with write access + # could execute arbitrary shell on the runner by passing a + # malicious URL). Also validate the override strictly to prevent + # the ZAP scanner from becoming an SSRF primitive against cloud + # metadata endpoints or the runner's local network. + env: + OVERRIDE_URL: ${{ github.event.inputs.target_url }} + run: | + set -euo pipefail + if [ -z "${OVERRIDE_URL:-}" ]; then + printf 'url=%s\n' 'http://localhost:8787' >> "$GITHUB_OUTPUT" + exit 0 + fi + # 1. Shape check - require plain http(s) URL, no backticks/$/;/\|/&/ /'/"/\r/\n. + if ! printf '%s' "$OVERRIDE_URL" | grep -Eq '^https?://[A-Za-z0-9._-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~:/?#@!$&()*+,;=%[-]*)?$'; then + echo "::error::Rejected target_url: does not match allowed URL shape." >&2 + exit 1 + fi + # 2. Host allowlist - deny cloud metadata, link-local, RFC1918 leakage + # (except explicit localhost), and unqualified hostnames. + host=$(printf '%s' "$OVERRIDE_URL" | sed -E 's#^https?://([^/:]+).*#\1#') + case "$host" in + 169.254.*|metadata.google.internal|metadata.azure.com|metadata.aws.amazon.com|100.100.100.200) + echo "::error::Rejected target_url: cloud metadata / link-local host." >&2 + exit 1 + ;; + 10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.2[0-9].*|172.3[0-1].*|192.168.*) + echo "::error::Rejected target_url: RFC1918 private IP." >&2 + exit 1 + ;; + localhost|127.0.0.1|127.*) + # Loopback explicitly allowed - the default case is localhost:8787. + ;; + *.*) + # Public FQDN - allowed. + ;; + *) + echo "::error::Rejected target_url: unqualified host." >&2 + exit 1 + ;; + esac + printf 'url=%s\n' "$OVERRIDE_URL" >> "$GITHUB_OUTPUT" + + - name: OWASP ZAP baseline scan + uses: zaproxy/action-baseline@7c4deb10e6261301961c86d65d54a516394f9aed # v0.14.0 + with: + target: ${{ steps.target.outputs.url }} + rules_file_name: .zap/rules.tsv + cmd_options: '-a -j -T 10' + fail_action: false + allow_issue_writing: true + + - name: Stop enclosed container + if: always() && github.event.inputs.target_url == '' + run: | + set -eu + docker logs enclosed-dast || true + docker rm -f enclosed-dast || true diff --git a/.github/workflows/dependency-review.yaml b/.github/workflows/dependency-review.yaml new file mode 100644 index 00000000..ad521180 --- /dev/null +++ b/.github/workflows/dependency-review.yaml @@ -0,0 +1,40 @@ +name: Security - Dependency Review + +on: + pull_request: + branches: [main, twn-main] + +permissions: + contents: read + +concurrency: + group: dependency-review-${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + # dependency-review-action requires GitHub Advanced Security on private + # repos. Allow the step to soft-fail so the PR isn't blocked on + # infrastructure availability; findings (when accessible) are still + # reported as a PR comment and fail the check, but merge remains + # possible. + - name: Dependency Review + uses: actions/dependency-review-action@v4 + continue-on-error: true + with: + fail-on-severity: high + comment-summary-in-pr: on-failure + deny-licenses: AGPL-1.0-only,AGPL-1.0-or-later,AGPL-3.0-only,AGPL-3.0-or-later,GPL-2.0-only,GPL-2.0-or-later,GPL-3.0-only,GPL-3.0-or-later,LGPL-2.0-only,LGPL-2.0-or-later,LGPL-2.1-only,LGPL-2.1-or-later,LGPL-3.0-only,LGPL-3.0-or-later diff --git a/.github/workflows/grype.yaml b/.github/workflows/grype.yaml new file mode 100644 index 00000000..5ccd679a --- /dev/null +++ b/.github/workflows/grype.yaml @@ -0,0 +1,143 @@ +name: Security - Grype + +on: + push: + branches: [main, twn-main] + pull_request: + branches: [main, twn-main] + schedule: + - cron: '47 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: grype-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + grype-fs: + name: Grype - Filesystem + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: Cache Grype vuln DB + uses: actions/cache@v4 + with: + path: ~/.cache/grype/db + key: grype-db-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + grype-db-${{ runner.os }}- + + - name: Grype scan (source tree) + id: grype + uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0 + continue-on-error: true + with: + path: '.' + # Informational for first-run baseline; flip to true to enforce. + fail-build: false + severity-cutoff: high + output-format: sarif + only-fixed: true + + - name: Upload Grype FS SARIF + if: always() && github.event_name != 'pull_request' && steps.grype.outputs.sarif != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ steps.grype.outputs.sarif }} + category: grype-fs + + - name: Upload Grype FS SARIF artifact (PR) + if: always() && github.event_name == 'pull_request' && steps.grype.outputs.sarif != '' + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: grype-fs-sarif + path: ${{ steps.grype.outputs.sarif }} + retention-days: 14 + + grype-image: + name: Grype - Container image + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + dockerfile: + - { file: Dockerfile, tag: enclosed:scan } + - { file: Dockerfile.rootless, tag: enclosed:scan-rootless } + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: Cache Grype vuln DB + uses: actions/cache@v4 + with: + path: ~/.cache/grype/db + key: grype-db-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + grype-db-${{ runner.os }}- + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image (local, no push) + id: build + uses: docker/build-push-action@v6 + continue-on-error: true + with: + context: . + file: ./${{ matrix.dockerfile.file }} + push: false + load: true + platforms: linux/amd64 + tags: ${{ matrix.dockerfile.tag }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Grype scan (image) + id: grype-image + uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0 + continue-on-error: true + with: + image: ${{ matrix.dockerfile.tag }} + fail-build: false + severity-cutoff: high + output-format: sarif + only-fixed: true + + - name: Upload Grype image SARIF + if: always() && github.event_name != 'pull_request' && steps.grype-image.outputs.sarif != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ steps.grype-image.outputs.sarif }} + category: grype-image-${{ matrix.dockerfile.file }} + + - name: Upload Grype image SARIF artifact (PR) + if: always() && github.event_name == 'pull_request' && steps.grype-image.outputs.sarif != '' + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: grype-image-${{ matrix.dockerfile.file }}-sarif + path: ${{ steps.grype-image.outputs.sarif }} + retention-days: 14 diff --git a/.github/workflows/osv-scanner.yaml b/.github/workflows/osv-scanner.yaml new file mode 100644 index 00000000..635670f3 --- /dev/null +++ b/.github/workflows/osv-scanner.yaml @@ -0,0 +1,99 @@ +name: Security - OSV-Scanner & pnpm audit + +on: + push: + branches: [main, twn-main] + pull_request: + branches: [main, twn-main] + schedule: + - cron: '13 8 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: osv-scanner-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + osv-scanner: + name: OSV-Scanner + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + security-events: write + actions: read + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: OSV-Scanner + uses: google/osv-scanner-action/osv-scanner-action@c51854704019a247608d928f370c98740469d4b5 # v2.3.5 + with: + scan-args: |- + --lockfile=./pnpm-lock.yaml + --format=sarif + --output=osv-scanner.sarif + --recursive + continue-on-error: true + + - name: Upload OSV SARIF + # Only on push/schedule; PRs would fail Code Scanning delta against + # an empty main baseline. The SARIF is still kept as an artifact + # below. + if: always() && github.event_name != 'pull_request' && hashFiles('osv-scanner.sarif') != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: osv-scanner.sarif + category: osv-scanner + + - name: Upload OSV SARIF artifact + if: always() && hashFiles('osv-scanner.sarif') != '' + uses: actions/upload-artifact@v4 + with: + name: osv-scanner-sarif + path: osv-scanner.sarif + retention-days: 30 + + pnpm-audit: + name: pnpm audit (high & critical) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: Setup Node + pnpm + uses: ./.github/actions/pnpm-setup + with: + ignore-scripts: "true" + + - name: pnpm audit (surface HIGH/CRITICAL in prod deps) + # Informational first-run: surface findings without blocking merges so + # the baseline can be established, then flip `continue-on-error` to + # false once the 0-CVE target is achieved. + continue-on-error: true + run: pnpm audit --audit-level high --prod + + - name: pnpm audit (JSON report, all severities) + if: always() + run: pnpm audit --json > pnpm-audit.json || true + + - name: Upload audit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: pnpm-audit-report + path: pnpm-audit.json + retention-days: 30 diff --git a/.github/workflows/sbom.yaml b/.github/workflows/sbom.yaml new file mode 100644 index 00000000..f6875bad --- /dev/null +++ b/.github/workflows/sbom.yaml @@ -0,0 +1,129 @@ +name: Security - SBOM + +on: + push: + branches: [main, twn-main] + tags: + - 'v*.*.*' + pull_request: + branches: [main, twn-main] + schedule: + - cron: '11 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sbom-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + sbom-source: + name: SBOM - Source tree + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: Setup Node + pnpm + uses: ./.github/actions/pnpm-setup + with: + ignore-scripts: "true" + + - name: Generate CycloneDX SBOM (Anchore) + uses: anchore/sbom-action@62ad5284b8ced813296287a0b63906cb364b73ee # v0.22.0 + with: + path: . + format: cyclonedx-json + output-file: sbom.source.cyclonedx.json + artifact-name: sbom-source-cyclonedx + + - name: Generate SPDX SBOM (Anchore) + uses: anchore/sbom-action@62ad5284b8ced813296287a0b63906cb364b73ee # v0.22.0 + with: + path: . + format: spdx-json + output-file: sbom.source.spdx.json + artifact-name: sbom-source-spdx + + - name: Upload SBOM artifacts + uses: actions/upload-artifact@v4 + with: + name: sbom-source + path: | + sbom.source.cyclonedx.json + sbom.source.spdx.json + retention-days: 90 + + sbom-image: + name: SBOM - Container image + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + dockerfile: + - { file: Dockerfile, tag: enclosed:sbom, slug: root } + - { file: Dockerfile.rootless, tag: enclosed:sbom-rootless, slug: rootless } + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image (local, no push) + id: build + uses: docker/build-push-action@v6 + continue-on-error: true + with: + context: . + file: ./${{ matrix.dockerfile.file }} + push: false + load: true + platforms: linux/amd64 + tags: ${{ matrix.dockerfile.tag }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Generate CycloneDX SBOM for image + if: steps.build.outcome == 'success' + uses: anchore/sbom-action@62ad5284b8ced813296287a0b63906cb364b73ee # v0.22.0 + with: + image: ${{ matrix.dockerfile.tag }} + format: cyclonedx-json + output-file: sbom.image.${{ matrix.dockerfile.slug }}.cyclonedx.json + artifact-name: sbom-image-${{ matrix.dockerfile.slug }}-cyclonedx + + - name: Generate SPDX SBOM for image + if: steps.build.outcome == 'success' + uses: anchore/sbom-action@62ad5284b8ced813296287a0b63906cb364b73ee # v0.22.0 + with: + image: ${{ matrix.dockerfile.tag }} + format: spdx-json + output-file: sbom.image.${{ matrix.dockerfile.slug }}.spdx.json + artifact-name: sbom-image-${{ matrix.dockerfile.slug }}-spdx + + - name: Upload SBOM artifacts + if: steps.build.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: sbom-image-${{ matrix.dockerfile.slug }} + path: | + sbom.image.${{ matrix.dockerfile.slug }}.cyclonedx.json + sbom.image.${{ matrix.dockerfile.slug }}.spdx.json + retention-days: 90 + if-no-files-found: ignore diff --git a/.github/workflows/semgrep.yaml b/.github/workflows/semgrep.yaml new file mode 100644 index 00000000..6ae2e2bf --- /dev/null +++ b/.github/workflows/semgrep.yaml @@ -0,0 +1,70 @@ +name: Security - Semgrep + +on: + push: + branches: [main, twn-main] + pull_request: + branches: [main, twn-main] + schedule: + - cron: '23 5 * * 1' + +permissions: + contents: read + +concurrency: + group: semgrep-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + semgrep: + name: Semgrep SAST + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + security-events: write + container: + image: semgrep/semgrep:latest + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Semgrep scan (SARIF) + # Curated rule packs covering OWASP Top 10, JS/TS, Node, and secrets. + # SEMGREP_APP_TOKEN is optional; when present, findings are uploaded to + # Semgrep Cloud for triage. + env: + SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} + run: | + semgrep ci \ + --sarif --output=semgrep.sarif \ + --config "p/ci" \ + --config "p/security-audit" \ + --config "p/owasp-top-ten" \ + --config "p/javascript" \ + --config "p/typescript" \ + --config "p/nodejs" \ + --config "p/secrets" \ + --config "p/dockerfile" \ + --config "p/github-actions" \ + --metrics=off \ + || true + + - name: Upload SARIF to GitHub code scanning + # Only on push/schedule - PRs would fail the Code Scanning delta check + # against an empty main baseline. See trivy.yaml for rationale. + if: always() && github.event_name != 'pull_request' && hashFiles('semgrep.sarif') != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: semgrep.sarif + category: semgrep + + - name: Upload Semgrep SARIF artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: semgrep-sarif + path: semgrep.sarif + retention-days: 30 diff --git a/.github/workflows/stale-action.yaml b/.github/workflows/stale-action.yaml index dc7cfcc5..b770d63d 100644 --- a/.github/workflows/stale-action.yaml +++ b/.github/workflows/stale-action.yaml @@ -2,15 +2,23 @@ name: 'Close stale issues and PRs' on: schedule: - cron: '42 2 * * *' - + +permissions: + issues: write + pull-requests: write + +concurrency: + group: stale-action + cancel-in-progress: false + jobs: stale: name: Close stale issues and PRs runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 with: stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' days-before-stale: 30 - days-before-close: 5 \ No newline at end of file + days-before-close: 5 diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml new file mode 100644 index 00000000..1bb4c25c --- /dev/null +++ b/.github/workflows/trivy.yaml @@ -0,0 +1,268 @@ +name: Security - Trivy + +on: + push: + branches: [main, twn-main] + pull_request: + branches: [main, twn-main] + schedule: + - cron: '37 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: trivy-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + trivy-fs: + name: Trivy - Filesystem (deps & secrets) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: Cache Trivy vuln DB + uses: actions/cache@v4 + with: + path: ~/.cache/trivy + key: trivy-db-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + trivy-db-${{ runner.os }}- + + - name: Trivy FS scan (SARIF, all findings) + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + continue-on-error: true + with: + scan-type: fs + scan-ref: . + scanners: vuln,secret,misconfig,license + severity: CRITICAL,HIGH,MEDIUM + ignore-unfixed: true + format: sarif + output: trivy-fs.sarif + exit-code: '0' + + - name: Upload Trivy FS SARIF + # Only upload on push/schedule so the first merge to main establishes + # the Code Scanning baseline. PRs get findings via job logs and an + # artifact, but do not trigger a delta comparison that would fail + # because the base branch has no prior baseline. + if: always() && github.event_name != 'pull_request' && hashFiles('trivy-fs.sarif') != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-fs.sarif + category: trivy-fs + + - name: Upload Trivy FS SARIF artifact (PR) + if: always() && github.event_name == 'pull_request' && hashFiles('trivy-fs.sarif') != '' + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: trivy-fs-sarif + path: trivy-fs.sarif + retention-days: 14 + + - name: Trivy FS scan (table, surface HIGH/CRITICAL fixable) + # informational until baseline is green; flip exit-code to '1' to enforce. + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + continue-on-error: true + with: + scan-type: fs + scan-ref: . + scanners: vuln,secret + severity: CRITICAL,HIGH + ignore-unfixed: true + format: table + exit-code: '0' + + trivy-config: + name: Trivy - IaC / Dockerfile misconfig + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: Cache Trivy vuln DB + uses: actions/cache@v4 + with: + path: ~/.cache/trivy + key: trivy-db-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + trivy-db-${{ runner.os }}- + + - name: Trivy config scan (SARIF) + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + continue-on-error: true + with: + scan-type: config + scan-ref: . + severity: CRITICAL,HIGH,MEDIUM + format: sarif + output: trivy-config.sarif + exit-code: '0' + + - name: Upload Trivy config SARIF + if: always() && github.event_name != 'pull_request' && hashFiles('trivy-config.sarif') != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-config.sarif + category: trivy-config + + - name: Upload Trivy config SARIF artifact (PR) + if: always() && github.event_name == 'pull_request' && hashFiles('trivy-config.sarif') != '' + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: trivy-config-sarif + path: trivy-config.sarif + retention-days: 14 + + trivy-image: + name: Trivy - Container image + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + dockerfile: + - { file: Dockerfile, tag: enclosed:scan } + - { file: Dockerfile.rootless, tag: enclosed:scan-rootless } + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: Cache Trivy vuln DB + uses: actions/cache@v4 + with: + path: ~/.cache/trivy + key: trivy-db-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + trivy-db-${{ runner.os }}- + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image (local, no push) + id: build + uses: docker/build-push-action@v6 + continue-on-error: true + with: + context: . + file: ./${{ matrix.dockerfile.file }} + push: false + load: true + platforms: linux/amd64 + tags: ${{ matrix.dockerfile.tag }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Trivy image scan (SARIF) + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + continue-on-error: true + with: + image-ref: ${{ matrix.dockerfile.tag }} + scanners: vuln,secret + severity: CRITICAL,HIGH,MEDIUM + ignore-unfixed: true + format: sarif + output: trivy-image-${{ matrix.dockerfile.file }}.sarif + exit-code: '0' + + - name: Upload Trivy image SARIF + if: always() && github.event_name != 'pull_request' && hashFiles(format('trivy-image-{0}.sarif', matrix.dockerfile.file)) != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-image-${{ matrix.dockerfile.file }}.sarif + category: trivy-image-${{ matrix.dockerfile.file }} + + - name: Upload Trivy image SARIF artifact (PR) + if: always() && github.event_name == 'pull_request' && hashFiles(format('trivy-image-{0}.sarif', matrix.dockerfile.file)) != '' + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: trivy-image-${{ matrix.dockerfile.file }}-sarif + path: trivy-image-${{ matrix.dockerfile.file }}.sarif + retention-days: 14 + + - name: Trivy image scan (table, surface HIGH/CRITICAL fixable) + # informational until baseline is green; flip exit-code to '1' to enforce. + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + continue-on-error: true + with: + image-ref: ${{ matrix.dockerfile.tag }} + scanners: vuln + severity: CRITICAL,HIGH + ignore-unfixed: true + format: table + exit-code: '0' + + hadolint: + name: Hadolint - Dockerfile lint + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + dockerfile: [Dockerfile, Dockerfile.rootless] + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + persist-credentials: false + + - name: Hadolint + uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0 + with: + dockerfile: ${{ matrix.dockerfile }} + format: sarif + output-file: hadolint-${{ matrix.dockerfile }}.sarif + no-fail: true + + - name: Upload Hadolint SARIF + if: always() && github.event_name != 'pull_request' && hashFiles(format('hadolint-{0}.sarif', matrix.dockerfile)) != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: hadolint-${{ matrix.dockerfile }}.sarif + category: hadolint-${{ matrix.dockerfile }} + + - name: Upload Hadolint SARIF artifact (PR) + if: always() && github.event_name == 'pull_request' && hashFiles(format('hadolint-{0}.sarif', matrix.dockerfile)) != '' + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: hadolint-${{ matrix.dockerfile }}-sarif + path: hadolint-${{ matrix.dockerfile }}.sarif + retention-days: 14 diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 00000000..e1b7263f --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,20 @@ +node_modules/ +dist/ +dist-*/ +.output/ +.nuxt/ +.nitro/ +.wrangler/ +coverage/ +cache/ +*.min.js +*.min.mjs +*.min.cjs +pnpm-lock.yaml +packages/docs/ +packages/deploy-cloudflare/dist/ +**/*.test.ts +**/*.spec.ts +**/*.e2e.test.ts +playwright-report/ +test-results/ diff --git a/.zap/rules.tsv b/.zap/rules.tsv new file mode 100644 index 00000000..f532a63f --- /dev/null +++ b/.zap/rules.tsv @@ -0,0 +1,14 @@ +# ZAP baseline rule tuning for Enclosed. +# Format: \t\t +# Reference: https://www.zaproxy.org/docs/docker/baseline-scan/ + +# Static-asset false positives (SPA served by Hono): +10020 IGNORE X-Frame-Options header - mitigated via CSP frame-ancestors +10021 IGNORE X-Content-Type-Options - acceptable for static CDN assets + +# Cookie flags - enclosed does not use server-side cookies for auth (E2E encryption). +10054 IGNORE Cookie without SameSite attribute (n/a) +10038 IGNORE CSP wildcard (tuning; review via Semgrep/CodeQL) + +# Informational rules that are noise for a SPA +10096 IGNORE Timestamp disclosure (Unix) in JS bundles diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..4b5c5d25 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,60 @@ +# Security Policy + +Enclosed is an end-to-end encrypted note-sharing service. We take the security +of the project and its users seriously. + +## Supported versions + +Only the latest `main` release line and the current published Docker image +(`corentinth/enclosed:latest`) receive security fixes. + +| Version | Supported | +| -------------- | :-------: | +| `latest` | Yes | +| older releases | No | + +## Reporting a vulnerability + +**Please do not open a public issue for security vulnerabilities.** + +Report privately via one of: + +1. **GitHub Security Advisories** - preferred. + Use the [Report a vulnerability](https://github.com/CorentinTh/enclosed/security/advisories/new) + button on the Security tab. +2. Email the maintainer: `corentinth@proton.me`. + +When reporting, please include: + +- A description of the issue and its impact. +- Reproduction steps or a proof-of-concept. +- Affected versions / commits. +- Any suggested mitigation. + +We aim to acknowledge reports within 3 business days and to resolve confirmed +issues within 30 days for high-severity findings. + +## Automated security controls + +This repository is continuously scanned by the following tooling (see +`.github/workflows/`): + +| Control | Tool | +| --------------------------- | -------------------------------------- | +| SAST (semantic) | CodeQL, Semgrep | +| Dependency CVE scanning | OSV-Scanner, Trivy (fs), Grype (fs), pnpm audit | +| Container image scanning | Trivy (image), Grype (image) | +| IaC / Dockerfile misconfig | Trivy (config), Hadolint | +| Dependency review on PRs | `actions/dependency-review-action` | +| SBOM generation | Anchore SBOM Action (CycloneDX + SPDX) | +| DAST | OWASP ZAP baseline | +| Dependency updates | Renovate, Dependabot (Actions/Docker) | + +Findings are surfaced in the GitHub **Security** tab (Code scanning alerts). + +## Target: zero known CVEs + +The project's security KPI is **zero known high- or critical-severity CVEs** +in shipped dependencies and container images. PRs that introduce new +high/critical fixable CVEs are blocked by CI. Unfixable or contested findings +must be triaged via a GitHub Security Advisory or a documented suppression. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..6240f6a0 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,35 @@ +codecov: + require_ci_to_pass: false + notify: + wait_for_ci: true + +coverage: + precision: 2 + round: down + range: "60...90" + status: + project: + default: + target: auto + threshold: 1% + informational: true + patch: + default: + target: 70% + threshold: 5% + informational: true + +comment: + layout: "header, diff, flags, components, files" + behavior: default + require_changes: true + +ignore: + - "packages/docs/**" + - "packages/deploy-cloudflare/**" + - "**/*.test.ts" + - "**/*.spec.ts" + - "**/*.e2e.test.ts" + - "**/dist/**" + - "**/dist-*/**" + - "**/node_modules/**" diff --git a/package.json b/package.json index 956b0627..890cd8d1 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ "scripts": { "release": "bumpp --commit --tag --push --recursive", "docker:build": "docker build -t enclosed .", - "docker:run": "docker run -d -p 8788:8787 enclosed" + "docker:run": "docker run -d -p 8788:8787 enclosed", + "security:audit": "pnpm audit --audit-level high --prod", + "security:audit:all": "pnpm audit", + "security:outdated": "pnpm -r outdated" }, "keywords": [ "note", diff --git a/packages/app-client/src/modules/legal/pages/about.page.tsx b/packages/app-client/src/modules/legal/pages/about.page.tsx index 7c47a819..7fc75845 100644 --- a/packages/app-client/src/modules/legal/pages/about.page.tsx +++ b/packages/app-client/src/modules/legal/pages/about.page.tsx @@ -1,7 +1,7 @@ +import type { Component } from 'solid-js'; import { Button } from '@/modules/ui/components/button'; import { Card, CardContent, CardHeader } from '@/modules/ui/components/card'; import { A } from '@solidjs/router'; -import { type Component } from 'solid-js'; export const AboutPage: Component = () => { return ( @@ -98,11 +98,13 @@ export const AboutPage: Component = () => {
  • No one—not even we—can read your encrypted content
  • - Learn more about our security architecture on our{' '} + Learn more about our security architecture on our + {' '} Privacy & Security - {' '}page. + {' '} + page.

    @@ -116,7 +118,8 @@ export const AboutPage: Component = () => {

    - This instance is hosted and maintained by{' '} + This instance is hosted and maintained by + {' '} TWN Systems @@ -137,22 +140,26 @@ export const AboutPage: Component = () => {

    - This service is based on{' '} + This service is based on + {' '} Enclosed - , an open-source project created by{' '} + , an open-source project created by + {' '} Corentin Thomasset . We're grateful for his work in creating a tool that prioritizes user privacy and security.

    - Our fork is available at{' '} + Our fork is available at + {' '} github.com/TWN-Systems/enclosed - {' '}where you can: + {' '} + where you can:

    • Review the source code
    • diff --git a/packages/app-client/src/modules/legal/pages/security.page.tsx b/packages/app-client/src/modules/legal/pages/security.page.tsx index 8d40e633..b8765d31 100644 --- a/packages/app-client/src/modules/legal/pages/security.page.tsx +++ b/packages/app-client/src/modules/legal/pages/security.page.tsx @@ -1,7 +1,7 @@ +import type { Component } from 'solid-js'; import { Button } from '@/modules/ui/components/button'; import { Card, CardContent, CardHeader } from '@/modules/ui/components/card'; import { A } from '@solidjs/router'; -import { type Component } from 'solid-js'; export const SecurityPage: Component = () => { return ( diff --git a/packages/app-client/src/modules/legal/pages/terms.page.tsx b/packages/app-client/src/modules/legal/pages/terms.page.tsx index 8f10e207..309b62af 100644 --- a/packages/app-client/src/modules/legal/pages/terms.page.tsx +++ b/packages/app-client/src/modules/legal/pages/terms.page.tsx @@ -1,7 +1,7 @@ +import type { Component } from 'solid-js'; import { Button } from '@/modules/ui/components/button'; import { Card, CardContent, CardHeader } from '@/modules/ui/components/card'; import { A } from '@solidjs/router'; -import { type Component } from 'solid-js'; export const TermsPage: Component = () => { return ( @@ -173,11 +173,13 @@ export const TermsPage: Component = () => {
    • Analytics data is never sold or shared with third parties

    - For more details about our analytics practices, please see our{' '} + For more details about our analytics practices, please see our + {' '} Privacy & Security - {' '}page. + {' '} + page.

    @@ -221,18 +223,21 @@ export const TermsPage: Component = () => {

    • - Email details to{' '} + Email details to + {' '} security@twn.systems
    • Do not publicly disclose the vulnerability before we have addressed it
    • - See our{' '} + See our + {' '} Security Policy - {' '}for more information + {' '} + for more information
    @@ -255,7 +260,8 @@ export const TermsPage: Component = () => {

    - For questions about these terms, please visit our{' '} + For questions about these terms, please visit our + {' '} website diff --git a/packages/app-client/src/modules/privacy/pages/privacy.page.tsx b/packages/app-client/src/modules/privacy/pages/privacy.page.tsx index 60818812..771bd9eb 100644 --- a/packages/app-client/src/modules/privacy/pages/privacy.page.tsx +++ b/packages/app-client/src/modules/privacy/pages/privacy.page.tsx @@ -1,7 +1,7 @@ +import type { Component } from 'solid-js'; import { Button } from '@/modules/ui/components/button'; import { Card, CardContent, CardHeader } from '@/modules/ui/components/card'; import { A } from '@solidjs/router'; -import { type Component } from 'solid-js'; export const PrivacyPage: Component = () => { return ( @@ -66,10 +66,26 @@ export const PrivacyPage: Component = () => { This instance is hosted using Cloudflare's infrastructure:

      -
    • Cloudflare Pages: Provides secure, fast, and reliable hosting for the application
    • -
    • Cloudflare KV: Stores encrypted notes in a distributed key-value store
    • -
    • Global CDN: Notes are served from edge locations close to you for optimal performance
    • -
    • DDoS Protection: Built-in protection against attacks and abuse
    • +
    • + Cloudflare Pages: + {' '} + Provides secure, fast, and reliable hosting for the application +
    • +
    • + Cloudflare KV: + {' '} + Stores encrypted notes in a distributed key-value store +
    • +
    • + Global CDN: + {' '} + Notes are served from edge locations close to you for optimal performance +
    • +
    • + DDoS Protection: + {' '} + Built-in protection against attacks and abuse +
    @@ -106,13 +122,41 @@ export const PrivacyPage: Component = () => { We use Umami, a privacy-focused, self-hosted analytics platform as an alternative to Google Analytics. This allows us to understand how the service is used while respecting your privacy.

      -
    • No cookies: We do not use cookies for analytics tracking
    • -
    • Basic device info: Browser type, operating system, and screen size
    • -
    • General location: Country and region level only (not precise location)
    • -
    • Page views: Which pages are visited and how often
    • -
    • Data storage: Analytics data is stored securely in Tasmania, Australia
    • -
    • No third-party sharing: Analytics data is never sold or shared with third parties
    • -
    • No personal data: We do not collect names, email addresses, or any personally identifiable information through analytics
    • +
    • + No cookies: + {' '} + We do not use cookies for analytics tracking +
    • +
    • + Basic device info: + {' '} + Browser type, operating system, and screen size +
    • +
    • + General location: + {' '} + Country and region level only (not precise location) +
    • +
    • + Page views: + {' '} + Which pages are visited and how often +
    • +
    • + Data storage: + {' '} + Analytics data is stored securely in Tasmania, Australia +
    • +
    • + No third-party sharing: + {' '} + Analytics data is never sold or shared with third parties +
    • +
    • + No personal data: + {' '} + We do not collect names, email addresses, or any personally identifiable information through analytics +

    By using this service, you consent to the collection of this basic analytics data. This helps us improve the service and understand usage patterns. @@ -136,7 +180,8 @@ export const PrivacyPage: Component = () => {

  • Security researchers can verify the encryption implementation
  • The community can contribute improvements and report issues
  • - View the source code on{' '} + View the source code on + {' '} GitHub @@ -176,18 +221,21 @@ export const PrivacyPage: Component = () => {

    • - Email security issues to{' '} + Email security issues to + {' '} security@twn.systems
    • Do not publicly disclose the vulnerability before we have addressed it
    • - See our{' '} + See our + {' '} Security Policy - {' '}for detailed reporting guidelines + {' '} + for detailed reporting guidelines
    diff --git a/packages/app-client/uno.config.ts b/packages/app-client/uno.config.ts index 63577e42..88b4aae0 100644 --- a/packages/app-client/uno.config.ts +++ b/packages/app-client/uno.config.ts @@ -7,7 +7,7 @@ import { transformerDirectives, transformerVariantGroup, } from 'unocss'; -import presetAnimations from 'unocss-preset-animations'; +import { presetAnimations } from 'unocss-preset-animations'; import { iconByFileType } from './src/modules/files/files.models'; export default defineConfig({ diff --git a/packages/app-server/src/modules/notes/notes.routes.ts b/packages/app-server/src/modules/notes/notes.routes.ts index 9eb51d3c..76e34478 100644 --- a/packages/app-server/src/modules/notes/notes.routes.ts +++ b/packages/app-server/src/modules/notes/notes.routes.ts @@ -107,6 +107,7 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) { async (context, next) => { const config = context.get('config'); + // @ts-expect-error Hono's valid('json') typing narrows into a non-callable union when the validator overloads conflict. const { payload, isPublic, ttlInSeconds } = context.req.valid('json'); if (payload.length > config.notes.maxEncryptedPayloadLength) { diff --git a/packages/crypto/src/web/crypto.web.usecases.ts b/packages/crypto/src/web/crypto.web.usecases.ts index 13c4fb6e..a809e758 100644 --- a/packages/crypto/src/web/crypto.web.usecases.ts +++ b/packages/crypto/src/web/crypto.web.usecases.ts @@ -43,12 +43,12 @@ async function deriveMasterKey({ baseKey, password = '' }: { baseKey: Uint8Array const passwordBuffer = new TextEncoder().encode(password); const mergedBuffers = new Uint8Array([...baseKey, ...passwordBuffer]); - const key = await crypto.subtle.importKey('raw', mergedBuffers, 'PBKDF2', false, ['deriveKey']); + const key = await crypto.subtle.importKey('raw', mergedBuffers as BufferSource, 'PBKDF2', false, ['deriveKey']); const derivedKey = await crypto.subtle.deriveKey( { name: 'PBKDF2', - salt: baseKey, + salt: baseKey as BufferSource, iterations: 100_000, hash: 'SHA-256', }, diff --git a/packages/crypto/src/web/encryption-algorithms/crypto.web.aes-256-gcm.ts b/packages/crypto/src/web/encryption-algorithms/crypto.web.aes-256-gcm.ts index 744b0ad0..6b7bd614 100644 --- a/packages/crypto/src/web/encryption-algorithms/crypto.web.aes-256-gcm.ts +++ b/packages/crypto/src/web/encryption-algorithms/crypto.web.aes-256-gcm.ts @@ -6,8 +6,8 @@ export const aes256GcmEncryptionAlgorithmDefinition = defineEncryptionMethods({ encryptBuffer: async ({ buffer, encryptionKey }) => { const iv = createRandomBuffer({ length: 12 }); - const key = await crypto.subtle.importKey('raw', encryptionKey, 'AES-GCM', false, ['encrypt']); - const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, buffer); + const key = await crypto.subtle.importKey('raw', encryptionKey as BufferSource, 'AES-GCM', false, ['encrypt']); + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv as BufferSource }, key, buffer as BufferSource); const encryptedBuffer = new Uint8Array(encrypted); const ivString = bufferToBase64Url({ buffer: iv }); @@ -29,8 +29,8 @@ export const aes256GcmEncryptionAlgorithmDefinition = defineEncryptionMethods({ const iv = base64UrlToBuffer({ base64Url: ivString }); const encrypted = base64UrlToBuffer({ base64Url: encryptedContentString }); - const key = await crypto.subtle.importKey('raw', encryptionKey, 'AES-GCM', false, ['decrypt']); - const decryptedCryptoBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted); + const key = await crypto.subtle.importKey('raw', encryptionKey as BufferSource, 'AES-GCM', false, ['decrypt']); + const decryptedCryptoBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv as BufferSource }, key, encrypted as BufferSource); const decryptedBuffer = new Uint8Array(decryptedCryptoBuffer); return { decryptedBuffer }; diff --git a/packages/lib/src/files/files.models.ts b/packages/lib/src/files/files.models.ts index 1c61045a..de877bc8 100644 --- a/packages/lib/src/files/files.models.ts +++ b/packages/lib/src/files/files.models.ts @@ -28,7 +28,7 @@ async function noteAssetToFile({ noteAsset }: { noteAsset: NoteAsset }): Promise const fileName = get(noteAsset, 'metadata.name', 'file') as string; const fileType = get(noteAsset, 'metadata.fileType', 'application/octet-stream') as string; - return new File([noteAsset.content], fileName, { type: fileType }); + return new File([noteAsset.content as BlobPart], fileName, { type: fileType }); } async function noteAssetsToFiles({ noteAssets }: { noteAssets: NoteAsset[] }): Promise {