From 15b58d681f3d32416fc5496e3c9eb2556a3de9b0 Mon Sep 17 00:00:00 2001 From: Adam Ross <14985050+R055A@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:57:17 +0200 Subject: [PATCH 1/5] docs: correct spelling in README (#2235) * Doc: README spelling corrections * Doc: README spelling correction for server * Doc: README spelling correction fix * Doc: README spelling correction fix --------- Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a0dde96a9..bbc831c37 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls > the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart > library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or > tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue — -> not a Docker passthrough failure. Re-install the serve engine via +> not a Docker passthrough failure. Reinstall the serve engine via > **Cookbook → Dependencies** to get a CUDA-enabled build. > > The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside From 93825a505c330a93c40e200e450b116ce79a0a08 Mon Sep 17 00:00:00 2001 From: nopoz Date: Thu, 11 Jun 2026 12:51:11 -0700 Subject: [PATCH 2/5] ci: security scanning suite and governance (consolidates #305-310) (#1314) * ci: add security scanning suite and governance Consolidates the security CI work into one reviewable change. Adds, as separate workflow files under .github/workflows/: - secret-scan.yml gitleaks (pinned + checksum-verified), full history - workflow-security.yml actionlint + zizmor, audits the workflows themselves - dependency-review.yml PR dependency gate + advisory pip-audit - container-scan.yml hadolint (blocking) + Trivy image scan (advisory) - codeql.yml CodeQL for Python and JS, main + weekly Plus .github/dependabot.yml (pip/npm/actions/docker), .github/CODEOWNERS, and docs/security-ci.md explaining each check and the one-time settings. All additive: no existing files are modified. Actions are pinned to commit SHAs, tokens default-deny (permissions: {}), advisory scans never block, and SARIF upload is gated to push so fork PRs do not fail on a read-only token. Composes with the correctness CI in #1015. * ci(security): isolate Trivy from the Dockerfile lint gate Address review on #1314 (points 2 and 3). container-scan.yml now runs only hadolint (the blocking Dockerfile lint) and keeps the broad pull_request + push:[main] trigger so the required check always reports and never hangs a PR. The advisory image scan moves to container-trivy.yml, split by event: - pull_request / workflow_dispatch: build and scan under contents:read only, no SARIF upload. The image build runs PR-supplied Dockerfile instructions, so this path holds no write scope. - push to main: build, scan, and upload SARIF with security-events:write. Only this trusted path is granted write. This stops PR jobs from requesting security-events:write they never use, and a paths-ignore (matching docker-publish.yml) skips the image rebuild on docs-only changes. docs/security-ci.md: correct the trigger description to "every pull request and every push to main", matching the workflows and the existing ci.yml convention. Verified locally: zizmor --offline --min-severity=low and actionlint are clean on the changed and new workflow files. --------- Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> --- .github/CODEOWNERS | 8 ++ .github/dependabot.yml | 48 +++++++++ .github/workflows/codeql.yml | 61 ++++++++++++ .github/workflows/container-scan.yml | 52 ++++++++++ .github/workflows/container-trivy.yml | 125 ++++++++++++++++++++++++ .github/workflows/dependency-review.yml | 71 ++++++++++++++ .github/workflows/secret-scan.yml | 60 ++++++++++++ .github/workflows/workflow-security.yml | 80 +++++++++++++++ docs/security-ci.md | 102 +++++++++++++++++++ 9 files changed, 607 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/container-scan.yml create mode 100644 .github/workflows/container-trivy.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/secret-scan.yml create mode 100644 .github/workflows/workflow-security.yml create mode 100644 docs/security-ci.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..13a2da69f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +# Code owners. +# +# Every file is owned by the maintainer, so that when branch protection has +# "Require review from Code Owners" turned on, no pull request can be merged +# without the maintainer's review. This is the human gate that backs up the +# automated security checks. See docs/security-ci.md for how to turn it on. + +* @pewdiepie-archdaemon diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..e1e0bf13e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,48 @@ +# Dependabot keeps dependencies and pinned action versions current. +# +# Why this matters for security: every workflow in this repo pins its GitHub +# Actions to an exact commit (a SHA), which is safe but freezes them in time. +# Dependabot opens a small, reviewable pull request whenever a newer version +# exists -- for Python packages, npm packages, the Docker base image, and the +# pinned Actions themselves -- so staying patched does not require manual work. +# Updates are grouped so a week's bumps arrive as one PR per ecosystem, not a +# flood of separate ones. + +version: 2 +updates: + # Python dependencies (requirements.txt + requirements-optional.txt). + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + python: + patterns: ["*"] + + # Frontend / tooling npm packages (package.json). + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + npm: + patterns: ["*"] + + # The pinned action SHAs used across .github/workflows. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + actions: + patterns: ["*"] + + # The Docker base image in the Dockerfile. + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..a53835a05 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,61 @@ +# CodeQL code scanning +# +# Purpose: GitHub's own static analysis engine reads the application source +# (Python backend + the JavaScript frontend) and looks for real +# vulnerabilities -- SQL/command injection, path traversal, auth mistakes, +# unsafe deserialization. Findings appear in the repo's Security tab. This is +# the deepest check in the suite and the most valuable for a high-profile +# target. +# +# It runs on every push to main and on a weekly schedule (to catch newly +# disclosed query patterns against unchanged code). It deliberately does NOT +# run on pull requests: most PRs here come from forks, whose read-only token +# cannot publish results, which would produce confusing failures. To scan pull +# requests too, a maintainer can instead enable CodeQL "default setup" in +# Settings -> Security -> Code scanning (one toggle, no file needed) -- see +# docs/security-ci.md. + +name: CodeQL + +on: + push: + branches: [main] + schedule: + # Weekly, Monday 06:00 UTC. + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: {} + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # publish results to the Security tab + strategy: + fail-fast: false + matrix: + # Both are interpreted, so CodeQL needs no build step (build-mode none). + language: [python, javascript-typescript] + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 + with: + languages: ${{ matrix.language }} + build-mode: none + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/container-scan.yml b/.github/workflows/container-scan.yml new file mode 100644 index 000000000..71c4121a4 --- /dev/null +++ b/.github/workflows/container-scan.yml @@ -0,0 +1,52 @@ +# Container security: Dockerfile lint +# +# Purpose: the Docker image is how most people run Odysseus, so it is part of +# the attack surface. hadolint lints the Dockerfile for mistakes and insecure +# patterns (running as root longer than needed, unpinned base image, bad apt +# usage). Blocking. +# +# The image vulnerability scan (Trivy, advisory) lives in its own file, +# container-trivy.yml. Keeping it separate lets that advisory scan be +# path-filtered and held to a read-only token on pull requests without +# weakening this blocking gate, which must always report so a required check +# never hangs. +# +# Note: a separate open PR (#120) proposes a local `scripts/scan_image.py`. +# This job is complementary -- it is a CI gate, not a script a contributor has +# to remember to run. + +name: Container scan + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: {} + +concurrency: + group: container-scan-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + hadolint: + name: hadolint (Dockerfile lint) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Lint Dockerfile + uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0 + with: + dockerfile: Dockerfile + # DL3008: pinning apt package versions is impractical on a -slim base + # image. Debian purges old package versions from its repos, so a + # pinned version breaks future rebuilds. The base image itself is + # what should be pinned (tracked by Dependabot's docker ecosystem). + ignore: DL3008 diff --git a/.github/workflows/container-trivy.yml b/.github/workflows/container-trivy.yml new file mode 100644 index 000000000..025fefc16 --- /dev/null +++ b/.github/workflows/container-trivy.yml @@ -0,0 +1,125 @@ +# Container image vulnerability scan (advisory) +# +# Trivy builds the application image and scans it for known-vulnerable OS and +# Python packages. Advisory only -- it reports findings to the repo's Security +# tab without blocking a merge, because the image inevitably contains +# already-known CVEs in upstream packages that are not this project's bug. +# +# Split from the Dockerfile lint (container-scan.yml) for two reasons: +# +# - Least privilege. The image build runs Dockerfile instructions, which on a +# pull request are attacker-influenceable. That path (the `scan` job) is +# held to a read-only token and never publishes results. Only `publish`, +# which runs on push to main (curated, fast-forwarded from reviewed dev), +# gets security-events:write to upload SARIF. +# - Cost. Docs-only changes do not rebuild the image (paths-ignore below), +# matching docker-publish.yml. hadolint stays on the broad trigger in +# container-scan.yml so the blocking gate always reports. + +name: Container scan (Trivy) + +on: + pull_request: + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + push: + branches: [main] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + workflow_dispatch: + +permissions: {} + +concurrency: + group: container-trivy-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Pull requests and manual runs: build and scan under a read-only token. + # The build executes PR-supplied Dockerfile instructions, so this job must + # not hold any write scope, and it does not upload to the Security tab. + scan: + name: Trivy (image scan, advisory) + if: github.event_name != 'push' + runs-on: ubuntu-latest + # Advisory: a CVE in an upstream package must not block a PR. + continue-on-error: true + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + # Build without pushing so a broken Dockerfile is caught here, and the + # exact image we ship is what gets scanned. + - name: Build image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + push: false + load: true + tags: odysseus:ci + + - name: Scan image with Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: odysseus:ci + format: table + ignore-unfixed: true + env: + # Pin the vuln DB source to GHCR to avoid rate-limited Docker Hub + # mirrors that flake on shared runners. + TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 + + # Push to main only: build, scan, and publish SARIF to the Security tab. + # This is the only path that runs trusted code, so it is the only one granted + # security-events:write. + publish: + name: Trivy (image scan + SARIF upload) + if: github.event_name == 'push' + runs-on: ubuntu-latest + continue-on-error: true + permissions: + contents: read + security-events: write # upload SARIF to the Security tab + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Build image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + push: false + load: true + tags: odysseus:ci + + - name: Scan image with Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: odysseus:ci + format: sarif + output: trivy-results.sarif + ignore-unfixed: true + env: + TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 + + - name: Upload Trivy results + uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 + with: + sarif_file: trivy-results.sarif + category: trivy-image diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..85dc26ec6 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,71 @@ +# Supply-chain review +# +# Purpose: defend against "side-chain" / supply-chain attacks -- a pull request +# that adds (or bumps) a dependency to a version with a known vulnerability or a +# disallowed license. Two layers: +# +# - dependency-review: runs ONLY on pull requests. It compares the +# dependencies before and after the PR and blocks the merge if the change +# pulls in a package with a known security advisory. This is the gate. +# - pip-audit: scans the project's current Python requirements against the +# advisory database. Advisory only (it never blocks a merge), because it can +# flag a pre-existing issue in an already-shipped dependency. + +name: Dependency review + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +# Default-deny token; jobs grant only read access. +permissions: {} + +concurrency: + group: dependency-review-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependency-review: + name: dependency-review (PR gate) + # Only meaningful on a pull request -- it needs a base..head diff to review. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + # Fail the PR on any newly introduced moderate-or-worse advisory. + fail-on-severity: moderate + + pip-audit: + name: pip-audit (advisory) + runs-on: ubuntu-latest + # Advisory: report known-vulnerable Python deps without blocking the merge. + continue-on-error: true + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + - name: Run pip-audit on requirements + run: | + set -euo pipefail + pip install pip-audit==2.10.0 + pip-audit -r requirements.txt -r requirements-optional.txt --strict diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 000000000..55825bedf --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,60 @@ +# Secret scanning +# +# Purpose: stop credentials (API keys, tokens, passwords, private keys) from +# ever living in the Git history. Odysseus deliberately keeps real secrets in +# files that are gitignored (.env, data/), but a slip in a future commit -- or a +# malicious pull request that sneaks one in -- would otherwise go unnoticed. +# This job reads the repository and the full commit history and fails if it +# finds anything that looks like a secret. +# +# It runs the official gitleaks BINARY directly (pinned to an exact version and +# verified against the project's published SHA-256 checksum) rather than the +# gitleaks GitHub Action, because the Action asks for a paid license on +# organization-owned repos. The binary is free and behaves identically. + +name: Secret scan + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +# Start with zero permissions; the single job opts back in to read-only. +permissions: {} + +concurrency: + group: secret-scan-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gitleaks: + name: gitleaks + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Full history so a secret committed in an earlier commit (and later + # deleted) is still caught -- deletion does not remove it from Git. + fetch-depth: 0 + persist-credentials: false + + # Pinned version + checksum so a tampered release binary cannot run here. + # Bump VERSION/SHA256 together; the checksum comes from the matching + # gitleaks__checksums.txt on the GitHub release. + - name: Run gitleaks (pinned, checksum-verified) + env: + GITLEAKS_VERSION: 8.30.1 + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + run: | + set -euo pipefail + TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -fsSL -o "${TARBALL}" \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${TARBALL}" + echo "${GITLEAKS_SHA256} ${TARBALL}" | sha256sum -c - + tar -xzf "${TARBALL}" gitleaks + # Scan the whole history. Findings print to the log and fail the job. + ./gitleaks git --no-banner --redact --verbose . diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml new file mode 100644 index 000000000..efe487319 --- /dev/null +++ b/.github/workflows/workflow-security.yml @@ -0,0 +1,80 @@ +# Workflow security (CI that audits the CI) +# +# Purpose: the GitHub Actions workflows themselves are an attack surface. A +# poorly written workflow can leak the repository token, run attacker-supplied +# code from a pull request, or pull in a tampered third-party action. These two +# tools check every workflow file in this repo for those mistakes: +# +# - actionlint: catches workflow syntax errors and shell-script bugs inside +# `run:` steps before they reach main. +# - zizmor: a security linter for Actions. Flags template-injection holes, +# unpinned actions, credential persistence, and over-broad token +# permissions -- exactly the patterns the rest of this CI is built to avoid. +# +# Add this early: it then audits every workflow added after it. + +name: Workflow security + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +# Default-deny token; each job grants only read access to the code. +permissions: {} + +concurrency: + group: workflow-security-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + name: actionlint + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # Pinned version + checksum so a tampered binary cannot run here. + - name: Run actionlint (pinned, checksum-verified) + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + run: | + set -euo pipefail + TARBALL="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -fsSL -o "${TARBALL}" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${TARBALL}" + echo "${ACTIONLINT_SHA256} ${TARBALL}" | sha256sum -c - + tar -xzf "${TARBALL}" actionlint + ./actionlint -color + + zizmor: + name: zizmor (Actions SAST) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + # Pinned zizmor release. --offline keeps the audit hermetic (no network + # calls about the actions it inspects); --min-severity=low surfaces + # everything so nothing slips through under the gate. + - name: Run zizmor + run: | + set -euo pipefail + pip install zizmor==1.25.2 + zizmor --offline --min-severity=low .github/workflows/ diff --git a/docs/security-ci.md b/docs/security-ci.md new file mode 100644 index 000000000..c25838f72 --- /dev/null +++ b/docs/security-ci.md @@ -0,0 +1,102 @@ +# Security CI guide + +This project runs a set of automated security checks on every pull request and +on every push to `main`. This page explains what each one does, whether it can +block a merge, and the few one-time settings you should turn on to get the full +benefit. + +## What runs, and why + +Each check lives in its own file under `.github/workflows/`. They run +automatically; you do not start them. + +| Check | What it protects against | Blocks a merge? | +|---|---|---| +| **Secret scan** (gitleaks) | An API key, token, or password being committed by mistake or on purpose | Yes | +| **Workflow security** (actionlint + zizmor) | A broken or insecure automation file that could leak the repo's access token | Yes | +| **Dependency review** | A pull request that adds a software library with a known security hole | Yes | +| **pip-audit** | Known security holes in the Python libraries already used | No (advisory) | +| **Container scan: hadolint** | Mistakes and insecure patterns in the `Dockerfile` | Yes | +| **Container scan: Trivy** | Known security holes in the Docker image | No (advisory) | +| **CodeQL** | Real bugs in the app's own code: injection, auth mistakes, path traversal | No (advisory) | + +"Blocks a merge" means a red X appears on the pull request and, once you enable +the setting below, the **Merge** button is disabled until it is fixed. + +"Advisory" means it reports problems into the repository's **Security** tab so +you can review them on your own schedule, but it never stops a merge. These are +advisory on purpose: they often flag long-standing issues in other people's +libraries, not something a given pull request introduced. + +## Where results appear + +- **Checks tab of a pull request**: the pass/fail of each check. A green tick is + good; a red X needs attention. +- **Security tab of the repository**: detailed findings from the advisory + scanners (Trivy and CodeQL). This is your dashboard. + +## If a check fails + +- **Secret scan failed**: a real credential may have been committed. Treat it as + leaked: rotate (regenerate) that key or token immediately, then remove it from + the file. Do not just delete the commit; assume it was seen. +- **Dependency review failed**: the pull request adds a library with a known + vulnerability. Ask the contributor to use a patched version, or decline the + change. +- **hadolint / workflow security failed**: the contributor changed the + `Dockerfile` or an automation file in a way the linter rejects. Ask them to + address the message shown in the failed check. + +## One-time settings to turn on + +These two settings unlock the full value. You only do them once. + +### 1. Require the blocking checks before merging + +This makes the **Merge** button refuse to work until the gating checks pass. + +1. Go to the repository on GitHub. +2. Click **Settings** (top right of the repo). +3. In the left sidebar, click **Branches**. +4. Under **Branch protection rules**, click **Add branch ruleset** (or **Add + rule**), and set the branch name pattern to `dev` (this is the branch all + pull requests target; `main` is fast-forwarded at releases). +5. Enable **Require status checks to pass before merging**. +6. In the search box that appears, add these checks by name: + - `Python syntax (compileall)` + - `JS syntax (node --check)` + - `gitleaks` + - `actionlint` + - `zizmor (Actions SAST)` + - `hadolint (Dockerfile lint)` + - `dependency-review (PR gate)` + + The first two come from the correctness CI (`ci.yml`); the rest are this + security suite. Leave pytest, pip-audit, Trivy, and CodeQL unchecked so they + stay advisory. +7. Also enable **Require a pull request before merging** and **Require review + from Code Owners** (this uses the `.github/CODEOWNERS` file so every change + needs your sign-off). +8. Click **Create** / **Save changes**. + +Note: a check name only appears in the list after it has run at least once, so +let the workflows run on one pull request first, then add them here. + +### 2. Turn on the Security tab features + +1. **Settings -> Code security** (or **Code security and analysis**). +2. Turn on **Dependency graph** (usually on by default for public repos) -- this + powers Dependency review and Dependabot. +3. Turn on **Dependabot alerts** and **Dependabot security updates**. +4. Under **Code scanning**, you have two ways to scan the app code with CodeQL: + - The included `codeql.yml` workflow already scans `main` and runs weekly. + - To also scan **pull requests** (recommended, since most contributions come + from forks), click **Set up -> Default** under Code scanning. GitHub then + runs CodeQL on pull requests for you, with no token limitations. + +## Keeping it current + +`.github/dependabot.yml` opens small weekly pull requests to update Python and +npm packages, the Docker base image, and the pinned automation actions +themselves. Review and merge those like any other pull request; they keep the +project patched without manual tracking. From f5c1eb4b9dc2afa30c9629e7834ca536901c21fd Mon Sep 17 00:00:00 2001 From: Mazen Tamer Salah <78306991+mazen-salah@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:20:10 +0300 Subject: [PATCH 3/5] fix(settings): degrade load_features to defaults on PermissionError load_settings() already catches PermissionError, but load_features() caught only FileNotFoundError/JSONDecodeError/ValueError. An existing-but-unreadable data/features.json (e.g. root-owned after a deploy) therefore raised instead of falling back to DEFAULT_FEATURES, taking down GET /api/auth/features and anything that reads feature flags. Add PermissionError to the except tuple to match load_settings(). Adds tests/test_load_features_permission_error.py. Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> --- src/settings.py | 2 +- tests/test_load_features_permission_error.py | 26 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/test_load_features_permission_error.py diff --git a/src/settings.py b/src/settings.py index f6540db53..f305355dc 100644 --- a/src/settings.py +++ b/src/settings.py @@ -283,7 +283,7 @@ def load_features() -> dict: if not isinstance(saved, dict): raise ValueError("features must be an object") merged = {**DEFAULT_FEATURES, **saved} - except (FileNotFoundError, json.JSONDecodeError, ValueError): + except (FileNotFoundError, PermissionError, json.JSONDecodeError, ValueError): merged = dict(DEFAULT_FEATURES) _features_cache = (now, merged) return merged diff --git a/tests/test_load_features_permission_error.py b/tests/test_load_features_permission_error.py new file mode 100644 index 000000000..309bcbcca --- /dev/null +++ b/tests/test_load_features_permission_error.py @@ -0,0 +1,26 @@ +"""load_features() must degrade to defaults if features.json is unreadable. + +load_settings() already catches PermissionError, but load_features() did not, so +an unreadable data/features.json (e.g. root-owned after a deploy) raised instead +of falling back to DEFAULT_FEATURES, taking down GET /api/auth/features. +""" +import builtins + +import src.settings as settings + + +def test_load_features_degrades_on_permission_error(monkeypatch): + # Ensure the cache does not short-circuit the read. + monkeypatch.setattr(settings, "_features_cache", None, raising=False) + + real_open = builtins.open + + def deny(path, *args, **kwargs): + if str(path) == str(settings.FEATURES_FILE): + raise PermissionError("denied") + return real_open(path, *args, **kwargs) + + monkeypatch.setattr(builtins, "open", deny) + + result = settings.load_features() + assert result == dict(settings.DEFAULT_FEATURES) From 3b3c0d6254f317f66ac5ae8c181474a59c785c0d Mon Sep 17 00:00:00 2001 From: muhamed hamed <111616619+muhamedhamedvl@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:53:16 +0300 Subject: [PATCH 4/5] fix: detect HuggingFace token when downloading cookbook models (#3459) Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> --- routes/cookbook_helpers.py | 20 ++++++++++++++++++ routes/cookbook_routes.py | 13 +++++------- src/tool_implementations.py | 3 ++- static/js/cookbook-hwfit.js | 10 ++++----- tests/test_cookbook_hf_token.py | 37 +++++++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 tests/test_cookbook_hf_token.py diff --git a/routes/cookbook_helpers.py b/routes/cookbook_helpers.py index 53bdde80e..c2f93cb77 100644 --- a/routes/cookbook_helpers.py +++ b/routes/cookbook_helpers.py @@ -1,12 +1,14 @@ """cookbook_helpers.py — validators + small helpers shared by the cookbook routes. Extracted from cookbook_routes.py; the routes module imports the symbols it needs.""" +import json import logging import ntpath import os import posixpath import re import shlex +from pathlib import Path from fastapi import HTTPException from pydantic import BaseModel @@ -90,6 +92,24 @@ def _validate_token(v: str | None) -> str | None: return v +def load_stored_hf_token(*, state_path: Path | str | None = None) -> str: + """Return the decrypted HF token from cookbook_state.json, else env fallback.""" + path = Path(state_path) if state_path else Path(os.environ.get("DATA_DIR", "data")) / "cookbook_state.json" + token = "" + if path.exists(): + try: + state = json.loads(path.read_text(encoding="utf-8")) + env = state.get("env") if isinstance(state, dict) else {} + if isinstance(env, dict) and env.get("hfToken"): + from src.secret_storage import decrypt + token = decrypt(env.get("hfToken") or "") + except Exception: + token = "" + if not token: + token = (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip() + return token + + def _validate_local_dir(v: str | None) -> str | None: if v is None or v == "": return None diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index 40cfec31d..edbba3ad7 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -40,6 +40,10 @@ from routes.cookbook_helpers import ( _ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, _safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines, _append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script, + load_stored_hf_token, + _append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain, + _pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd, + _diagnose_serve_output, run_ssh_command_async, _ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd, ModelDownloadRequest, ServeRequest, @@ -234,14 +238,7 @@ def setup_cookbook_routes() -> APIRouter: return state def _load_stored_hf_token() -> str: - if not _cookbook_state_path.exists(): - return "" - try: - state = json.loads(_cookbook_state_path.read_text(encoding="utf-8")) - env = state.get("env") if isinstance(state, dict) else {} - return _decrypt_secret(env.get("hfToken") if isinstance(env, dict) else "") - except Exception: - return "" + return load_stored_hf_token(state_path=_cookbook_state_path) def _cookbook_ssh_dir() -> Path: # The Docker image keeps cookbook keys under /app/.ssh; that path only diff --git a/src/tool_implementations.py b/src/tool_implementations.py index 27c05f139..33cc8dc11 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -2054,13 +2054,14 @@ async def _cookbook_env_for_host(host: str) -> Dict[str, Any]: else: env_prefix = f'eval "$(conda shell.bash hook)" && conda activate {env_path}' + from routes.cookbook_helpers import load_stored_hf_token return { "env_prefix": env_prefix, "env_type": env_kind, "env_path": env_path, "gpus": env_root.get("gpus") or "", "platform": platform, - "hf_token": env_root.get("hfToken") or "", + "hf_token": load_stored_hf_token(), "ssh_port": ssh_port, } diff --git a/static/js/cookbook-hwfit.js b/static/js/cookbook-hwfit.js index d8652d02e..29feb9279 100644 --- a/static/js/cookbook-hwfit.js +++ b/static/js/cookbook-hwfit.js @@ -1506,12 +1506,10 @@ export function _hwfitInit() { clearTimeout(_hwfitDebounce); _hwfitDebounce = setTimeout(() => _hwfitFetch(), 400); }); - // HF Token - const hfToken = document.getElementById('hwfit-hftoken'); - if (hfToken) { - hfToken.addEventListener('change', () => { _envState.hfToken = hfToken.value.trim(); _persistEnvState(); }); - hfToken.addEventListener('input', () => { _envState.hfToken = hfToken.value.trim(); }); - } + // HF token save is owned by cookbook.js (_wireTabEvents) — do not wire a + // second change/input handler here. The old duplicate ran after cookbook.js + // cleared the input on save and overwrote _envState.hfToken with "", so the + // debounced state sync never persisted the token to cookbook_state.json. // Rebuild all server select dropdowns with current servers function _rebuildServerSelect() { diff --git a/tests/test_cookbook_hf_token.py b/tests/test_cookbook_hf_token.py new file mode 100644 index 000000000..4299158a9 --- /dev/null +++ b/tests/test_cookbook_hf_token.py @@ -0,0 +1,37 @@ +"""Cookbook HF token persistence and lookup.""" + +import json +import os + +import pytest + +from routes.cookbook_helpers import load_stored_hf_token +from src.secret_storage import encrypt + + +def test_load_stored_hf_token_reads_encrypted_state(tmp_path, monkeypatch): + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + state_path = tmp_path / "cookbook_state.json" + state_path.write_text( + json.dumps({"env": {"hfToken": encrypt("hf_test_token_12345")}}), + encoding="utf-8", + ) + assert load_stored_hf_token() == "hf_test_token_12345" + assert load_stored_hf_token(state_path=state_path) == "hf_test_token_12345" + + +def test_load_stored_hf_token_falls_back_to_env_when_state_missing(tmp_path, monkeypatch): + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setenv("HF_TOKEN", "hf_from_env") + assert load_stored_hf_token() == "hf_from_env" + + +def test_load_stored_hf_token_prefers_state_over_env(tmp_path, monkeypatch): + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setenv("HF_TOKEN", "hf_from_env") + state_path = tmp_path / "cookbook_state.json" + state_path.write_text( + json.dumps({"env": {"hfToken": encrypt("hf_from_state")}}), + encoding="utf-8", + ) + assert load_stored_hf_token() == "hf_from_state" From 20cf94f53dfcb1eb3fbc2659c95afdf4301a3186 Mon Sep 17 00:00:00 2001 From: Rolly Calma <115199279+Ghraven@users.noreply.github.com> Date: Fri, 12 Jun 2026 04:58:22 +0800 Subject: [PATCH 5/5] fix(platform): read proc version with utf-8 Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> --- core/platform_compat.py | 2 +- tests/test_platform_compat.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/core/platform_compat.py b/core/platform_compat.py index 1a927702b..efa496ac6 100644 --- a/core/platform_compat.py +++ b/core/platform_compat.py @@ -300,7 +300,7 @@ def is_wsl() -> bool: import sys if sys.platform.startswith("linux") or os.name == "posix": try: - with open("/proc/version", "r") as f: + with open("/proc/version", "r", encoding="utf-8", errors="ignore") as f: if "microsoft" in f.read().lower(): return True except Exception: diff --git a/tests/test_platform_compat.py b/tests/test_platform_compat.py index 2d8c211c0..d3e42b5ae 100644 --- a/tests/test_platform_compat.py +++ b/tests/test_platform_compat.py @@ -83,6 +83,7 @@ def test_is_wsl_true_when_proc_version_mentions_microsoft(monkeypatch): def fake_open(path, mode="r", *args, **kwargs): assert path == "/proc/version" assert mode == "r" + assert kwargs == {"encoding": "utf-8", "errors": "ignore"} return io.StringIO("Linux version 6.6.0 microsoft standard") monkeypatch.setattr("builtins.open", fake_open)