diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5ef1e06..cf315f1 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -7,6 +7,12 @@ on: pull_request: workflow_dispatch: +# Every job here is checkout plus local tools. The token needs to read the tree +# and nothing else, and saying so keeps a future step that reaches for the API +# from quietly holding a token that can write to the repository. +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} @@ -191,6 +197,70 @@ jobs: hadolint -c .hadolint.yaml "${dockerfiles[@]}" + # Known, accepted, and recorded. Each line is a real advisory against a + # package we build into the panel image, kept in this workflow rather than in + # the package manifest so that a subtree sync from forust/userbot cannot + # silently widen the exemption. + # + # starlette is the reason this job is not simply "fail on everything": + # fastapi 0.115.12 pins `starlette<0.47.0`, and the fixes for the last four + # below need 0.49.1 through 1.3.1, so clearing them means a jump from fastapi + # 0.115.12 to 0.141.x. That is upstream's call, not a drive-by in a lint + # commit. Of the seven, four are reachable here in principle: 1942 is a + # crafted Range header hitting FileResponse, and the panel serves its built + # SPA through exactly that; 249 is request.form() ignoring max_fields for + # x-www-form-urlencoded, which is the login form; 1941 is a large multipart + # body blocking the event loop; 161 and 248 are unvalidated Host and request + # path reaching request.url. 2280 needs HTTPEndpoint, which the panel does + # not use, and 2281 is Windows-only, and this deploys on Linux. + # + # The panel answers on userbot.workstation.internal and has no public + # forust.xyz route, which is what keeps the four reachable ones from being + # an internet-facing DoS. It still manages Telegram credentials. + # + # Deleting an entry here is how you accept a new advisory, so the diff says + # so out loud. + scan-deps: + runs-on: [self-hosted, linux, arch, homelab] + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Audit the Python dependencies that ship in the image + shell: bash + run: | + set -euo pipefail + tools_dir="$(bash .gitea/workflows/install-ci-tools.sh pip-audit)" + export PATH="$tools_dir:$PATH" + # requirements.txt, not requirements-dev.txt: this is what the image + # installs, and the test tooling is not a shipped attack surface. + pip-audit -r userbot/panel/backend/requirements.txt --strict \ + --ignore-vuln CVE-2025-67720 \ + --ignore-vuln PYSEC-2026-161 \ + --ignore-vuln PYSEC-2026-1941 \ + --ignore-vuln PYSEC-2026-1942 \ + --ignore-vuln PYSEC-2026-2280 \ + --ignore-vuln PYSEC-2026-2281 \ + --ignore-vuln PYSEC-2026-248 \ + --ignore-vuln PYSEC-2026-249 + + # devDependencies are excluded on purpose. `npm audit` on the full tree + # reports 7 findings, and every one of them is a build- or test-time + # package: the esbuild CORS advisory needs a vite dev server serving to + # the internet, and nanoid's infinite loop needs a custom generator + # called with size 0, which postcss does not do. None of them are in the + # 91 kB bundle the panel serves. The one production finding, devalue + # via svelte, is moderate, which is where --audit-level draws the line; + # this fails on the next high or critical one. + - name: Audit the production npm dependencies + shell: bash + run: | + set -euo pipefail + cd userbot/panel/frontend + npm ci + npm audit --omit=dev --audit-level=high + test-backend: runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 15 diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index c36d42d..395ef52 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -8,6 +8,12 @@ on: types: [completed] workflow_dispatch: +# The deploy jobs read the tree, then reach the cluster over SSH with the +# deploy key. The Actions token itself is not part of that path, so it gets +# read-only contents and no more. +permissions: + contents: read + concurrency: group: deploy-main # Queue instead of cancelling. Cancelling a run kills the apply job mid-loop and diff --git a/.gitea/workflows/install-ci-tools.sh b/.gitea/workflows/install-ci-tools.sh index f4565bf..fc1de64 100755 --- a/.gitea/workflows/install-ci-tools.sh +++ b/.gitea/workflows/install-ci-tools.sh @@ -138,6 +138,10 @@ install_yamllint() { install_uv_tool yamllint "${YAMLLINT_VERSION}" } +install_pip_audit() { + install_uv_tool pip-audit "${PIP_AUDIT_VERSION}" +} + install_prettier() { if at_version prettier "${PRETTIER_VERSION}"; then return 0 @@ -186,6 +190,7 @@ for tool in "${wanted[@]}"; do prettier) install_prettier ;; ruff) install_ruff ;; yamllint) install_yamllint ;; + pip-audit) install_pip_audit ;; hadolint) install_hadolint ;; uv) install_uv ;; *) diff --git a/.gitea/workflows/renovate-ci.yaml b/.gitea/workflows/renovate-ci.yaml index 60c29d1..00dfee9 100644 --- a/.gitea/workflows/renovate-ci.yaml +++ b/.gitea/workflows/renovate-ci.yaml @@ -7,6 +7,9 @@ on: - main workflow_dispatch: +permissions: + contents: read + jobs: validate-renovate: runs-on: [self-hosted, linux, arch, homelab] diff --git a/.gitea/workflows/renovate-run.yaml b/.gitea/workflows/renovate-run.yaml index b5faca9..cd5644c 100644 --- a/.gitea/workflows/renovate-run.yaml +++ b/.gitea/workflows/renovate-run.yaml @@ -21,6 +21,11 @@ on: default: false type: boolean +# Renovate writes through its own bot PAT, passed in as RENOVATE_TOKEN, so the +# Actions token is only ever used to read the checkout. +permissions: + contents: read + concurrency: group: renovate-run cancel-in-progress: false diff --git a/.gitea/workflows/tool-versions.env b/.gitea/workflows/tool-versions.env index e13a7db..286e74a 100644 --- a/.gitea/workflows/tool-versions.env +++ b/.gitea/workflows/tool-versions.env @@ -17,6 +17,10 @@ PRETTIER_VERSION="3.8.1" RUFF_VERSION="0.16.8" YAMLLINT_VERSION="1.38.0" HADOLINT_VERSION="2.14.0" +# pip-audit reads the advisory database over the network, so a floating version +# would make the same commit report different things on different days. Pin it +# like the rest: the advisories themselves are the moving part, not the tool. +PIP_AUDIT_VERSION="2.10.1" # uv builds the throwaway venv the pytest job runs in, and unpacks the PyPI -# wheels for ruff and yamllint. +# wheels for ruff, yamllint and pip-audit. UV_VERSION="0.12.17" diff --git a/renovate/k8s/configmap.yaml b/renovate/k8s/configmap.yaml index 6000964..ffa3d4d 100644 --- a/renovate/k8s/configmap.yaml +++ b/renovate/k8s/configmap.yaml @@ -117,6 +117,14 @@ data: "datasourceTemplate": "pypi", "depNameTemplate": "ruff" }, + { + "customType": "regex", + "description": "pip-audit version used by the ci workflow", + "managerFilePatterns": ["^\\.gitea/workflows/tool-versions\\.env$"], + "matchStrings": ["(?:^|\\n)PIP_AUDIT_VERSION=\"(?[0-9.]+)\""], + "datasourceTemplate": "pypi", + "depNameTemplate": "pip-audit" + }, { "customType": "regex", "description": "yamllint version used by the ci workflow", diff --git a/renovate/renovate.json b/renovate/renovate.json index d61c440..3c7a06b 100644 --- a/renovate/renovate.json +++ b/renovate/renovate.json @@ -106,6 +106,14 @@ "datasourceTemplate": "pypi", "depNameTemplate": "ruff" }, + { + "customType": "regex", + "description": "pip-audit version used by the ci workflow", + "managerFilePatterns": ["^\\.gitea/workflows/tool-versions\\.env$"], + "matchStrings": ["(?:^|\\n)PIP_AUDIT_VERSION=\"(?[0-9.]+)\""], + "datasourceTemplate": "pypi", + "depNameTemplate": "pip-audit" + }, { "customType": "regex", "description": "yamllint version used by the ci workflow",