diff --git a/.github/actions/setup-snyk/action.yml b/.github/actions/setup-snyk/action.yml
deleted file mode 100644
index ce2ba6bfc..000000000
--- a/.github/actions/setup-snyk/action.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-name: Setup Snyk
-description: Install Node.js and the pinned Snyk CLI for workflow jobs
-
-inputs:
- node-version:
- description: Node.js version to install
- required: true
- snyk-version:
- description: Snyk CLI version to install
- required: true
-
-runs:
- using: composite
- steps:
- - name: Setup Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- with:
- node-version: ${{ inputs.node-version }}
-
- - name: Install Snyk CLI
- uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
- with:
- snyk-version: ${{ inputs.snyk-version }}
diff --git a/.github/workflows/security-grype.yml b/.github/workflows/security-grype.yml
new file mode 100644
index 000000000..50c4d717e
--- /dev/null
+++ b/.github/workflows/security-grype.yml
@@ -0,0 +1,147 @@
+name: "🛡️ Security: Grype"
+run-name: >-
+ ${{
+ github.event_name == 'schedule' && '🛡️ Security: Grype — Weekly run' ||
+ github.event_name == 'pull_request' && format('🛡️ Security: Grype — PR #{0}', github.event.pull_request.number) ||
+ format('🛡️ Security: Grype — Manual by {0}', github.actor)
+ }}
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches: [main]
+ # Grype matches lockfile-resolved versions, so only dependency / image
+ # surface changes can move its findings — scope PR runs to those so
+ # source- or doc-only PRs stay fast. Source SAST is CodeQL's job
+ # (ci-verify.yml); new-dependency CVEs are dependency-review's.
+ paths:
+ - '**/package.json'
+ - '**/package-lock.json'
+ - 'Dockerfile'
+ - '.github/workflows/security-grype.yml'
+ schedule:
+ - cron: '15 7 * * 1' # Weekly on Monday at 07:15 UTC
+
+permissions:
+ contents: read
+
+concurrency:
+ group: security-grype-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ grype-deps:
+ name: "📦 Security: Grype Dependency Scan (npm lockfiles)"
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ security-events: write
+ steps:
+ - name: Harden Runner
+ uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
+ with:
+ egress-policy: audit
+
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Run Grype across the repository (npm lockfiles)
+ # Scanning the repo root catalogs every package-lock.json
+ # (root, app, ui, e2e, apps/demo, apps/web) in a single pass.
+ # Grype matches the lockfile-resolved versions — not the manifest
+ # requirement graph — so it does not emit the phantom-dependency
+ # findings the SCM manifest-graph scanners do.
+ uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
+ id: grype-deps
+ with:
+ path: .
+ severity-cutoff: high
+ fail-build: true
+ output-format: sarif
+
+ - name: Upload Grype dependency SARIF
+ # Code scanning uploads need a public repo or GHAS. Drydock is public,
+ # so this runs; the guard keeps the job green on a fork/private mirror.
+ if: always() && github.event.repository.visibility == 'public'
+ uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+ with:
+ sarif_file: ${{ steps.grype-deps.outputs.sarif }}
+ category: grype-deps
+
+ - name: Summarize
+ if: always()
+ run: |
+ {
+ echo "### Grype Dependency Scan (npm lockfiles)"
+ echo "- Scanned every package-lock.json across the repo for HIGH/CRITICAL CVEs"
+ echo "- Matches lockfile-resolved versions (no requirement-graph false positives)"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ grype-image:
+ name: "🐳 Security: Grype Container Scan"
+ # The image build is the heavy step; keep it on scheduled/manual runs.
+ # PRs get fast, accurate dependency coverage from grype-deps without
+ # paying for a Docker build on every push.
+ if: github.event_name != 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ contents: read
+ security-events: write
+ steps:
+ - name: Harden Runner
+ uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
+ with:
+ egress-policy: audit
+
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+
+ - name: Build image for scanning
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
+ with:
+ context: .
+ push: false
+ load: true
+ tags: drydock:grype-scan
+ build-args: DD_VERSION=grype
+ # load: true uses the docker exporter, which can't accept the OCI
+ # manifest list produced by provenance attestations. The scan just
+ # needs the local image; real attestations live on release-cut.yml.
+ provenance: false
+ cache-from: type=gha,scope=drydock
+ cache-to: type=gha,mode=max,scope=drydock,ignore-error=true
+
+ - name: Run Grype vulnerability scanner
+ # The built image's package catalog is the dependency set actually
+ # shipped — so this is the authoritative view of the runtime CVE surface.
+ uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
+ id: grype
+ with:
+ image: drydock:grype-scan
+ severity-cutoff: high
+ fail-build: true
+ output-format: sarif
+
+ - name: Upload Grype SARIF to code scanning
+ if: always() && github.event.repository.visibility == 'public'
+ uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+ with:
+ sarif_file: ${{ steps.grype.outputs.sarif }}
+ category: grype-image
+
+ - name: Summarize
+ if: always()
+ run: |
+ {
+ echo "### Grype Container Scan"
+ echo "- Scanned the built image (shipped dependency set) for HIGH/CRITICAL CVEs"
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/security-snyk-weekly.yml b/.github/workflows/security-snyk-weekly.yml
deleted file mode 100644
index cd62207bc..000000000
--- a/.github/workflows/security-snyk-weekly.yml
+++ /dev/null
@@ -1,399 +0,0 @@
-name: "🛡️ Security: Snyk Paid Scans"
-run-name: >-
- ${{
- github.event_name == 'schedule' && '🛡️ Security: Snyk Paid Scans — Weekly run' ||
- format('🛡️ Security: Snyk Paid Scans — Manual by {0}', github.actor)
- }}
-
-on:
- workflow_dispatch:
- schedule:
- - cron: '15 7 * * 1' # Weekly on Monday at 07:15 UTC
-
-permissions:
- contents: read
-
-concurrency:
- group: snyk-paid-${{ github.workflow }}
- cancel-in-progress: true
-
-env:
- SNYK_CLI_VERSION: '1.1305.0'
- SNYK_QUOTA_CONFIG_PATH: scripts/snyk-quota-config.json
- SNYK_CONTAINER_IMAGE: drydock:snyk
-
-jobs:
- prepare:
- name: "🛠️ Security: Prepare Snyk Context"
- runs-on: ubuntu-latest
- timeout-minutes: 5
- environment: ci-security
- outputs:
- has_token: ${{ steps.token.outputs.has_token }}
- is_default_branch: ${{ steps.token.outputs.is_default_branch }}
- steps:
- - name: Harden Runner
- uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
- with:
- egress-policy: audit
-
- - name: Check Snyk token availability
- id: token
- env:
- SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- CURRENT_REF: ${{ github.ref }}
- run: |
- if [ -n "${SNYK_TOKEN:-}" ]; then
- echo "has_token=true" >> "$GITHUB_OUTPUT"
- else
- echo "has_token=false" >> "$GITHUB_OUTPUT"
- fi
-
- if [ "$CURRENT_REF" = "refs/heads/$DEFAULT_BRANCH" ]; then
- echo "is_default_branch=true" >> "$GITHUB_OUTPUT"
- else
- echo "is_default_branch=false" >> "$GITHUB_OUTPUT"
- fi
-
- quota-plan:
- name: "📊 Security: Snyk Quota Plan"
- runs-on: ubuntu-latest
- timeout-minutes: 5
- needs: [prepare]
- steps:
- - name: Harden Runner
- uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
- with:
- egress-policy: audit
-
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Validate monthly quota plan
- id: quota
- run: |
- set -euo pipefail
- node scripts/snyk-quota-plan.mjs --config "${SNYK_QUOTA_CONFIG_PATH}" > quota.json
- cat quota.json
-
- - name: Attach quota summary
- run: |
- {
- echo "### Snyk Quota Plan"
- cat quota.json
- } >> "$GITHUB_STEP_SUMMARY"
-
- open-source:
- name: "📦 Security: Snyk Open Source"
- runs-on: ubuntu-latest
- timeout-minutes: 20
- permissions:
- actions: read
- contents: read
- security-events: write
- environment: ci-security
- needs: [prepare, quota-plan]
- if: ${{ needs.quota-plan.result == 'success' && needs.prepare.outputs.has_token == 'true' && needs.prepare.outputs.is_default_branch == 'true' }}
- env:
- SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
-
- steps:
- - name: Harden Runner
- uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
- with:
- egress-policy: audit
-
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Setup Snyk toolchain
- uses: ./.github/actions/setup-snyk
- with:
- node-version: '24'
- snyk-version: v${{ env.SNYK_CLI_VERSION }}
-
- - name: Run Snyk Open Source scans
- id: snyk_open_source
- continue-on-error: true
- run: |
- set -euo pipefail
- mkdir -p artifacts/snyk/open-source
- ./scripts/snyk-deps-gate.sh \
- --file=package-lock.json \
- --package-manager=npm \
- --sarif-file-output=artifacts/snyk/open-source/root.sarif
- ./scripts/snyk-deps-gate.sh \
- --file=app/package-lock.json \
- --package-manager=npm \
- --sarif-file-output=artifacts/snyk/open-source/app.sarif
- ./scripts/snyk-deps-gate.sh \
- --file=ui/package-lock.json \
- --package-manager=npm \
- --sarif-file-output=artifacts/snyk/open-source/ui.sarif
- ./scripts/snyk-deps-gate.sh \
- --file=e2e/package-lock.json \
- --package-manager=npm \
- --sarif-file-output=artifacts/snyk/open-source/e2e.sarif
-
- - name: Upload root Open Source SARIF
- if: ${{ always() && hashFiles('artifacts/snyk/open-source/root.sarif') != '' }}
- uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- with:
- sarif_file: artifacts/snyk/open-source/root.sarif
- category: snyk-open-source-root
-
- - name: Upload app Open Source SARIF
- if: ${{ always() && hashFiles('artifacts/snyk/open-source/app.sarif') != '' }}
- uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- with:
- sarif_file: artifacts/snyk/open-source/app.sarif
- category: snyk-open-source-app
-
- - name: Upload UI Open Source SARIF
- if: ${{ always() && hashFiles('artifacts/snyk/open-source/ui.sarif') != '' }}
- uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- with:
- sarif_file: artifacts/snyk/open-source/ui.sarif
- category: snyk-open-source-ui
-
- - name: Upload e2e Open Source SARIF
- if: ${{ always() && hashFiles('artifacts/snyk/open-source/e2e.sarif') != '' }}
- uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- with:
- sarif_file: artifacts/snyk/open-source/e2e.sarif
- category: snyk-open-source-e2e
-
- - name: Fail if any Snyk scan errored
- if: always()
- env:
- OUTCOME: ${{ steps.snyk_open_source.outcome }}
- run: |
- if [ "$OUTCOME" = "failure" ]; then
- echo "::error::Snyk Open Source scan failed"
- exit 1
- fi
-
- code:
- name: "🧠 Security: Snyk Code"
- runs-on: ubuntu-latest
- timeout-minutes: 20
- permissions:
- actions: read
- contents: read
- security-events: write
- environment: ci-security
- needs: [prepare, quota-plan]
- if: ${{ needs.quota-plan.result == 'success' && needs.prepare.outputs.has_token == 'true' && needs.prepare.outputs.is_default_branch == 'true' }}
- env:
- SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
- SNYK_CODE_ENFORCE: "true"
-
- steps:
- - name: Harden Runner
- uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
- with:
- egress-policy: audit
-
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Setup Snyk toolchain
- uses: ./.github/actions/setup-snyk
- with:
- node-version: '24'
- snyk-version: v${{ env.SNYK_CLI_VERSION }}
-
- - name: Run Snyk Code scan
- id: snyk_code
- continue-on-error: true
- run: |
- set -euo pipefail
- mkdir -p artifacts/snyk
- ./scripts/snyk-code-gate.sh --sarif-file-output=artifacts/snyk/code.sarif
-
- - name: Upload Code SARIF
- if: ${{ always() && hashFiles('artifacts/snyk/code.sarif') != '' }}
- uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- with:
- sarif_file: artifacts/snyk/code.sarif
- category: snyk-code
-
- - name: Fail if any Snyk scan errored
- if: always()
- env:
- OUTCOME: ${{ steps.snyk_code.outcome }}
- run: |
- if [ "$OUTCOME" = "failure" ]; then
- echo "::error::Snyk Code scan failed"
- exit 1
- fi
-
- container:
- name: "🐳 Security: Snyk Container"
- runs-on: ubuntu-latest
- timeout-minutes: 30 # Includes Docker image build before scan
- permissions:
- actions: read
- contents: read
- security-events: write
- environment: ci-security
- needs: [prepare, quota-plan]
- if: ${{ needs.quota-plan.result == 'success' && needs.prepare.outputs.has_token == 'true' && needs.prepare.outputs.is_default_branch == 'true' }}
- env:
- SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
-
- steps:
- - name: Harden Runner
- uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
- with:
- egress-policy: audit
-
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Setup Snyk toolchain
- uses: ./.github/actions/setup-snyk
- with:
- node-version: '24'
- snyk-version: v${{ env.SNYK_CLI_VERSION }}
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
-
- - name: Build image for container scan (cached)
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
- with:
- context: .
- push: false
- load: true
- tags: ${{ env.SNYK_CONTAINER_IMAGE }}
- build-args: DD_VERSION=snyk
- # load: true uses the docker exporter, which can't accept the OCI
- # manifest list produced by provenance attestations. Snyk just needs
- # the local image; real attestations live on release-cut.yml.
- provenance: false
- cache-from: type=gha,scope=drydock
- cache-to: type=gha,mode=max,scope=drydock,ignore-error=true
-
- - name: Run Snyk Container scan
- id: snyk_container
- continue-on-error: true
- run: |
- set -euo pipefail
- mkdir -p artifacts/snyk
- ./scripts/snyk-container-gate.sh "${SNYK_CONTAINER_IMAGE}" \
- --file=Dockerfile \
- --sarif-file-output=artifacts/snyk/container.sarif
-
- - name: Upload Container SARIF
- if: ${{ always() && hashFiles('artifacts/snyk/container.sarif') != '' }}
- uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- with:
- sarif_file: artifacts/snyk/container.sarif
- category: snyk-container
-
- - name: Fail if any Snyk scan errored
- if: always()
- env:
- OUTCOME: ${{ steps.snyk_container.outcome }}
- run: |
- if [ "$OUTCOME" = "failure" ]; then
- echo "::error::Snyk Container scan failed"
- exit 1
- fi
-
- iac:
- name: "🏗️ Security: Snyk IaC"
- runs-on: ubuntu-latest
- timeout-minutes: 15
- permissions:
- actions: read
- contents: read
- security-events: write
- environment: ci-security
- needs: [prepare, quota-plan]
- if: ${{ needs.quota-plan.result == 'success' && needs.prepare.outputs.has_token == 'true' && needs.prepare.outputs.is_default_branch == 'true' }}
- env:
- SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
-
- steps:
- - name: Harden Runner
- uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
- with:
- egress-policy: audit
-
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Setup Snyk toolchain
- uses: ./.github/actions/setup-snyk
- with:
- node-version: '24'
- snyk-version: v${{ env.SNYK_CLI_VERSION }}
-
- - name: Run Snyk IaC scan
- id: snyk_iac
- continue-on-error: true
- run: |
- set -euo pipefail
- mkdir -p artifacts/snyk
- ./scripts/snyk-iac-gate.sh . --sarif-file-output=artifacts/snyk/iac.sarif
-
- - name: Upload IaC SARIF
- if: ${{ always() && hashFiles('artifacts/snyk/iac.sarif') != '' }}
- uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- with:
- sarif_file: artifacts/snyk/iac.sarif
- category: snyk-iac
-
- - name: Fail if any Snyk scan errored
- if: always()
- env:
- OUTCOME: ${{ steps.snyk_iac.outcome }}
- run: |
- if [ "$OUTCOME" = "failure" ]; then
- echo "::error::Snyk IaC scan failed"
- exit 1
- fi
-
- skipped:
- name: "⏭️ Security: Snyk Scans Skipped"
- runs-on: ubuntu-latest
- timeout-minutes: 5
- needs: [prepare, quota-plan]
- if: ${{ always() && (needs.quota-plan.result != 'success' || needs.prepare.outputs.has_token != 'true' || needs.prepare.outputs.is_default_branch != 'true') }}
- steps:
- - name: Harden Runner
- uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
- with:
- egress-policy: audit
-
- - name: Explain skip reason(s)
- env:
- HAS_TOKEN: ${{ needs.prepare.outputs.has_token }}
- IS_DEFAULT_BRANCH: ${{ needs.prepare.outputs.is_default_branch }}
- QUOTA_PLAN_RESULT: ${{ needs.quota-plan.result }}
- run: |
- {
- echo "### Snyk scans skipped"
- if [ "$HAS_TOKEN" != "true" ]; then
- echo "- Reason: \`SNYK_TOKEN\` secret is not configured."
- fi
- if [ "$IS_DEFAULT_BRANCH" != "true" ]; then
- echo "- Reason: this workflow only runs paid scans on the default branch to protect quotas."
- fi
- if [ "$QUOTA_PLAN_RESULT" != "success" ]; then
- echo "- Reason: quota plan validation failed (\`quota-plan\` job result: \`$QUOTA_PLAN_RESULT\`)."
- fi
- } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.snyk b/.snyk
deleted file mode 100644
index e169c231f..000000000
--- a/.snyk
+++ /dev/null
@@ -1,37 +0,0 @@
-# Snyk policy file — https://docs.snyk.io/manage-risk/policies/the-.snyk-file
-#
-# SCOPE — this file is ONLY enforced by `snyk test` (Open Source) and
-# `snyk iac test`. It is NOT enforced by `snyk code test` (SAST).
-# Per Snyk docs:
-# "To ignore code vulnerabilities in Snyk Code scans, use the ignore
-# button within the Snyk UI. The .snyk file cannot be used for
-# ignoring issues in Code scans."
-#
-# Snyk Code suppressions live server-side in the Snyk platform (web UI
-# or Policies REST API). They show up in SARIF as `suppressions` with
-# `kind: external`, not from this file. The audit trail for Code
-# suppressions is documented in:
-#
-# .planning/snyk-scans/
-
March 2026 — lefthook + GitHub Actions — Round 1 + Round 2
if: needs.quota-plan.result == 'success'.snyk-quota-config.json.