diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index ce3509e..9a0d329 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -14,8 +14,14 @@ jobs: - uses: actions/setup-node@v4 with: { node-version: 22, cache: npm } - run: npm ci + - run: npm audit --omit=dev --audit-level=high - run: npx tsc --noEmit - run: npm test + - run: npm run sbom -- artifacts/mebbling.spdx.json + - uses: actions/upload-artifact@v4 + with: + name: mebbling-sbom-${{ gitea.sha }} + path: artifacts/mebbling.spdx.json - run: docker build --build-arg APP_VERSION=${{ gitea.ref_name }} -t mebbling:${{ gitea.sha }} . # Optional: configure DEPLOY_WEBHOOK_URL as a Gitea Actions secret to notify your host on a v* tag. - if: startsWith(gitea.ref, 'refs/tags/v') && secrets.DEPLOY_WEBHOOK_URL != '' diff --git a/CHANGELOG.md b/CHANGELOG.md index 3391452..69b4b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Added a repeatable FTS search benchmark and documented PostgreSQL migration decision criteria. - Added persistent alert delivery with HTTPS validation, exponential-backoff retries, and Prometheus delivery-state metrics. - Added verified backup reports plus optional age-encrypted rclone offsite copies. +- Added production dependency auditing, reproducible SPDX SBOM generation, and a documented release/upgrade/signing path. ### Fixed diff --git a/README.md b/README.md index a8dfba6..3a05a52 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo - 登入在 15 分鐘內最多嘗試 8 次;webhook 與登入限流資料存於 SQLite,同一份資料庫的多個 Web 容器會共用計數。 - 所有會改變帳號或內容的瀏覽器 POST 都檢查 `Origin`,Webhook 則使用密鑰 URL 驗證,不適用此規則。 - Gitea Actions 工作流程會在推送/標籤時執行型別檢查、測試與 Docker 建置;若設定 `DEPLOY_WEBHOOK_URL` secret,建立 `v*` tag 時會通知部署端。 +- 工作流程也會對 production dependencies 執行高風險漏洞檢查,並產生 SPDX SBOM artifact;發布、升級與日後映像簽章的流程見 [發布文件](docs/RELEASING.md)。 ## Webhook 設定與驗證 diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..14a37ce --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,17 @@ +# 發布流程 + +## 每次發布前 + +1. 確認 `CHANGELOG.md` 的 Unreleased 內容與目標版本一致。 +2. 在本機執行 `npm test`、`npx tsc --noEmit`、`npm run build` 與 `./scripts/backup.sh`。 +3. 提交並推送 `main`,確認 Gitea Actions 的 verify workflow 成功;它會執行 production dependency audit、測試、Docker build,並產生 SPDX SBOM artifact。 +4. 建立 annotated tag,例如:`git tag -a v0.8.0 -m "Mebbling v0.8.0"`,再執行 `git push origin v0.8.0`。 +5. 在 Gitea 的 Releases 以同一個 tag 建立 release;若版本尚供測試,勾選 Pre-release。 + +## 映像簽章 + +目前 workflow 只建置本機 Docker image,尚未指定容器 registry,因此不會產生無法驗證的假簽章。要啟用 cosign,先決定可推送的 OCI registry 與 image 名稱,並在 Gitea Actions 設定 `COSIGN_PRIVATE_KEY`、`COSIGN_PASSWORD` 和 registry 登入 secret;之後將 push、`cosign sign` 與 `cosign verify` 加入 tag 工作。部署端應只接受已驗證的 tag digest。 + +## 升級 + +升級前先備份。將 `.env` 的 `MEBBLING_VERSION` 改為新 tag,執行 `docker compose pull`(若使用 registry)或重新建置後 `docker compose up -d --no-build`。確認 `/api/health?probe=ready` 與 `/api/metrics`,並觀察 worker log。資料庫 migration 會在 Web/Worker 啟動時自動執行,請勿在升級後直接回退到舊映像。 diff --git a/package.json b/package.json index 43f7b6e..e72d552 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "HUB_BUILD=1 next build", "start": "next start", "worker": "tsx worker/index.ts", - "test": "TMPDIR=/tmp tsx --test tests/**/*.test.ts" + "test": "TMPDIR=/tmp tsx --test tests/**/*.test.ts", + "sbom": "node scripts/generate-sbom.mjs" }, "keywords": [], "author": "", diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs new file mode 100644 index 0000000..be090a3 --- /dev/null +++ b/scripts/generate-sbom.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +const output = resolve(process.argv[2] || "artifacts/mebbling.spdx.json"); +const lock = JSON.parse(await readFile("package-lock.json", "utf8")); +const packages = Object.entries(lock.packages) + .filter(([path, value]) => path.startsWith("node_modules/") && value.version) + .map(([path, value]) => { + const name = path.slice("node_modules/".length); + return { SPDXID: `SPDXRef-Package-${name.replace(/[^A-Za-z0-9.-]/g, "-")}`, name, versionInfo: value.version, downloadLocation: value.resolved || "NOASSERTION", licenseConcluded: "NOASSERTION", licenseDeclared: value.license || "NOASSERTION", checksums: value.integrity ? [{ algorithm: "SHA512", checksumValue: value.integrity.replace(/^sha512-/, "") }] : [], externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: `pkg:npm/${encodeURIComponent(name).replace("%40", "@")}@${value.version}` }] }; + }); +const sbom = { spdxVersion: "SPDX-2.3", dataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT", name: "mebbling", documentNamespace: `https://gitea.fishking.studio/tangsongdayo/Mebbling/sbom/${lock.version}`, creationInfo: { created: new Date().toISOString(), creators: ["Tool: Mebbling package-lock SBOM generator"] }, packages: [{ SPDXID: "SPDXRef-Mebbling", name: lock.name, versionInfo: lock.version, downloadLocation: "NOASSERTION", licenseConcluded: "NOASSERTION", licenseDeclared: lock.packages[""].license || "NOASSERTION" }, ...packages], relationships: packages.map((item) => ({ spdxElementId: "SPDXRef-Mebbling", relationshipType: "DEPENDS_ON", relatedSpdxElement: item.SPDXID })) }; +await mkdir(dirname(output), { recursive: true }); +await writeFile(output, `${JSON.stringify(sbom, null, 2)}\n`); +console.log(`Generated SPDX SBOM with ${packages.length} packages: ${output}`);