From dd4d9545ffcab7b4e67328e7c702dca062bd9297 Mon Sep 17 00:00:00 2001 From: reflectme-source Date: Fri, 9 Oct 2026 15:26:01 +0200 Subject: [PATCH] fix: validate generated manifests for WXT and Plasmo --- README.md | 15 ++++++-- examples/plasmo-release-check.yml | 31 +++++++++++++++++ examples/wxt-release-check.yml | 31 +++++++++++++++++ test/validator.test.mjs | 57 ++++++++++++++++++++++++++++--- validate.mjs | 44 ++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 examples/plasmo-release-check.yml create mode 100644 examples/wxt-release-check.yml diff --git a/README.md b/README.md index 694a8e8..5b571de 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@v4 - name: Validate extension release id: extensionops - uses: reflectme-source/extensionops-action@v1.0.1 + uses: reflectme-source/extensionops-action@v1.0.2 # For supply-chain pinning, replace the tag with the immutable release commit SHA. with: manifest-path: manifest.json # Optional; omit for auto-discovery. @@ -46,13 +46,22 @@ jobs: if-no-files-found: warn ``` -**Version note:** Use the published `v1.0.1` tag, or pin an audited release commit SHA for stronger supply-chain guarantees. The action does not publish to any browser store. +**Version note:** Use the published `v1.0.2` tag, or pin an audited release commit SHA for stronger supply-chain guarantees. The action does not publish to any browser store. + +## Frameworks with generated manifests (WXT and Plasmo) + +Frameworks frequently generate the Chrome manifest during a production build. Validate **after** building so checks use the manifest you will actually ship, not a placeholder source file: + +- **WXT:** [Copyable WXT workflow](examples/wxt-release-check.yml) uses `pnpm wxt build` and `manifest-path: .output/chrome-mv3/manifest.json`. +- **Plasmo:** [Copyable Plasmo workflow](examples/plasmo-release-check.yml) uses `pnpm build` and `manifest-path: build/chrome-mv3-prod/manifest.json`. + +These examples use pnpm and assume your repository includes an appropriate lockfile and a compatible `packageManager` setting. Adapt install/build steps to your own project. The validation action does **not** need publishing secrets, submit releases, or inspect unpublished store accounts. It checks the selected generated manifest and reviewable repository source; it is **not** a complete binary audit of all bundled output. ## Inputs and outputs | Name | Direction | Description | | --- | --- | --- | -| `manifest-path` | Input | Optional path to `manifest.json`; leave blank for auto-discovery. | +| `manifest-path` | Input | Optional workspace-relative path to the actual `manifest.json`, including a generated build output. Leave blank for auto-discovery; a missing, invalid, or out-of-workspace explicit path fails validation. | | `score` | Output | Release Confidence Score for checks executed by this action. | | `report` | Output | Relative report path: `.extensionops/report.json`. | diff --git a/examples/plasmo-release-check.yml b/examples/plasmo-release-check.yml new file mode 100644 index 0000000..b3278f1 --- /dev/null +++ b/examples/plasmo-release-check.yml @@ -0,0 +1,31 @@ +# Plasmo + pnpm reference workflow. Adjust to your repository's build script. +name: Plasmo extension release validation +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + validate-extension: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Build extension + run: | + corepack enable + pnpm install --frozen-lockfile + pnpm build + - name: Validate built Chrome MV3 manifest and source + uses: reflectme-source/extensionops-action@v1.0.2 + with: + manifest-path: build/chrome-mv3-prod/manifest.json + - name: Save validation report + if: always() + uses: actions/upload-artifact@v4 + with: + name: extensionops-plasmo-evidence + path: .extensionops/report.json + if-no-files-found: warn diff --git a/examples/wxt-release-check.yml b/examples/wxt-release-check.yml new file mode 100644 index 0000000..5f088eb --- /dev/null +++ b/examples/wxt-release-check.yml @@ -0,0 +1,31 @@ +# WXT + pnpm reference workflow. Adjust to your repository's build script. +name: WXT extension release validation +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + validate-extension: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Build extension + run: | + corepack enable + pnpm install --frozen-lockfile + pnpm wxt build + - name: Validate built Chrome MV3 manifest and source + uses: reflectme-source/extensionops-action@v1.0.2 + with: + manifest-path: .output/chrome-mv3/manifest.json + - name: Save validation report + if: always() + uses: actions/upload-artifact@v4 + with: + name: extensionops-wxt-evidence + path: .extensionops/report.json + if-no-files-found: warn diff --git a/test/validator.test.mjs b/test/validator.test.mjs index 3a4f6fe..a0ac470 100644 --- a/test/validator.test.mjs +++ b/test/validator.test.mjs @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; const validator = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../validate.mjs"); -function validate(files) { +function validate(files, manifestPath = "") { const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "extensionops-action-")); try { for (const [relative, contents] of Object.entries(files)) { @@ -18,12 +18,13 @@ function validate(files) { } const execution = spawnSync(process.execPath, [validator], { cwd: workspace, - env: { ...process.env, GITHUB_WORKSPACE: workspace, GITHUB_OUTPUT: "" }, + env: { ...process.env, GITHUB_WORKSPACE: workspace, GITHUB_OUTPUT: "", EXTENSIONOPS_MANIFEST_PATH: manifestPath }, encoding: "utf8", }); assert.equal(execution.error, undefined, execution.stderr); - const report = JSON.parse(fs.readFileSync(path.join(workspace, ".extensionops/report.json"), "utf8")); - return { status: execution.status, report }; + const reportPath = path.join(workspace, ".extensionops/report.json"); + const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath, "utf8")) : null; + return { status: execution.status, report, stderr: execution.stderr }; } finally { fs.rmSync(workspace, { recursive: true, force: true }); } @@ -58,3 +59,51 @@ test("still checks API permissions when extension manifest exists", () => { assert.equal(status, 0); assert.ok(report.findings.some(f => f.ruleId === "MISSING_API_PERMISSION" && f.severity === "high")); }); + +test("uses a generated Plasmo manifest inside the ignored build directory", () => { + const { status, report } = validate({ + "build/chrome-mv3-prod/manifest.json": JSON.stringify({ + manifest_version: 3, name: "Built extension", version: "1.0.0", permissions: ["history"], + }), + "src/content.js": "chrome.history.search({ text: '', maxResults: 5 });", + }, "build/chrome-mv3-prod/manifest.json"); + assert.equal(status, 0); + assert.equal(report.manifestSeen, true); + assert.ok(!report.findings.some(f => f.ruleId === "MISSING_API_PERMISSION")); +}); + +test("explicit WXT output manifest takes precedence over a root manifest", () => { + const { status, report } = validate({ + "manifest.json": JSON.stringify({ manifest_version: 3, name: "Old", version: "1.0.0", permissions: [] }), + ".output/chrome-mv3/manifest.json": JSON.stringify({ + manifest_version: 3, name: "Built", version: "1.0.0", permissions: ["history"], + }), + "src/content.js": "chrome.history.search({ text: '', maxResults: 5 });", + }, ".output/chrome-mv3/manifest.json"); + assert.equal(status, 0); + assert.equal(report.manifestSeen, true); + assert.ok(!report.findings.some(f => f.ruleId === "MISSING_API_PERMISSION")); +}); + +test("fails closed when configured manifest is missing", () => { + const { status, report, stderr } = validate({}, "build/chrome-mv3-prod/manifest.json"); + assert.notEqual(status, 0); + assert.equal(report, null); + assert.match(stderr, /ENOENT/); +}); + +test("rejects manifest paths that escape the workspace", () => { + const { status, report, stderr } = validate({}, "../manifest.json"); + assert.notEqual(status, 0); + assert.equal(report, null); + assert.match(stderr, /must stay inside/); +}); + +test("rejects malformed configured manifest JSON", () => { + const { status, report, stderr } = validate({ + ".output/chrome-mv3/manifest.json": "{broken", + }, ".output/chrome-mv3/manifest.json"); + assert.notEqual(status, 0); + assert.equal(report, null); + assert.match(stderr, /valid browser-extension manifest JSON/); +}); diff --git a/validate.mjs b/validate.mjs index c5b05f2..70d7150 100644 --- a/validate.mjs +++ b/validate.mjs @@ -738,7 +738,51 @@ function scanActiveManifests() { scanPermissionConsistency(active); } +function selectExplicitManifest() { + const requested = process.env.EXTENSIONOPS_MANIFEST_PATH?.trim(); + if (!requested) return; + + // This input is a repository-relative path, never an arbitrary filesystem path. + if (path.isAbsolute(requested) || path.win32.isAbsolute(requested)) { + throw new Error("manifest-path must be relative to the GitHub workspace."); + } + const absolute = path.resolve(root, requested); + const relative = path.relative(root, absolute); + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error("manifest-path must stay inside the GitHub workspace."); + } + if (path.basename(absolute).toLowerCase() !== "manifest.json") { + throw new Error("manifest-path must point to a manifest.json file."); + } + + // Resolve symlinks before reading, so a checked-in symlink cannot escape the workspace. + const rootReal = fs.realpathSync(root); + const fileReal = fs.realpathSync(absolute); + const realRelative = path.relative(rootReal, fileReal); + if (!realRelative || realRelative === ".." || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) { + throw new Error("manifest-path resolves outside the GitHub workspace."); + } + const stat = fs.statSync(fileReal); + if (!stat.isFile() || stat.size > 512_000) { + throw new Error("manifest-path must reference a regular manifest.json of at most 512 KB."); + } + const content = fs.readFileSync(fileReal, "utf8"); + try { + const parsed = JSON.parse(content); + if (!parsed || typeof parsed !== "object" || ![2, 3].includes(parsed.manifest_version)) { + throw new Error("Unsupported manifest version."); + } + } catch { + throw new Error("manifest-path must reference valid browser-extension manifest JSON."); + } + + // An explicit manifest is authoritative: do not silently use another target. + manifestFiles.length = 0; + manifestFiles.push({ file: relative.replaceAll("\\", "/"), content }); +} + walk(root); +selectExplicitManifest(); scanDependencyFiles(); scanActiveManifests();