From baedea504d5c7eb5174f8738f6f9fa9ba819189f Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 27 Sep 2026 10:46:01 +0200 Subject: [PATCH] ci: scan dependencies for new advisories and stop handing out a write token Two things, both about not finding out late. No workflow declared `permissions`, so all eighteen jobs across the four workflows ran on a token with the default full repository scope. Every one of them only checks out code, and deploy reaches the cluster over SSH with the deploy key, and Renovate writes through its own bot PAT rather than the Actions token. So `contents: read` is all any of them needed. The panel image ships 15 known advisories and nothing was looking. Add a scan-deps job that fails on anything new, and record the eight current ones by ID in the workflow. It is a list rather than a baseline count so that the diff that accepts an advisory says so in words, and it lives in our workflow instead of the package manifest so a subtree sync from forust/userbot cannot quietly widen the exemption. Both halves were checked to fail on a regression, not just to pass today: removing one --ignore-vuln turns the Python step red, and dropping --audit-level to moderate turns the npm one red on the devalue advisory. npm audits production dependencies only. All seven findings in the full tree are build- or test-time: the esbuild advisory needs a vite dev server exposed to the internet, and nanoid's infinite loop needs a custom generator called with size 0, which postcss does not do. None are in the 91 kB bundle the panel serves, so failing on them would be noise that trains people to ignore the job. The starlette entries are the reason the job is not "fail on everything": fastapi 0.115.12 pins starlette<0.47.0 and the last four fixes need 0.49.1 through 1.3.1, so clearing them is a jump to fastapi 0.141.x and is upstream's call, not a drive-by. Four of the seven are reachable in principle, which the comment on the job sets out. The panel answers only on userbot.workstation.internal with no public route, which is what keeps those four from being an internet-facing DoS. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yaml | 70 ++++++++++++++++++++++++++++ .gitea/workflows/deploy.yaml | 6 +++ .gitea/workflows/install-ci-tools.sh | 5 ++ .gitea/workflows/renovate-ci.yaml | 3 ++ .gitea/workflows/renovate-run.yaml | 5 ++ .gitea/workflows/tool-versions.env | 6 ++- renovate/k8s/configmap.yaml | 8 ++++ renovate/renovate.json | 8 ++++ 8 files changed, 110 insertions(+), 1 deletion(-) 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",