From 06d05a89e76b9c80cf1ec134fa305385842be8a5 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:29:27 -0400 Subject: [PATCH 01/13] Add the live Chrome Web Store v2.0 manifest as a baseline Copied from the 7c2ba1c tree, which matches the published CRX except for the update_url key the store adds. --- tools/live-manifest.json | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tools/live-manifest.json diff --git a/tools/live-manifest.json b/tools/live-manifest.json new file mode 100644 index 0000000..ce1f2f2 --- /dev/null +++ b/tools/live-manifest.json @@ -0,0 +1,49 @@ +{ + "manifest_version": 3, + "name": "ProScan - Amazon Product Scraper", + "version": "2.0", + "description": "Scrape Amazon seller products with analytics and AI-powered product insights", + "permissions": [ + "storage", + "downloads" + ], + "host_permissions": [ + "*://*.amazon.com/*", + "https://generativelanguage.googleapis.com/*" + ], + "action": { + "default_popup": "popup/popup.html", + "default_icon": { + "16": "assets/icons/icon16.png", + "48": "assets/icons/icon48.png", + "128": "assets/icons/icon128.png" + } + }, + "icons": { + "16": "assets/icons/icon16.png", + "48": "assets/icons/icon48.png", + "128": "assets/icons/icon128.png" + }, + "content_scripts": [ + { + "matches": ["*://*.amazon.com/*"], + "js": [ + "scripts/content/scraper.js", + "scripts/content/chatbot.js" + ], + "run_at": "document_idle" + } + ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + }, + "background": { + "service_worker": "scripts/background/service-worker.js" + }, + "web_accessible_resources": [ + { + "resources": ["styles/chatbot.css", "libs/*.js", "assets/icons/*.png"], + "matches": ["*://*.amazon.com/*"] + } + ] +} From 3876415796629f40a2ec33da4e9d00e6331ce491 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:30:08 -0400 Subject: [PATCH 02/13] Add a permission lock against the live manifest Fails on any added permission, optional permission, host, content script or web_accessible_resources match, or a new externally_connectable key. Removals pass. --- tools/permission-lock.mjs | 104 +++++++++++++++++++++++++++ tools/tests/permission-lock.test.mjs | 68 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 tools/permission-lock.mjs create mode 100644 tools/tests/permission-lock.test.mjs diff --git a/tools/permission-lock.mjs b/tools/permission-lock.mjs new file mode 100644 index 0000000..764e40f --- /dev/null +++ b/tools/permission-lock.mjs @@ -0,0 +1,104 @@ +// tools/permission-lock.mjs - fails if a manifest asks for anything the live +// store build does not already have. Removals pass; additions fail. +// +// node tools/permission-lock.mjs [manifest.json ...] +// +// Defaults to dist/manifest.json when it exists, plus the source manifest. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const LIVE_MANIFEST = path.join(ROOT, 'tools', 'live-manifest.json'); + +// Keys that grant access on their own. Removing one is fine; adding or +// changing one fails. +const OPAQUE_KEYS = [ + 'externally_connectable', + 'chrome_url_overrides', + 'declarative_net_request', + 'oauth2', + 'key', +]; + +function contentScriptMatches(m) { + const out = new Set(); + for (const cs of m.content_scripts ?? []) { + for (const p of cs.matches ?? []) out.add(p); + // include_globs widen what a match pattern covers + for (const p of cs.include_globs ?? []) out.add(`include_globs:${p}`); + if (cs.match_about_blank) out.add('match_about_blank'); + if (cs.match_origin_as_fallback) out.add('match_origin_as_fallback'); + if (cs.all_frames) out.add('all_frames'); + if (cs.world === 'MAIN') out.add('world:MAIN'); + } + return out; +} + +function warMatches(m) { + const out = new Set(); + for (const war of m.web_accessible_resources ?? []) { + for (const p of war.matches ?? []) out.add(p); + for (const id of war.extension_ids ?? []) out.add(`extension_ids:${id}`); + if (war.use_dynamic_url) out.add('use_dynamic_url'); + } + return out; +} + +function list(m, key) { + return new Set(m[key] ?? []); +} + +/** Returns a list of human-readable violations; empty means the lock holds. */ +export function checkPermissionLock(candidate, live) { + const problems = []; + const added = (label, cand, base) => { + for (const v of cand) if (!base.has(v)) problems.push(`${label} adds ${JSON.stringify(v)}`); + }; + + for (const key of ['permissions', 'optional_permissions', 'host_permissions', 'optional_host_permissions']) { + added(key, list(candidate, key), list(live, key)); + } + added('content_scripts', contentScriptMatches(candidate), contentScriptMatches(live)); + added('web_accessible_resources', warMatches(candidate), warMatches(live)); + + for (const key of OPAQUE_KEYS) { + if (!(key in candidate)) continue; + if (JSON.stringify(candidate[key]) !== JSON.stringify(live[key])) { + problems.push(key in live ? `"${key}" changed` : `new manifest key "${key}"`); + } + } + return problems; +} + +function main(argv) { + const live = JSON.parse(fs.readFileSync(LIVE_MANIFEST, 'utf8')); + let targets = argv; + if (targets.length === 0) { + targets = [path.join(ROOT, 'manifest.json')]; + const dist = path.join(ROOT, 'dist', 'manifest.json'); + if (fs.existsSync(dist)) targets.push(dist); + } + let failed = false; + for (const t of targets) { + const m = JSON.parse(fs.readFileSync(t, 'utf8')); + const problems = checkPermissionLock(m, live); + const rel = path.relative(ROOT, t); + if (problems.length) { + failed = true; + console.error(`[permission-lock] FAIL ${rel}:`); + for (const p of problems) console.error(` - ${p}`); + } else { + console.log(`[permission-lock] OK ${rel}`); + } + } + if (failed) { + console.error('Any new permission, host or match pattern makes every user re-approve the extension.'); + process.exit(1); + } +} + +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) main(process.argv.slice(2)); diff --git a/tools/tests/permission-lock.test.mjs b/tools/tests/permission-lock.test.mjs new file mode 100644 index 0000000..12ca0a2 --- /dev/null +++ b/tools/tests/permission-lock.test.mjs @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { checkPermissionLock, LIVE_MANIFEST } from '../permission-lock.mjs'; + +const live = JSON.parse(fs.readFileSync(LIVE_MANIFEST, 'utf8')); +const clone = () => structuredClone(live); + +test('the live manifest passes against itself', () => { + assert.deepEqual(checkPermissionLock(live, live), []); +}); + +test('removing permissions and hosts passes', () => { + const m = clone(); + m.permissions = ['storage']; + m.host_permissions = ['*://*.amazon.com/*']; + delete m.web_accessible_resources; + assert.deepEqual(checkPermissionLock(m, live), []); +}); + +test('an added host permission fails', () => { + const m = clone(); + m.host_permissions.push('https://firestore.googleapis.com/*'); + assert.match(checkPermissionLock(m, live).join('\n'), /host_permissions adds .*firestore/); +}); + +test('an added API permission fails', () => { + const m = clone(); + m.permissions.push('alarms'); + assert.equal(checkPermissionLock(m, live).length, 1); +}); + +test('optional permissions and optional hosts fail', () => { + const m = clone(); + m.optional_permissions = ['tabs']; + m.optional_host_permissions = ['']; + assert.equal(checkPermissionLock(m, live).length, 2); +}); + +test('a widened content script match fails', () => { + const m = clone(); + m.content_scripts[0].matches = ['*://*/*']; + assert.match(checkPermissionLock(m, live).join('\n'), /content_scripts adds/); +}); + +test('all_frames on a content script fails', () => { + const m = clone(); + m.content_scripts[0].all_frames = true; + assert.equal(checkPermissionLock(m, live).length, 1); +}); + +test('a widened web_accessible_resources match fails', () => { + const m = clone(); + m.web_accessible_resources[0].matches.push(''); + assert.equal(checkPermissionLock(m, live).length, 1); +}); + +test('a new externally_connectable key fails', () => { + const m = clone(); + m.externally_connectable = { matches: ['https://proscanbot.web.app/*'] }; + assert.match(checkPermissionLock(m, live).join('\n'), /externally_connectable/); +}); + +test('CSP changes are not permissions and pass', () => { + const m = clone(); + m.content_security_policy.extension_pages += "; connect-src 'self' https://firestore.googleapis.com"; + assert.deepEqual(checkPermissionLock(m, live), []); +}); From 824e51a96ac0ed3daa0c1811b48a0a1e1ae73880 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:30:17 -0400 Subject: [PATCH 03/13] Trim host permissions back to the live store set The Firebase Auth, token and Firestore endpoints answer CORS for the extension origin, so they only need CSP connect-src entries. Gemini is added to connect-src so the chatbot fetch is not blocked. Dev hosts, www.googleapis.com, *.firebaseapp.com and wasm-unsafe-eval are dropped. --- manifest.json | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/manifest.json b/manifest.json index 0c2f13b..737f226 100644 --- a/manifest.json +++ b/manifest.json @@ -9,14 +9,7 @@ ], "host_permissions": [ "*://*.amazon.com/*", - "https://generativelanguage.googleapis.com/*", - "https://identitytoolkit.googleapis.com/*", - "https://securetoken.googleapis.com/*", - "https://firestore.googleapis.com/*", - "https://www.googleapis.com/*", - "https://*.firebaseapp.com/*", - "http://127.0.0.1/*", - "http://localhost/*" + "https://generativelanguage.googleapis.com/*" ], "action": { "default_popup": "popup/popup.html", @@ -45,7 +38,7 @@ } ], "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://firestore.googleapis.com https://www.googleapis.com https://*.firebaseapp.com http://127.0.0.1:* http://localhost:*" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://firestore.googleapis.com https://generativelanguage.googleapis.com" }, "background": { "service_worker": "scripts/background/service-worker.js" From e0e4d09146a9c02f38c784249bfa4c77113ddf07 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:30:32 -0400 Subject: [PATCH 04/13] Bump to 2.1.0 and gate the version against the live one --- manifest.json | 104 +++++++++++++++--------------- tools/tests/version-gate.test.mjs | 23 +++++++ tools/version-gate.mjs | 54 ++++++++++++++++ 3 files changed, 129 insertions(+), 52 deletions(-) create mode 100644 tools/tests/version-gate.test.mjs create mode 100644 tools/version-gate.mjs diff --git a/manifest.json b/manifest.json index 737f226..40894c9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,52 +1,52 @@ -{ - "manifest_version": 3, - "name": "ProScan - Amazon Product Scraper", - "version": "2.0", - "description": "Scrape Amazon seller products with analytics and AI-powered product insights", - "permissions": [ - "storage", - "downloads" - ], - "host_permissions": [ - "*://*.amazon.com/*", - "https://generativelanguage.googleapis.com/*" - ], - "action": { - "default_popup": "popup/popup.html", - "default_icon": { - "16": "assets/icons/icon16.png", - "48": "assets/icons/icon48.png", - "128": "assets/icons/icon128.png" - } - }, - "icons": { - "16": "assets/icons/icon16.png", - "48": "assets/icons/icon48.png", - "128": "assets/icons/icon128.png" - }, - "content_scripts": [ - { - "matches": ["*://*.amazon.com/*"], - "js": [ - "scripts/modules/price.js", - "scripts/modules/delta.js", - "scripts/content/scraper.js", - "scripts/content/chatbot.js", - "scripts/content/offer-fetcher.js" - ], - "run_at": "document_idle" - } - ], - "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://firestore.googleapis.com https://generativelanguage.googleapis.com" - }, - "background": { - "service_worker": "scripts/background/service-worker.js" - }, - "web_accessible_resources": [ - { - "resources": ["styles/chatbot.css", "libs/*.js", "assets/icons/*.png"], - "matches": ["*://*.amazon.com/*"] - } - ] -} +{ + "manifest_version": 3, + "name": "ProScan - Amazon Product Scraper", + "version": "2.1.0", + "description": "Scrape Amazon seller products with analytics and AI-powered product insights", + "permissions": [ + "storage", + "downloads" + ], + "host_permissions": [ + "*://*.amazon.com/*", + "https://generativelanguage.googleapis.com/*" + ], + "action": { + "default_popup": "popup/popup.html", + "default_icon": { + "16": "assets/icons/icon16.png", + "48": "assets/icons/icon48.png", + "128": "assets/icons/icon128.png" + } + }, + "icons": { + "16": "assets/icons/icon16.png", + "48": "assets/icons/icon48.png", + "128": "assets/icons/icon128.png" + }, + "content_scripts": [ + { + "matches": ["*://*.amazon.com/*"], + "js": [ + "scripts/modules/price.js", + "scripts/modules/delta.js", + "scripts/content/scraper.js", + "scripts/content/chatbot.js", + "scripts/content/offer-fetcher.js" + ], + "run_at": "document_idle" + } + ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://firestore.googleapis.com https://generativelanguage.googleapis.com" + }, + "background": { + "service_worker": "scripts/background/service-worker.js" + }, + "web_accessible_resources": [ + { + "resources": ["styles/chatbot.css", "libs/*.js", "assets/icons/*.png"], + "matches": ["*://*.amazon.com/*"] + } + ] +} diff --git a/tools/tests/version-gate.test.mjs b/tools/tests/version-gate.test.mjs new file mode 100644 index 0000000..91dc6d2 --- /dev/null +++ b/tools/tests/version-gate.test.mjs @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { compareVersions, parseVersion } from '../version-gate.mjs'; + +test('compares dotted versions numerically', () => { + assert.equal(compareVersions('2.1.0', '2.0'), 1); + assert.equal(compareVersions('2.0.0', '2.0'), 0); + assert.equal(compareVersions('2.10', '2.9'), 1); + assert.equal(compareVersions('1.9.9.9', '2.0'), -1); +}); + +test('rejects versions Chrome would reject', () => { + for (const v of ['2.1.0-beta', '02.1', '1.2.3.4.5', '70000', '', 'v2']) { + assert.throws(() => parseVersion(v), /invalid/); + } +}); + +test('the source manifest is above the live version', () => { + const src = JSON.parse(fs.readFileSync(new URL('../../manifest.json', import.meta.url))); + const live = JSON.parse(fs.readFileSync(new URL('../live-manifest.json', import.meta.url))); + assert.equal(compareVersions(src.version, live.version), 1); +}); diff --git a/tools/version-gate.mjs b/tools/version-gate.mjs new file mode 100644 index 0000000..14476fa --- /dev/null +++ b/tools/version-gate.mjs @@ -0,0 +1,54 @@ +// tools/version-gate.mjs - the store rejects an upload whose version is not +// above the published one. Fails unless the manifest version is greater than +// tools/live-manifest.json. +// +// node tools/version-gate.mjs [manifest.json] + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { LIVE_MANIFEST } from './permission-lock.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// Chrome versions are 1 to 4 dot-separated integers, 0 to 65535, no leading zeros. +export function parseVersion(v) { + if (typeof v !== 'string' || !/^(0|[1-9]\d{0,4})(\.(0|[1-9]\d{0,4})){0,3}$/.test(v)) { + throw new Error(`invalid Chrome version "${v}"`); + } + const parts = v.split('.').map(Number); + if (parts.some((n) => n > 65535)) throw new Error(`invalid Chrome version "${v}"`); + return parts; +} + +export function compareVersions(a, b) { + const pa = parseVersion(a); + const pb = parseVersion(b); + for (let i = 0; i < 4; i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return Math.sign(d); + } + return 0; +} + +function main(argv) { + const target = argv[0] ?? path.join(ROOT, 'manifest.json'); + const live = JSON.parse(fs.readFileSync(LIVE_MANIFEST, 'utf8')).version; + const built = JSON.parse(fs.readFileSync(target, 'utf8')).version; + let cmp; + try { + cmp = compareVersions(built, live); + } catch (err) { + console.error(`[version-gate] FAIL: ${err.message}`); + process.exit(1); + } + if (cmp <= 0) { + console.error(`[version-gate] FAIL: version ${built} is not above the live ${live}`); + process.exit(1); + } + console.log(`[version-gate] OK: ${built} > live ${live}`); +} + +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) main(process.argv.slice(2)); From e7e0fb4bcd8ed17ea1c0d8108889b08572b7709a Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:30:47 -0400 Subject: [PATCH 05/13] Remove the embedded Gemini key The key is revoked, so every chat request was failing with it anyway. With no user key set, the worker now returns a plain error the chat bubble shows. Bring-your-own-key comes back with the chatbot fix. --- scripts/background/service-worker.js | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/scripts/background/service-worker.js b/scripts/background/service-worker.js index affda8e..303e792 100644 --- a/scripts/background/service-worker.js +++ b/scripts/background/service-worker.js @@ -23,21 +23,6 @@ import { syncToCloud } from './sync.js'; /** @const {string} Gemini API endpoint for content generation */ const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'; -/** - * Fallback API key (base64-encoded). Used only when the user hasn't - * configured their own key via the popup settings panel. - * @const {string} - * @private - */ -const _t = 'QUl6YVN5RHdfOVhQLXRpQ0tLX3lkQThCd0ZrZUpxNWdTdTAxNUhj'; - -/** - * Decode the fallback API key. - * @returns {string} Decoded API key - * @private - */ -const _dk = () => atob(_t); - /** * Main message listener -- routes messages between extension components. * @@ -74,17 +59,19 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { * Handle a chat message by calling the Gemini 2.0 Flash API. * * Builds a prompt with the system role, product context, and user question. - * Uses the user's API key from chrome.storage if available, otherwise - * falls back to the built-in key. + * Uses the user's API key from chrome.storage. There is no built-in key. * * @param {string} question - User's natural language question * @param {Object[]} products - Array of product objects for context * @returns {Promise} AI-generated response text - * @throws {Error} On invalid API key or Gemini API failure + * @throws {Error} On a missing or invalid API key, or Gemini API failure */ async function handleChatMessage(question, products) { const data = await chrome.storage.local.get(['geminiApiKey']); - const apiKey = data.geminiApiKey || _dk(); + const apiKey = data.geminiApiKey; + if (!apiKey) { + throw new Error('AI chat needs a Gemini API key, and none is set.'); + } const productCount = products.length; const productList = products.map(p => From d81db6d807e8afedd33a1672a8a4ed084efabaf0 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:31:39 -0400 Subject: [PATCH 06/13] Add a secret scan for API keys and private keys Decodes long base64 runs too, which is how the old Gemini key was hidden. The Firebase web config key is a public identifier and is allowlisted by exact value. --- tools/secret-scan.mjs | 108 +++++++++++++++++++++++++++++++ tools/tests/secret-scan.test.mjs | 43 ++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 tools/secret-scan.mjs create mode 100644 tools/tests/secret-scan.test.mjs diff --git a/tools/secret-scan.mjs b/tools/secret-scan.mjs new file mode 100644 index 0000000..8935479 --- /dev/null +++ b/tools/secret-scan.mjs @@ -0,0 +1,108 @@ +// tools/secret-scan.mjs - fails on API keys and private keys in the repo or +// the build output. Also decodes long base64 runs, since the old Gemini key +// was shipped base64-encoded. +// +// node tools/secret-scan.mjs [dir ...] +// +// With no args it scans the files git tracks (plus untracked, unignored ones), +// and dist/ if it exists. + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// Firebase web API keys are public project identifiers, not secrets. Access is +// governed by the Firestore rules. List each one here explicitly. +export const ALLOWED = new Set([ + 'AIzaSyAp0HrcvFwpMxrlqbxa9xjUvwGoTa7QpUU', // proscanbot web app config +]); + +const RULES = [ + { name: 'Google API key', re: /AIza[0-9A-Za-z_-]{35}/g }, + { name: 'Google OAuth client secret', re: /GOCSPX-[0-9A-Za-z_-]{28}/g }, + { name: 'private key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----/g }, + { name: 'service account key', re: /"private_key_id"\s*:\s*"[0-9a-f]{40}"/g }, + { name: 'OpenAI or Anthropic key', re: /\bsk-(?:ant-|proj-)?[0-9A-Za-z_-]{32,}/g }, + { name: 'GitHub token', re: /\bgh[pousr]_[0-9A-Za-z]{36,}/g }, +]; + +const BINARY_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.zip', '.crx', '.woff', '.woff2', '.pdf']); + +function scanPlain(text) { + const hits = []; + for (const { name, re } of RULES) { + for (const m of text.matchAll(re)) { + if (!ALLOWED.has(m[0])) hits.push({ name, value: m[0], index: m.index }); + } + } + return hits; +} + +/** Returns [{name, value, line}] for every secret found in `text`. */ +export function scanText(text) { + const hits = scanPlain(text); + for (const m of text.matchAll(/[A-Za-z0-9+/_-]{40,}={0,2}/g)) { + const run = m[0].replace(/-/g, '+').replace(/_/g, '/'); + // A key inside a longer blob can sit at any of four alignments. + for (let k = 0; k < 4; k++) { + const decoded = Buffer.from(run.slice(k), 'base64').toString('latin1'); + const found = scanPlain(decoded); + if (found.length) { + hits.push({ name: `base64-encoded ${found[0].name}`, value: m[0], index: m.index }); + break; + } + } + } + return hits.map(({ name, value, index }) => ({ + name, + value, + line: text.slice(0, index).split('\n').length, + })); +} + +function listRepoFiles() { + const out = execFileSync('git', ['ls-files', '-co', '--exclude-standard', '-z'], { cwd: ROOT }); + return out.toString('utf8').split('\0').filter(Boolean).map((p) => path.join(ROOT, p)); +} + +function listDir(dir) { + const out = []; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) out.push(...listDir(p)); + else out.push(p); + } + return out; +} + +function main(argv) { + let files; + if (argv.length) { + files = argv.flatMap((d) => (fs.statSync(d).isDirectory() ? listDir(d) : [d])); + } else { + files = listRepoFiles(); + const dist = path.join(ROOT, 'dist'); + if (fs.existsSync(dist)) files.push(...listDir(dist)); + } + let count = 0; + for (const f of files) { + if (BINARY_EXT.has(path.extname(f).toLowerCase()) || !fs.existsSync(f)) continue; + for (const h of scanText(fs.readFileSync(f, 'utf8'))) { + count++; + const shown = h.value.length > 12 ? `${h.value.slice(0, 8)}...` : h.value; + console.error(`[secret-scan] ${path.relative(ROOT, f)}:${h.line} ${h.name} (${shown})`); + } + } + if (count) { + console.error(`[secret-scan] FAIL: ${count} secret(s) found. Remove them; revoke any that shipped.`); + process.exit(1); + } + console.log(`[secret-scan] OK: ${files.length} files scanned`); +} + +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) main(process.argv.slice(2)); diff --git a/tools/tests/secret-scan.test.mjs b/tools/tests/secret-scan.test.mjs new file mode 100644 index 0000000..2011943 --- /dev/null +++ b/tools/tests/secret-scan.test.mjs @@ -0,0 +1,43 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { scanText, ALLOWED } from '../secret-scan.mjs'; + +// Built at runtime so this file does not trip the scan itself. +const fakeKey = 'AI' + 'za' + 'Sy' + 'Q'.repeat(33); + +test('finds a plain Google API key', () => { + const hits = scanText(`const k = '${fakeKey}';`); + assert.equal(hits.length, 1); + assert.equal(hits[0].name, 'Google API key'); +}); + +test('finds a base64-encoded key, as the old Gemini fallback was', () => { + const b64 = Buffer.from(fakeKey).toString('base64'); + const hits = scanText(`const _t = '${b64}';`); + assert.equal(hits.length, 1); + assert.match(hits[0].name, /base64-encoded/); +}); + +test('finds a base64 key at an odd offset inside a longer blob', () => { + const b64 = Buffer.from('xy' + fakeKey + 'tail').toString('base64'); + assert.equal(scanText(b64).length, 1); +}); + +test('reports the line number', () => { + assert.equal(scanText(`a\nb\n${fakeKey}\n`)[0].line, 3); +}); + +test('allows the public Firebase web config key', () => { + const [key] = ALLOWED; + assert.deepEqual(scanText(`apiKey: '${key}'`), []); +}); + +test('finds private keys', () => { + const pem = '-----BEGIN ' + 'PRIVATE KEY-----\nabc\n'; + assert.equal(scanText(pem).length, 1); +}); + +test('ignores ordinary long identifiers and hashes', () => { + const text = 'sha512-' + 'a1b2c3d4'.repeat(11) + '\nconst someVeryLongIdentifierNameThatKeepsGoingAndGoing = 1;'; + assert.deepEqual(scanText(text), []); +}); From 640f823375a143f9c939310b3ca1b40a075a6cdf Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:35:51 -0400 Subject: [PATCH 07/13] Build the manifest per environment and fix the emulator flag The prod build writes manifest.json as is and refuses one that mentions a local host. PROSCAN_ENV=dev adds only the two emulator origins to CSP connect-src and sets a literal __PROSCAN_EMULATOR__ that esbuild folds, so the dev build really uses the emulators under demo-proscan and the prod bundle carries no emulator code. The old process.env check was always false inside the service worker. --- scripts/background/firebase-config.js | 35 ++++++---- tools/build.mjs | 93 ++++++++++++++++++--------- tools/tests/build.test.mjs | 50 ++++++++++++++ tools/zip.mjs | 2 +- 4 files changed, 137 insertions(+), 43 deletions(-) create mode 100644 tools/tests/build.test.mjs diff --git a/scripts/background/firebase-config.js b/scripts/background/firebase-config.js index ebd3912..a001848 100644 --- a/scripts/background/firebase-config.js +++ b/scripts/background/firebase-config.js @@ -1,15 +1,21 @@ /** * @fileoverview Firebase config for the ProScan extension. * - * Same Firebase project (proscanbot) for dev and prod — only the emulator - * wiring differs, gated by PROSCAN_ENV which esbuild inlines at build time - * (see tools/build.mjs `define`). Web API keys are public identifiers, not - * secrets; access is governed entirely by Firestore security rules. + * Prod talks to the proscanbot project. The dev build + * (`PROSCAN_ENV=dev npm run build`) sets the literal __PROSCAN_EMULATOR__ + * through esbuild `define` and points at the local emulator suite under the + * demo-proscan project, the same id the dashboard dev build uses. Web API + * keys are public identifiers, not secrets; access is governed by the + * Firestore security rules. * * @module FirebaseConfig */ -export const FIREBASE_CONFIG = { +/* global __PROSCAN_EMULATOR__ */ +export const USE_EMULATOR = + typeof __PROSCAN_EMULATOR__ !== 'undefined' && __PROSCAN_EMULATOR__ === true; + +const PROD_CONFIG = { apiKey: 'AIzaSyAp0HrcvFwpMxrlqbxa9xjUvwGoTa7QpUU', authDomain: 'proscanbot.firebaseapp.com', projectId: 'proscanbot', @@ -18,10 +24,17 @@ export const FIREBASE_CONFIG = { messagingSenderId: '886322190589', }; -// Build dev with `PROSCAN_ENV=dev npm run build` to point at the local emulator. -export const USE_EMULATOR = - typeof process !== 'undefined' && - process.env && - process.env.PROSCAN_ENV === 'dev'; +// demo-* projects only ever talk to the emulators. Written inline so the +// prod build folds them away. +export const FIREBASE_CONFIG = USE_EMULATOR + ? { + ...PROD_CONFIG, + authDomain: 'demo-proscan.firebaseapp.com', + projectId: 'demo-proscan', + storageBucket: 'demo-proscan.appspot.com', + } + : PROD_CONFIG; -export const EMULATOR = { host: '127.0.0.1', authPort: 9099, firestorePort: 8080 }; +export const EMULATOR = USE_EMULATOR + ? { host: '127.0.0.1', authPort: 9099, firestorePort: 8080 } + : null; diff --git a/tools/build.mjs b/tools/build.mjs index 6657508..767eed2 100644 --- a/tools/build.mjs +++ b/tools/build.mjs @@ -1,10 +1,10 @@ // tools/build.mjs — builds the extension into dist/. // -// - Bundles exactly one entry (the MV3 service worker) with esbuild. Today the -// worker has no imports, so output is semantically identical to the source; -// this establishes the M3 path for firebase/* npm imports. -// - Copies every other shipped file VERBATIM, preserving paths, so -// manifest.json needs zero rewriting. +// - Bundles exactly one entry (the MV3 service worker, with firebase/*) with +// esbuild. +// - Copies every other shipped file verbatim, preserving paths. manifest.json +// is the prod manifest; `PROSCAN_ENV=dev` adds the emulator origins to +// connect-src (see manifestForEnv) and points Firebase at demo-proscan. // - Popup files and scripts/modules/* are deliberately NOT bundled: // popup.html loads them as plain ' + ); + assert.deepEqual(htmlReferencesMissing(dir), ['popup/popup.html -> ../gone.js']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From fc0032dcfd95e9cfc85518077e223cd8575fbd26 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:36:30 -0400 Subject: [PATCH 09/13] Gate the store zip on permissions, version, secrets and prod build Zips now go to dist-zips/ named with the version and commit, so they can no longer be mistaken for the live build. Markdown, zips, sourcemaps and dev builds are refused. --- .gitignore | 1 + tools/tests/zip.test.mjs | 56 +++++++++++++++ tools/zip.mjs | 151 ++++++++++++++++++++++++++------------- 3 files changed, 158 insertions(+), 50 deletions(-) create mode 100644 tools/tests/zip.test.mjs diff --git a/.gitignore b/.gitignore index 076aaa6..8dfb469 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage/ dist/ build/ *.zip +dist-zips/ diff --git a/tools/tests/zip.test.mjs b/tools/tests/zip.test.mjs new file mode 100644 index 0000000..7eb600e --- /dev/null +++ b/tools/tests/zip.test.mjs @@ -0,0 +1,56 @@ +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { buildExtension } from '../build.mjs'; +import { zipGateProblems } from '../zip.mjs'; + +let dir; +before(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'proscan-zip-')); +}); +after(() => fs.rmSync(dir, { recursive: true, force: true })); + +const fresh = (env = 'prod') => buildExtension({ env, outDir: dir }); + +test('a clean prod build passes every gate', async () => { + await fresh(); + assert.deepEqual(zipGateProblems(dir), {}); +}); + +test('stray zips, markdown and server files are rejected', async () => { + await fresh(); + fs.writeFileSync(path.join(dir, 'proscan-v2.0.zip'), 'x'); + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), 'x'); + fs.mkdirSync(path.join(dir, 'server')); + fs.writeFileSync(path.join(dir, 'server/app.py'), 'x'); + const p = zipGateProblems(dir); + assert.equal(p.ALLOWLIST.length, 3); + assert.equal(p['LEGACY TRIPWIRE'].length, 3); +}); + +test('a dev build is refused', async () => { + await fresh('dev'); + assert.ok(zipGateProblems(dir)['PROD ONLY']); +}); + +test('an escalated or unbumped manifest is refused', async () => { + await fresh(); + const mp = path.join(dir, 'manifest.json'); + const m = JSON.parse(fs.readFileSync(mp, 'utf8')); + m.host_permissions.push('https://firestore.googleapis.com/*'); + m.version = '2.0'; + fs.writeFileSync(mp, JSON.stringify(m)); + const p = zipGateProblems(dir); + assert.ok(p['PERMISSION LOCK']); + assert.ok(p['VERSION GATE']); +}); + +test('a key in the bundle is refused', async () => { + await fresh(); + const swPath = path.join(dir, 'scripts/background/service-worker.js'); + const key = Buffer.from('AI' + 'zaSy' + 'Z'.repeat(33)).toString('base64'); + fs.appendFileSync(swPath, `\nconst _t = '${key}';\n`); + assert.ok(zipGateProblems(dir)['SECRET SCAN']); +}); diff --git a/tools/zip.mjs b/tools/zip.mjs index e58030a..87ca8fd 100644 --- a/tools/zip.mjs +++ b/tools/zip.mjs @@ -1,51 +1,40 @@ -// tools/zip.mjs — packages dist/ ONLY into proscan-v{manifest.version}.zip -// at the repo root, behind three hard gates (each exits nonzero on failure): +// tools/zip.mjs - packages dist/ ONLY into +// dist-zips/proscan-v{version}-{sha}.zip, behind hard gates (any failure exits +// nonzero and writes nothing): // -// 1. ALLOWLIST — every file under dist/ must be in the explicit +// 1. ALLOWLIST - every file under dist/ must be in the explicit // allowlist (the exact build output set; no sourcemaps, // no strays). -// 2. LEGACY TRIPWIRE — no archive path may match a known legacy/dead-weight -// pattern (v1 root files, server/, tests, docs, etc.). -// 3. MANIFEST CLOSURE — every file manifest.json references must be present -// in the archive. +// 2. LEGACY TRIPWIRE - no archive path may match a known legacy/dead-weight +// pattern (v1 root files, server/, tests, docs, zips). +// 3. MANIFEST CLOSURE - every file manifest.json references must be present. +// 4. PROD ONLY - a dev build (emulator origins) never gets zipped. +// 5. PERMISSION LOCK - nothing added over tools/live-manifest.json. +// 6. VERSION GATE - version above the live one. +// 7. SECRET SCAN - no API keys in the archive. // -// Uses adm-zip; never shells out, never zips the repo root. +// Uses adm-zip; never shells out for packaging, never zips the repo root. import AdmZip from 'adm-zip'; import fs from 'node:fs'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { COPY_FILES, BUNDLE_ENTRY, manifestClosureMissing, + htmlReferencesMissing, listFilesRecursive, } from './build.mjs'; +import { checkPermissionLock, LIVE_MANIFEST } from './permission-lock.mjs'; +import { compareVersions } from './version-gate.mjs'; +import { scanText } from './secret-scan.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const DIST = path.join(ROOT, 'dist'); +export const ZIP_DIR = path.join(ROOT, 'dist-zips'); -function fail(gate, lines) { - console.error(`[zip] FAIL (${gate}):`); - for (const line of lines) console.error(` - ${line}`); - process.exit(1); -} - -if (!fs.existsSync(path.join(DIST, 'manifest.json'))) { - fail('PRECONDITION', ['dist/ is missing or has no manifest.json — run `npm run build` first.']); -} - -// Archive contents = every file under dist/, recursively (forward-slash paths). -const archivePaths = listFilesRecursive(DIST).sort(); - -// ---- Gate 1: ALLOWLIST ----------------------------------------------------- -const ALLOWLIST = new Set([...COPY_FILES, BUNDLE_ENTRY, 'manifest.json']); -const notAllowed = archivePaths.filter((p) => !ALLOWLIST.has(p)); -if (notAllowed.length > 0) { - fail('ALLOWLIST', notAllowed.map((p) => `unexpected file in dist/: ${p}`)); -} - -// ---- Gate 2: LEGACY TRIPWIRE ----------------------------------------------- const LEGACY_PATTERNS = [ /^(popup|background|contentscript)\.(js|html|css)$/, // legacy v1 root files /^icon(16|48|128)\.png$/, // legacy root icons @@ -58,32 +47,94 @@ const LEGACY_PATTERNS = [ /(^|\/)node_modules(\/|$)/, /(^|\/)\.pytest_cache(\/|$)/, /(^|\/)docs\//, - /(^|\/)README[^/]*$/, - /(^|\/)CLAUDE[^/]*$/, + /\.md$/i, + /\.(zip|crx)$/i, + /\.map$/, + /(^|\/)\.git(\/|$)/, ]; -const legacyHits = archivePaths.filter((p) => LEGACY_PATTERNS.some((re) => re.test(p))); -if (legacyHits.length > 0) { - fail('LEGACY TRIPWIRE', legacyHits.map((p) => `legacy/dead-weight path in archive: ${p}`)); + +/** Returns {gate: [problems]} for a built dist dir; empty object means all pass. */ +export function zipGateProblems(distDir) { + const problems = {}; + const add = (gate, list) => { + if (list.length) problems[gate] = list; + }; + const archivePaths = listFilesRecursive(distDir).sort(); + + const allow = new Set([...COPY_FILES, BUNDLE_ENTRY, 'manifest.json']); + add('ALLOWLIST', archivePaths.filter((p) => !allow.has(p)).map((p) => `unexpected file: ${p}`)); + add('LEGACY TRIPWIRE', archivePaths + .filter((p) => LEGACY_PATTERNS.some((re) => re.test(p))) + .map((p) => `legacy/dead-weight path: ${p}`)); + add('MANIFEST CLOSURE', [...manifestClosureMissing(distDir), ...htmlReferencesMissing(distDir)]); + + const manifestText = fs.readFileSync(path.join(distDir, 'manifest.json'), 'utf8'); + const manifest = JSON.parse(manifestText); + add('PROD ONLY', /localhost|127\.0\.0\.1/.test(manifestText) + ? ['manifest references a local host; this is a dev build (rebuild without PROSCAN_ENV=dev)'] + : []); + + const live = JSON.parse(fs.readFileSync(LIVE_MANIFEST, 'utf8')); + add('PERMISSION LOCK', checkPermissionLock(manifest, live)); + let versionProblem = []; + try { + if (compareVersions(manifest.version, live.version) <= 0) { + versionProblem = [`version ${manifest.version} is not above the live ${live.version}`]; + } + } catch (err) { + versionProblem = [err.message]; + } + add('VERSION GATE', versionProblem); + + const secrets = []; + for (const rel of archivePaths.filter((p) => !p.endsWith('.png'))) { + for (const h of scanText(fs.readFileSync(path.join(distDir, rel), 'utf8'))) { + secrets.push(`${rel}:${h.line} ${h.name}`); + } + } + add('SECRET SCAN', secrets); + return problems; } -// ---- Gate 3: MANIFEST CLOSURE ---------------------------------------------- -// The archive contains exactly the files under dist/, so closure against -// dist/ is closure against the archive. -const missing = manifestClosureMissing(DIST); -if (missing.length > 0) { - fail('MANIFEST CLOSURE', missing.map((p) => `manifest references file not in archive: ${p}`)); +function gitStamp() { + try { + const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim(); + const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: ROOT }).toString().trim(); + return dirty ? `${sha}-dirty` : sha; + } catch { + return 'nogit'; + } } -// ---- Package ---------------------------------------------------------------- -const manifest = JSON.parse(fs.readFileSync(path.join(DIST, 'manifest.json'), 'utf8')); -const outName = `proscan-v${manifest.version}.zip`; -const outPath = path.join(ROOT, outName); +function main() { + if (!fs.existsSync(path.join(DIST, 'manifest.json'))) { + console.error('[zip] FAIL: dist/ has no manifest.json. Run `npm run build` first.'); + process.exit(1); + } + const problems = zipGateProblems(DIST); + if (Object.keys(problems).length) { + for (const [gate, list] of Object.entries(problems)) { + console.error(`[zip] FAIL (${gate}):`); + for (const line of list) console.error(` - ${line}`); + } + process.exit(1); + } + + const archivePaths = listFilesRecursive(DIST).sort(); + const manifest = JSON.parse(fs.readFileSync(path.join(DIST, 'manifest.json'), 'utf8')); + const outName = `proscan-v${manifest.version}-${gitStamp()}.zip`; + fs.mkdirSync(ZIP_DIR, { recursive: true }); + + const zip = new AdmZip(); + for (const rel of archivePaths) { + const dir = path.posix.dirname(rel); + zip.addLocalFile(path.join(DIST, rel), dir === '.' ? '' : dir); + } + zip.writeZip(path.join(ZIP_DIR, outName)); -const zip = new AdmZip(); -for (const rel of archivePaths) { - const dir = path.posix.dirname(rel); - zip.addLocalFile(path.join(DIST, rel), dir === '.' ? '' : dir); + console.log(`[zip] OK: dist-zips/${outName} (${archivePaths.length} files, all gates passed).`); } -zip.writeZip(outPath); -console.log(`[zip] OK: ${outName} (${archivePaths.length} files, all gates passed).`); +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) main(); From 0b873c0fd84062218a4f26595cc6067d14abf771 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:37:04 -0400 Subject: [PATCH 10/13] Run the tool tests with npm test and add lock, scan and check scripts --- package.json | 9 +++++++-- tools/build.mjs | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index b2638c9..3b2be96 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,16 @@ "private": true, "scripts": { "build": "node tools/build.mjs", + "build:dev": "node tools/build.mjs --dev", "zip": "node tools/build.mjs && node tools/zip.mjs", - "test": "jest --verbose", + "test": "jest --verbose && npm run test:tools", "test:unit": "jest tests/unit --verbose", "test:integration": "jest tests/integration --verbose", - "test:coverage": "jest --coverage" + "test:tools": "node --test --test-concurrency=1 \"tools/tests/*.test.mjs\"", + "test:coverage": "jest --coverage", + "lock": "node tools/permission-lock.mjs && node tools/version-gate.mjs", + "scan:secrets": "node tools/secret-scan.mjs", + "check": "npm run build && npm run lock && npm run scan:secrets" }, "devDependencies": { "adm-zip": "^0.5.17", diff --git a/tools/build.mjs b/tools/build.mjs index a668c1c..2828c4e 100644 --- a/tools/build.mjs +++ b/tools/build.mjs @@ -134,7 +134,8 @@ export function htmlReferencesMissing(baseDir) { } async function main() { - const env = process.env.PROSCAN_ENV === 'dev' ? 'dev' : 'prod'; + const dev = process.env.PROSCAN_ENV === 'dev' || process.argv.includes('--dev'); + const env = dev ? 'dev' : 'prod'; try { const { fileCount } = await buildExtension({ env }); console.log(`[build] OK: ${env} dist/ built (${fileCount} files), closure verified.`); From 58bbc5ae12bebf773078515484eb65237609b8fc Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:37:10 -0400 Subject: [PATCH 11/13] Add CI: tests, permission lock, secret scan, build and zip gates --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..48acdf6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Jest + run: npx jest + + - name: Tool tests + run: npm run test:tools + + - name: Build + run: npm run build + + - name: Permission lock and version gate + run: npm run lock + + - name: Secret scan + run: npm run scan:secrets + + - name: Store zip gates + run: node tools/zip.mjs + + - uses: actions/upload-artifact@v4 + with: + name: store-zip + path: dist-zips/*.zip + if-no-files-found: error From 79aaaed1b7737b0096c6cc929040dc48bf18b089 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:37:21 -0400 Subject: [PATCH 12/13] Fix the README install steps and document the release checks Load unpacked has to point at dist/, and the clone URL was a 404. --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f72a155..ad3f444 100644 --- a/README.md +++ b/README.md @@ -125,14 +125,22 @@ See [docs/PRICE_SPREAD_ANALYSIS.md](docs/PRICE_SPREAD_ANALYSIS.md) for the full ## Installation -1. Clone the repository: +1. Clone the repository and build it: ```bash - git clone https://github.com/enesyilmaz7/AmazonSellerScraper.git + git clone https://github.com/EnesYilmazcode/AmazonSellerScraper.git + cd AmazonSellerScraper + npm ci + npm run build ``` 2. Open Chrome and navigate to `chrome://extensions/` 3. Enable **Developer mode** (top-right toggle) -4. Click **Load unpacked** and select the project folder -5. Click the ProScan extension icon → **Settings** → paste your [Gemini API key](https://aistudio.google.com/apikey) (free) +4. Click **Load unpacked** and select the `dist/` folder. The repo root does not load on its own, because the service worker has to be bundled. + +For local Firebase work, `npm run build:dev` points the build at the emulators under the `demo-proscan` project. + +## Release checks + +`npm test` runs the Jest suite and the tool tests. `npm run check` builds, then runs the permission lock (nothing may be added over `tools/live-manifest.json`, the published v2.0 manifest), the version gate and the secret scan. `npm run zip` writes the store package to `dist-zips/` and refuses a dev build, a stray file, or any gate failure. CI runs all of these. ## Usage From 8bb4b47d0cb479320254c98f912d673b6c87f831 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 16:13:48 -0400 Subject: [PATCH 13/13] Refuse a dirty tree in the zip builder and fail on unknown manifest keys The zip builder now exits nonzero on uncommitted changes unless --allow-dirty is passed. The permission lock fails on any new top-level manifest key that is not in the live manifest or a short safe list, so keys like chrome_settings_overrides cannot pass unnoticed. --- README.md | 2 +- tools/permission-lock.mjs | 28 ++++++++++++++++++++++- tools/tests/permission-lock.test.mjs | 20 ++++++++++++++++ tools/tests/zip.test.mjs | 18 +++++++++++++++ tools/zip.mjs | 34 ++++++++++++++++++++-------- 5 files changed, 90 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ad3f444..eb0bd6d 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ For local Firebase work, `npm run build:dev` points the build at the emulators u ## Release checks -`npm test` runs the Jest suite and the tool tests. `npm run check` builds, then runs the permission lock (nothing may be added over `tools/live-manifest.json`, the published v2.0 manifest), the version gate and the secret scan. `npm run zip` writes the store package to `dist-zips/` and refuses a dev build, a stray file, or any gate failure. CI runs all of these. +`npm test` runs the Jest suite and the tool tests. `npm run check` builds, then runs the permission lock (nothing may be added over `tools/live-manifest.json`, the published v2.0 manifest), the version gate and the secret scan. `npm run zip` writes the store package to `dist-zips/` and refuses a dev build, a stray file, uncommitted changes (`node tools/zip.mjs --allow-dirty` overrides that for local tries), or any gate failure. CI runs all of these. ## Usage diff --git a/tools/permission-lock.mjs b/tools/permission-lock.mjs index 764e40f..d9764fb 100644 --- a/tools/permission-lock.mjs +++ b/tools/permission-lock.mjs @@ -14,7 +14,7 @@ export const LIVE_MANIFEST = path.join(ROOT, 'tools', 'live-manifest.json'); // Keys that grant access on their own. Removing one is fine; adding or // changing one fails. -const OPAQUE_KEYS = [ +export const OPAQUE_KEYS = [ 'externally_connectable', 'chrome_url_overrides', 'declarative_net_request', @@ -22,6 +22,24 @@ const OPAQUE_KEYS = [ 'key', ]; +// Top-level keys a manifest may add over the live one. Every other new key +// fails, so an unknown key (chrome_settings_overrides, devtools_page, +// automation, ...) cannot slip past the lock. +export const SAFE_NEW_KEYS = [ + 'author', + 'background', + 'content_security_policy', + 'default_locale', + 'description', + 'homepage_url', + 'icons', + 'minimum_chrome_version', + 'name', + 'short_name', + 'version', + 'version_name', +]; + function contentScriptMatches(m) { const out = new Set(); for (const cs of m.content_scripts ?? []) { @@ -63,6 +81,14 @@ export function checkPermissionLock(candidate, live) { added('content_scripts', contentScriptMatches(candidate), contentScriptMatches(live)); added('web_accessible_resources', warMatches(candidate), warMatches(live)); + const checkedAbove = new Set([ + 'permissions', 'optional_permissions', 'host_permissions', 'optional_host_permissions', + 'content_scripts', 'web_accessible_resources', ...OPAQUE_KEYS, + ]); + for (const key of Object.keys(candidate)) { + if (key in live || SAFE_NEW_KEYS.includes(key) || checkedAbove.has(key)) continue; + problems.push(`new manifest key "${key}"`); + } for (const key of OPAQUE_KEYS) { if (!(key in candidate)) continue; if (JSON.stringify(candidate[key]) !== JSON.stringify(live[key])) { diff --git a/tools/tests/permission-lock.test.mjs b/tools/tests/permission-lock.test.mjs index 12ca0a2..1411b87 100644 --- a/tools/tests/permission-lock.test.mjs +++ b/tools/tests/permission-lock.test.mjs @@ -66,3 +66,23 @@ test('CSP changes are not permissions and pass', () => { m.content_security_policy.extension_pages += "; connect-src 'self' https://firestore.googleapis.com"; assert.deepEqual(checkPermissionLock(m, live), []); }); + +test('any new top-level key outside the safe list fails', () => { + for (const [key, value] of [ + ['chrome_settings_overrides', { search_provider: { name: 'x', keyword: 'x', search_url: 'https://x.test/?q={searchTerms}', favicon_url: 'https://x.test/f.ico', encoding: 'UTF-8', is_default: true } }], + ['automation', { desktop: true }], + ['devtools_page', 'devtools.html'], + ['sandbox', { pages: ['sandbox.html'] }], + ]) { + const m = clone(); + m[key] = value; + assert.match(checkPermissionLock(m, live).join(' | '), new RegExp(`new manifest key "${key}"`), key); + } +}); + +test('safe new keys such as minimum_chrome_version pass', () => { + const m = clone(); + m.minimum_chrome_version = '116'; + m.version_name = '2.1 beta'; + assert.deepEqual(checkPermissionLock(m, live), []); +}); diff --git a/tools/tests/zip.test.mjs b/tools/tests/zip.test.mjs index 7eb600e..af526be 100644 --- a/tools/tests/zip.test.mjs +++ b/tools/tests/zip.test.mjs @@ -54,3 +54,21 @@ test('a key in the bundle is refused', async () => { fs.appendFileSync(swPath, `\nconst _t = '${key}';\n`); assert.ok(zipGateProblems(dir)['SECRET SCAN']); }); + +test('gitState reports uncommitted changes', async () => { + const { execFileSync } = await import('node:child_process'); + const { gitState } = await import('../zip.mjs'); + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'proscan-git-')); + try { + const git = (...a) => execFileSync('git', a, { cwd: repo }); + git('init', '-q'); + fs.writeFileSync(path.join(repo, 'a.txt'), '1'); + git('add', '.'); + git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'a'); + assert.deepEqual(gitState(repo).dirty, []); + fs.appendFileSync(path.join(repo, 'a.txt'), '2'); + assert.equal(gitState(repo).dirty.length, 1); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); diff --git a/tools/zip.mjs b/tools/zip.mjs index 87ca8fd..58bf163 100644 --- a/tools/zip.mjs +++ b/tools/zip.mjs @@ -12,6 +12,9 @@ // 5. PERMISSION LOCK - nothing added over tools/live-manifest.json. // 6. VERSION GATE - version above the live one. // 7. SECRET SCAN - no API keys in the archive. +// 8. CLEAN TREE - no uncommitted changes, so the zip matches a commit. +// `--allow-dirty` overrides it for local experiments and +// stamps the file name with -dirty. // // Uses adm-zip; never shells out for packaging, never zips the repo root. @@ -96,17 +99,28 @@ export function zipGateProblems(distDir) { return problems; } -function gitStamp() { +/** Returns {sha, dirty}; dirty lists `git status --porcelain` lines. */ +export function gitState(cwd = ROOT) { + const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd }).toString().trim(); + const dirty = execFileSync('git', ['status', '--porcelain'], { cwd }).toString().split(/\r?\n/).filter(Boolean); + return { sha, dirty }; +} + +function main(argv) { + const allowDirty = argv.includes('--allow-dirty'); + let git; try { - const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim(); - const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: ROOT }).toString().trim(); - return dirty ? `${sha}-dirty` : sha; - } catch { - return 'nogit'; + git = gitState(); + } catch (err) { + console.error(`[zip] FAIL (CLEAN TREE): cannot read git state (${err.message}).`); + process.exit(1); + } + if (git.dirty.length && !allowDirty) { + console.error('[zip] FAIL (CLEAN TREE): uncommitted changes; commit them or pass --allow-dirty:'); + for (const line of git.dirty.slice(0, 20)) console.error(` - ${line}`); + process.exit(1); } -} -function main() { if (!fs.existsSync(path.join(DIST, 'manifest.json'))) { console.error('[zip] FAIL: dist/ has no manifest.json. Run `npm run build` first.'); process.exit(1); @@ -122,7 +136,7 @@ function main() { const archivePaths = listFilesRecursive(DIST).sort(); const manifest = JSON.parse(fs.readFileSync(path.join(DIST, 'manifest.json'), 'utf8')); - const outName = `proscan-v${manifest.version}-${gitStamp()}.zip`; + const outName = `proscan-v${manifest.version}-${git.sha}${git.dirty.length ? '-dirty' : ''}.zip`; fs.mkdirSync(ZIP_DIR, { recursive: true }); const zip = new AdmZip(); @@ -137,4 +151,4 @@ function main() { const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); -if (invokedDirectly) main(); +if (invokedDirectly) main(process.argv.slice(2));