diff --git a/.github/workflows/build-v3.yml b/.github/workflows/build-v3.yml index 8f7ada043..86bdf75a0 100644 --- a/.github/workflows/build-v3.yml +++ b/.github/workflows/build-v3.yml @@ -30,6 +30,17 @@ jobs: - name: Verify dependency lock run: uv lock --check + - name: Audit locked Python dependencies + run: | + uv export --quiet --locked --no-dev --no-emit-project \ + --output-file /tmp/moviepilot-audit-requirements.txt + uvx --from pip-audit==2.10.1 pip-audit \ + --require-hashes \ + --disable-pip \ + --strict \ + --progress-spinner off \ + --requirement /tmp/moviepilot-audit-requirements.txt + - name: Release version id: release_version run: | @@ -127,6 +138,66 @@ jobs: - name: Set Up Buildx uses: docker/setup-buildx-action@v3 + - name: Build amd64 candidate + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: docker/Dockerfile + platforms: linux/amd64 + load: true + push: false + pull: true + tags: moviepilot-v3-candidate:linux-amd64 + build-args: | + MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }} + MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }} + MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }} + MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }} + cache-from: type=gha,scope=moviepilot-v3-docker-amd64,version=2 + cache-to: type=gha,scope=moviepilot-v3-docker-amd64,mode=max,version=2 + + - name: Scan amd64 candidate vulnerabilities + uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 + with: + image-ref: moviepilot-v3-candidate:linux-amd64 + version: v0.70.0 + scanners: vuln + vuln-type: os,library + severity: HIGH,CRITICAL + ignore-unfixed: true + trivyignores: .trivyignore.yaml + exit-code: 1 + + - name: Build arm64 candidate + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: docker/Dockerfile + platforms: linux/arm64/v8 + load: true + push: false + pull: true + tags: moviepilot-v3-candidate:linux-arm64 + build-args: | + MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }} + MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }} + MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }} + MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }} + cache-from: type=gha,scope=moviepilot-v3-docker-arm64,version=2 + cache-to: type=gha,scope=moviepilot-v3-docker-arm64,mode=max,version=2 + + - name: Scan arm64 candidate vulnerabilities + uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 + with: + image-ref: moviepilot-v3-candidate:linux-arm64 + version: v0.70.0 + scanners: vuln + vuln-type: os,library + severity: HIGH,CRITICAL + ignore-unfixed: true + trivyignores: .trivyignore.yaml + exit-code: 1 + - name: Login DockerHub uses: docker/login-action@v3 with: @@ -140,8 +211,8 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build Image - uses: docker/build-push-action@v7 + - name: Publish multi-architecture image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: docker/Dockerfile @@ -149,6 +220,7 @@ jobs: linux/amd64 linux/arm64/v8 push: true + pull: false tags: ${{ steps.meta.outputs.tags }} build-args: | MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }} @@ -166,8 +238,9 @@ jobs: org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }} org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }} org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }} - cache-from: type=gha,scope=moviepilot-v3-docker,version=2 - cache-to: type=gha,scope=moviepilot-v3-docker,mode=max,version=2 + cache-from: | + type=gha,scope=moviepilot-v3-docker-amd64,version=2 + type=gha,scope=moviepilot-v3-docker-arm64,version=2 - name: Generate Changelog id: changelog diff --git a/.gitignore b/.gitignore index 86a9974c0..c9fac1576 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ dist/ rust/**/target/ nginx/ test.py -safety_report.txt app/application/site/*.bin # 站点数据的运行期下载产物。上游 v3 架构重构后落点从 app/application/site 移到了 # app/helper,同目录的 .so/.pyd 由上面的通配兜住,只有 .bin 漏了网 diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 000000000..7f9c73026 --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,61 @@ +vulnerabilities: + - id: GHSA-6v7p-g79w-8964 + paths: + - Python + purls: + - pkg:pypi/msgpack@1.1.2 + statement: The finding belongs to the base image's system pip and is not imported by MoviePilot. + - id: CVE-2025-47273 + paths: + - Python + purls: + - pkg:pypi/setuptools@70.3.0 + statement: The finding belongs to the base image's system pip and is not used for dependency installation. + - id: CVE-2026-33818 + paths: + - usr/bin/rclone + purls: + - pkg:golang/stdlib@v1.26.5 + statement: The official rclone binary has no patched release for this embedded Go runtime yet. + - id: CVE-2026-39821 + paths: + - usr/bin/rclone + purls: + - pkg:golang/stdlib@v1.26.5 + statement: The official rclone binary has no patched release for this embedded Go runtime yet. + - id: CVE-2026-46600 + paths: + - usr/bin/rclone + purls: + - pkg:golang/stdlib@v1.26.5 + statement: The official rclone binary has no patched release for this embedded Go runtime yet. + - id: CVE-2026-56853 + paths: + - usr/bin/rclone + purls: + - pkg:golang/stdlib@v1.26.5 + statement: The official rclone binary has no patched release for this embedded Go runtime yet. + - id: CVE-2026-56858 + paths: + - usr/bin/rclone + purls: + - pkg:golang/stdlib@v1.26.5 + statement: The official rclone binary has no patched release for this embedded Go runtime yet. + - id: CVE-2026-56859 + paths: + - usr/bin/rclone + purls: + - pkg:golang/stdlib@v1.26.5 + statement: The official rclone binary has no patched release for this embedded Go runtime yet. + - id: CVE-2026-56860 + paths: + - usr/bin/rclone + purls: + - pkg:golang/stdlib@v1.26.5 + statement: The official rclone binary has no patched release for this embedded Go runtime yet. + - id: CVE-2026-56862 + paths: + - usr/bin/rclone + purls: + - pkg:golang/stdlib@v1.26.5 + statement: The official rclone binary has no patched release for this embedded Go runtime yet. diff --git a/AGENTS.md b/AGENTS.md index f604f988a..01f810bb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ For work that changes or reviews repository behavior, identify the domains actua ### Quality and Security * **Primary Reference:** `docs/rules/11-quality-and-security.md` -* **Required Constraints:** All code changes must pass the relevant pytest tests and pylint checks. Dependency changes require a current `uv.lock`, locked environment verification, and a passing manual Safety scan. +* **Required Constraints:** All code changes must pass the relevant pytest tests and pylint checks. Dependency changes require a current `uv.lock`, locked environment verification, and a passing locked dependency vulnerability audit. ### Testing * **Primary Reference:** `docs/testing.md` diff --git a/docker/Dockerfile b/docker/Dockerfile index c00de5169..db4246e6c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -9,7 +9,7 @@ FROM rclone/rclone:1.75.0@sha256:b06aed988cf5967de7c25be5925240983981c757f4ed1ac FROM mwader/static-ffmpeg:8.1.1@sha256:735f84b905e00d5c618b667f0b053f83b1096f5fc404c607e6134bf2275a0e0a AS ffmpeg -FROM python:3.14.7-slim-trixie@sha256:ce40764625a4ff50df3548277632e7f96c4e77fe75fa848aae9885476e7df5a4 AS base +FROM python:3.14.7-slim-trixie AS base # 准备外部制品所需的最小工具集 @@ -38,7 +38,6 @@ ENV LANG="C.UTF-8" \ ENV PATH="${VENV_PATH}/bin:${PATH}" RUN apt-get update \ - && apt-get upgrade -y \ && apt-get install -y --no-install-recommends ca-certificates curl \ && install -d -m 0755 /usr/share/postgresql-common/pgdg \ && curl -fsSL \ @@ -77,6 +76,7 @@ RUN apt-get update \ openssh-client \ sshpass \ iproute2 \ + util-linux \ netcat-openbsd \ lsof \ nano \ diff --git a/docs/development-setup.md b/docs/development-setup.md index 08ae17c84..f1db3c475 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -150,20 +150,20 @@ python -m scripts.generate_plugin_market_default \ --config-file app/runtime/config.py ``` -### 5. 运行安全检查 +### 5. 运行依赖漏洞检查 -我们使用 `safety` 工具检查 `pyproject.toml` 与 `uv.lock` 中是否存在已知安全漏洞。该检查是 -依赖变更的人工门禁,当前不属于自动 CI。 - -#### 执行安全检查 - -可以通过 `uvx` 在隔离工具环境中运行 `safety`,无需把它加入主程序依赖: +正式发布会使用固定版本的 `pip-audit` 检查 `uv.lock` 锁定的运行时依赖。依赖变更后也可以在 +本地执行同一检查: ```bash -uvx safety scan --target . --policy-file safety.policy.yml +uv export --quiet --locked --no-dev --no-emit-project \ + --output-file /tmp/moviepilot-audit-requirements.txt +uvx --from pip-audit==2.10.1 pip-audit \ + --require-hashes --disable-pip --strict --progress-spinner off \ + --requirement /tmp/moviepilot-audit-requirements.txt ``` -Safety 直接识别项目清单和锁文件,不需要生成或维护 requirements 文件。 +导出文件由 `uv.lock` 生成且保留哈希,不作为项目依赖清单提交。 ### 6. 提交代码前的检查 @@ -171,7 +171,7 @@ Safety 直接识别项目清单和锁文件,不需要生成或维护 requireme 1. **确认依赖分层正确**:运行时包进入 `[project].dependencies`;测试、覆盖率、静态检查和构建辅助进入 `[dependency-groups].dev`;插件依赖不并入主程序运行时依赖。 -2. **运行安全检查**:确保 `safety` 检查通过,没有新的安全漏洞。 +2. **运行依赖漏洞检查**:确保锁定的运行时依赖通过 `pip-audit`。 3. **运行测试**:如果项目中包含测试,请确保所有测试都通过。运行以下命令以执行测试: @@ -202,6 +202,6 @@ Safety 直接识别项目清单和锁文件,不需要生成或维护 requireme ### 7. 参考资源 - [uv 官方文档](https://docs.astral.sh/uv/) -- [Safety CLI 官方文档](https://docs.safetycli.com/) +- [pip-audit](https://github.com/pypa/pip-audit) - [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) - [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins) diff --git a/docs/rules/02-tech-stack.md b/docs/rules/02-tech-stack.md index 7101a5f0e..d98bd30c4 100644 --- a/docs/rules/02-tech-stack.md +++ b/docs/rules/02-tech-stack.md @@ -132,7 +132,7 @@ | pytest | Test runner | `uv run --locked --no-sync pytest tests/test_xxx.py` | | pylint | Static analysis | `uv run --locked --no-sync pylint app/` | | uv | Lock and environment consistency | `uv lock --check && uv pip check` | -| safety | Manual dependency vulnerability scan | `uvx safety scan --target . --policy-file safety.policy.yml` | +| pip-audit | Locked dependency vulnerability scan | `uv export --quiet --locked --no-dev --no-emit-project -o /tmp/moviepilot-audit-requirements.txt && uvx --from pip-audit==2.10.1 pip-audit --require-hashes --disable-pip --strict --progress-spinner off -r /tmp/moviepilot-audit-requirements.txt` | --- diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md index d24821e43..c2623fe4f 100644 --- a/docs/rules/03-commands.md +++ b/docs/rules/03-commands.md @@ -79,13 +79,16 @@ uv run --locked --no-sync pylint app/chain/download.py ## Security Scan ```bash -# Scan pyproject.toml and uv.lock -uvx safety scan --target . --policy-file=safety.policy.yml +uv export --quiet --locked --no-dev --no-emit-project \ + --output-file /tmp/moviepilot-audit-requirements.txt +uvx --from pip-audit==2.10.1 pip-audit \ + --require-hashes --disable-pip --strict --progress-spinner off \ + --requirement /tmp/moviepilot-audit-requirements.txt ``` **Rules:** -- Run manually after runtime or development dependency changes; this is not currently an automated CI job. -- No new high-severity vulnerabilities may be introduced. +- Run after runtime dependency changes; the release workflow enforces the same audit before publishing images. +- Any Python vulnerability reported by this audit blocks publishing until the dependency or explicit audit policy is updated. --- diff --git a/docs/rules/11-quality-and-security.md b/docs/rules/11-quality-and-security.md index bafda521c..1fd6fa273 100644 --- a/docs/rules/11-quality-and-security.md +++ b/docs/rules/11-quality-and-security.md @@ -54,12 +54,17 @@ uv run --locked --no-sync pylint app/ ## Dependency Security Scan ```bash -uvx safety scan --target . --policy-file safety.policy.yml +uv export --quiet --locked --no-dev --no-emit-project \ + --output-file /tmp/moviepilot-audit-requirements.txt +uvx --from pip-audit==2.10.1 pip-audit \ + --require-hashes --disable-pip --strict --progress-spinner off \ + --requirement /tmp/moviepilot-audit-requirements.txt ``` -- Run manually after runtime or development dependency changes; Safety scans `pyproject.toml` and `uv.lock` directly, and this check is not currently an automated CI job. -- No new high-severity vulnerabilities may be introduced. -- If a vulnerability cannot be patched immediately, document it explicitly in the PR description. +- Run after runtime dependency changes; the release workflow audits the same locked dependency set before publishing images. +- Any Python vulnerability reported by this audit blocks publishing until the dependency or explicit audit policy is updated. +- Release candidates also scan OS and language packages on amd64 and arm64. HIGH or CRITICAL findings with an available fix block publishing; unfixed upstream findings require a separate reachability and impact assessment. +- If upstream has no fix, assess reachability and impact before changing the audit policy; PR documentation alone does not bypass the gate. --- @@ -131,7 +136,7 @@ Before marking any task as complete: - [ ] Related pytest tests pass - [ ] No new pylint error-level issues in `pylint app/` -- [ ] If dependencies changed: the package is in the correct `pyproject.toml` group, `uv.lock` is current, locked sync and `uv pip check` pass, and the manual Safety scan passes +- [ ] If dependencies changed: the package is in the correct `pyproject.toml` group, `uv.lock` is current, locked sync and `uv pip check` pass, and the locked runtime dependency audit passes - [ ] If CLI behavior changed: `docs/cli.md` and related tests are updated - [ ] If MCP/API behavior changed: `docs/mcp-api.md` and related skill files are updated - [ ] If database schema changed: a new Alembic migration exists under `database/versions/` diff --git a/docs/rules/12-collaboration-and-distribution.md b/docs/rules/12-collaboration-and-distribution.md index c96a1de11..645556731 100644 --- a/docs/rules/12-collaboration-and-distribution.md +++ b/docs/rules/12-collaboration-and-distribution.md @@ -103,7 +103,7 @@ When updating a dependency: 1. Decide the dependency layer: runtime packages go to `[project].dependencies`; test, coverage, lint, and explicit build tooling go to `[dependency-groups].dev`. 2. Run `uv lock`, commit the updated `uv.lock`, and verify it with `uv lock --check`. -3. Run `uv sync --locked`, `uv pip check`, and the manual `uvx safety scan --target . --policy-file safety.policy.yml` check. +3. Run `uv sync --locked`, `uv pip check`, and the locked runtime dependency audit documented in `03-commands.md`. 4. Run the full test suite: `uv run --locked --no-sync pytest`. --- diff --git a/docs/rules/README.md b/docs/rules/README.md index 985667eee..585c48d04 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -56,7 +56,7 @@ This repository maintains a structured documentation library covering the full d * **11 Code Quality and Security** * File: `11-quality-and-security.md` - * Scope: Testing requirements, pylint gates, safety scans, authentication patterns, and input validation rules. + * Scope: Testing requirements, pylint gates, dependency vulnerability scans, authentication patterns, and input validation rules. * **12 Collaboration, Versioning, Build, and Release** * File: `12-collaboration-and-distribution.md` diff --git a/safety.policy.yml b/safety.policy.yml deleted file mode 100644 index 2642a1a0a..000000000 --- a/safety.policy.yml +++ /dev/null @@ -1,15 +0,0 @@ -security: - ignore-unpinned-requirements: False - ignore-vulnerabilities: - 70612: - reason: The official statement indicates that this vulnerability is not valid because users should use sandboxing when handling untrusted templates. - 65532: - reason: Legacy issue related to tvdbapi usage. - 40100: - reason: Legacy issue related to tvdbapi usage. - 68094: - reason: This vulnerability is resolved by upgrading `python-multipart` to version 0.0.9. - 65293: - reason: This vulnerability is resolved by upgrading `python-multipart` to version 0.0.9. - 64930: - reason: This vulnerability is resolved by upgrading `python-multipart` to version 0.0.9. diff --git a/tests/test_database_backup_docker.py b/tests/test_database_backup_docker.py index 9de8d90e4..c08ab2cf5 100644 --- a/tests/test_database_backup_docker.py +++ b/tests/test_database_backup_docker.py @@ -11,7 +11,7 @@ def test_runtime_image_installs_postgresql_18_client_from_pgdg() -> None: ).read_text(encoding="utf-8") assert re.search( - r"^FROM python:[^\s]+-slim-trixie@sha256:[0-9a-f]{64} AS base$", + r"^FROM python:3\.14\.7-slim-trixie AS base$", dockerfile, re.MULTILINE, ) diff --git a/tests/test_release_supply_chain.py b/tests/test_release_supply_chain.py new file mode 100644 index 000000000..06d3a540e --- /dev/null +++ b/tests/test_release_supply_chain.py @@ -0,0 +1,107 @@ +"""正式镜像发布的供应链门禁合同。""" + +from pathlib import Path + +from ruamel.yaml import YAML + + +ROOT = Path(__file__).resolve().parents[1] +DOCKERFILE = ROOT / "docker" / "Dockerfile" +RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "build-v3.yml" + + +def _load_workflow() -> dict: + """以 YAML 1.2 解析正式发布工作流。""" + yaml = YAML(typ="safe") + return yaml.load(RELEASE_WORKFLOW.read_text(encoding="utf-8")) + + +def _steps_by_name(workflow: dict) -> dict[str, dict]: + """按名称索引发布步骤,顺序仍由原列表校验。""" + return { + step["name"]: step + for step in workflow["jobs"]["Docker-build"]["steps"] + if "name" in step + } + + +def test_base_image_uses_refreshable_tag_and_apt_does_not_upgrade_in_place() -> None: + """基础镜像允许获得上游更新,构建阶段不得无边界升级整套 Debian。""" + dockerfile = DOCKERFILE.read_text(encoding="utf-8") + + assert "FROM python:3.14.7-slim-trixie AS base" in dockerfile + assert "python:3.14.7-slim-trixie@sha256:" not in dockerfile + assert "apt-get upgrade" not in dockerfile + assert "\n util-linux \\\n" in dockerfile + + +def test_release_audits_locked_runtime_dependencies_before_building() -> None: + """发布构建前必须审计带哈希的锁定运行时依赖。""" + workflow = _load_workflow() + steps = workflow["jobs"]["Docker-build"]["steps"] + names = [step.get("name") for step in steps] + audit = _steps_by_name(workflow)["Audit locked Python dependencies"]["run"] + + assert names.index("Audit locked Python dependencies") < names.index("Build amd64 candidate") + assert "uv export --quiet --locked --no-dev --no-emit-project" in audit + assert "pip-audit==2.10.1" in audit + for option in ("--require-hashes", "--disable-pip", "--strict"): + assert option in audit + + +def test_release_scans_both_architectures_before_registry_login_and_publish() -> None: + """任一架构的最终漏洞扫描失败时都不得登录仓库或发布镜像。""" + workflow = _load_workflow() + steps = workflow["jobs"]["Docker-build"]["steps"] + names = [step.get("name") for step in steps] + indexed = _steps_by_name(workflow) + + expected_candidates = { + "Build amd64 candidate": ("linux/amd64", "moviepilot-v3-candidate:linux-amd64"), + "Build arm64 candidate": ("linux/arm64/v8", "moviepilot-v3-candidate:linux-arm64"), + } + for name, (platform, tag) in expected_candidates.items(): + build = indexed[name]["with"] + assert build["platforms"] == platform + assert build["load"] is True + assert build["push"] is False + assert build["tags"] == tag + assert build["pull"] is True + assert "no-cache-filters" not in build + + for name in ( + "Scan amd64 candidate vulnerabilities", + "Scan arm64 candidate vulnerabilities", + ): + scan = indexed[name] + assert scan["uses"] == ( + "aquasecurity/trivy-action@" + "a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8" + ) + assert scan["with"].items() >= { + "version": "v0.70.0", + "scanners": "vuln", + "vuln-type": "os,library", + "severity": "HIGH,CRITICAL", + "ignore-unfixed": True, + "trivyignores": ".trivyignore.yaml", + "exit-code": 1, + }.items() + + last_scan = names.index("Scan arm64 candidate vulnerabilities") + assert last_scan < names.index("Login DockerHub") + assert last_scan < names.index("Login GitHub Container Registry") + assert last_scan < names.index("Publish multi-architecture image") + + +def test_publish_reuses_scanned_architecture_caches_without_refreshing_base() -> None: + """发布构建复用已扫描候选缓存,不得在扫描后重新拉取未审计基础镜像。""" + workflow = _load_workflow() + publish = _steps_by_name(workflow)["Publish multi-architecture image"]["with"] + + assert workflow["on"]["workflow_dispatch"] is None + assert publish["platforms"] == "linux/amd64\nlinux/arm64/v8\n" + assert publish["push"] is True + assert publish["pull"] is False + assert "scope=moviepilot-v3-docker-amd64" in publish["cache-from"] + assert "scope=moviepilot-v3-docker-arm64" in publish["cache-from"]