Skip to content

Commit fdd3be3

Browse files
v2.1.0: security hardening + cross-browser parity + release CI (#15)
Cherry-picks @anthonyonazure's closed PR #11 onto master post-Firefox port, adds Firefox parity for the nonce-validated interceptor bridge, and ships GH Actions for tag-driven releases plus PR validation. Closes #11 Co-Authored-By: Anthony <anthony@anthonyonazure.com>
1 parent 8d2f3fc commit fdd3be3

12 files changed

Lines changed: 395 additions & 31 deletions

File tree

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
8+
jobs:
9+
validate:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- name: Validate manifest JSON
15+
run: |
16+
python3 -c "import json,sys; json.load(open('manifest.json'))"
17+
python3 -c "import json,sys; json.load(open('manifest.firefox.json'))"
18+
19+
- name: Manifest versions must match
20+
run: |
21+
C=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
22+
F=$(python3 -c "import json; print(json.load(open('manifest.firefox.json'))['version'])")
23+
[ "$C" = "$F" ] || { echo "Chrome=$C Firefox=$F"; exit 1; }
24+
25+
- name: Build runs cleanly
26+
run: bash scripts/build.sh
27+
28+
- name: Install web-ext
29+
run: npm install -g web-ext
30+
31+
- name: web-ext lint (Firefox)
32+
run: web-ext lint --source-dir dist/firefox --self-hosted

.github/workflows/release.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
workflow_dispatch:
8+
inputs:
9+
tag:
10+
description: "Tag to build (e.g. v2.1.0). Leave blank to build current ref."
11+
required: false
12+
13+
permissions:
14+
contents: write
15+
16+
jobs:
17+
build:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v4
22+
with:
23+
ref: ${{ github.event.inputs.tag || github.ref }}
24+
25+
- name: Read version from manifest
26+
id: meta
27+
run: |
28+
VERSION=$(grep '"version"' manifest.json | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
29+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
30+
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
31+
32+
- name: Verify tag matches manifest version
33+
if: startsWith(github.ref, 'refs/tags/v')
34+
run: |
35+
TAG="${GITHUB_REF#refs/tags/}"
36+
if [ "$TAG" != "${{ steps.meta.outputs.tag }}" ] && [ "$TAG" != "${{ steps.meta.outputs.tag }}-firefox" ]; then
37+
echo "Tag $TAG does not match manifest version ${{ steps.meta.outputs.tag }}"
38+
exit 1
39+
fi
40+
41+
- name: Build Chrome + Firefox zips
42+
run: bash scripts/build.sh
43+
44+
- name: Compute checksums
45+
working-directory: dist
46+
run: |
47+
shasum -a 256 keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip > keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip.sha256
48+
shasum -a 256 keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip > keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip.sha256
49+
50+
- name: Upload build artifacts
51+
uses: actions/upload-artifact@v4
52+
with:
53+
name: keyfinder-v${{ steps.meta.outputs.version }}
54+
path: |
55+
dist/keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip
56+
dist/keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip
57+
dist/keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip.sha256
58+
dist/keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip.sha256
59+
if-no-files-found: error
60+
61+
- name: Attach to GitHub Release
62+
if: startsWith(github.ref, 'refs/tags/v')
63+
uses: softprops/action-gh-release@v2
64+
with:
65+
files: |
66+
dist/keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip
67+
dist/keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip
68+
dist/keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip.sha256
69+
dist/keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip.sha256
70+
generate_release_notes: true
71+
fail_on_unmatched_files: true

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
*.crx
33
*.pem
44
*.zip
5+
dist/
56
.idea/
67
.vscode/
78
*.swp

js/background.js

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,85 @@
11
const KEYWORDS_KEY = "kf_keywords";
22
const FINDINGS_KEY = "kf_findings";
3+
const MAX_FINDINGS = 5000;
4+
const MAX_KEYWORDS = 50;
5+
const MAX_KEYWORD_LENGTH = 50;
36

47
const DEFAULT_KEYWORDS = [
58
"key", "api_key", "apikey", "api-key", "secret", "token",
69
"access_token", "auth", "credential", "password",
710
"client_id", "client_secret"
811
];
912

13+
// Serialize all storage writes to prevent race conditions
14+
let storageQueue = Promise.resolve();
15+
function enqueue(fn) {
16+
storageQueue = storageQueue.then(fn, fn);
17+
return storageQueue;
18+
}
19+
20+
// --- Per-tab alert icon ---
21+
const alertTabs = new Set();
22+
let alertIconCache = null;
23+
24+
async function buildAlertIcons() {
25+
if (alertIconCache) return alertIconCache;
26+
const sizes = [16, 48];
27+
const imageData = {};
28+
for (const size of sizes) {
29+
const resp = await fetch(chrome.runtime.getURL(`icons/icon${size}.png`));
30+
const blob = await resp.blob();
31+
const bitmap = await createImageBitmap(blob);
32+
const canvas = new OffscreenCanvas(size, size);
33+
const ctx = canvas.getContext("2d");
34+
ctx.drawImage(bitmap, 0, 0, size, size);
35+
// Red alert dot in top-right
36+
const r = Math.max(3, Math.round(size * 0.22));
37+
const cx = size - r - 1;
38+
const cy = r + 1;
39+
ctx.beginPath();
40+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
41+
ctx.fillStyle = "#ff4444";
42+
ctx.fill();
43+
ctx.lineWidth = size >= 48 ? 2 : 1;
44+
ctx.strokeStyle = "#0f0f0f";
45+
ctx.stroke();
46+
imageData[size] = ctx.getImageData(0, 0, size, size);
47+
}
48+
alertIconCache = imageData;
49+
return imageData;
50+
}
51+
52+
async function setAlertIcon(tabId) {
53+
if (alertTabs.has(tabId)) return;
54+
alertTabs.add(tabId);
55+
try {
56+
const imageData = await buildAlertIcons();
57+
await chrome.action.setIcon({ tabId, imageData });
58+
} catch {}
59+
}
60+
61+
function resetTabIcon(tabId) {
62+
if (!alertTabs.delete(tabId)) return;
63+
try {
64+
chrome.action.setIcon({
65+
tabId,
66+
path: { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }
67+
});
68+
} catch {}
69+
}
70+
71+
// Reset icon when a tab navigates to a new page
72+
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
73+
if (changeInfo.status === "loading") {
74+
resetTabIcon(tabId);
75+
}
76+
});
77+
78+
// Clean up when a tab is closed
79+
chrome.tabs.onRemoved.addListener((tabId) => {
80+
alertTabs.delete(tabId);
81+
});
82+
1083
chrome.runtime.onInstalled.addListener(async (details) => {
1184
if (details.reason === "install") {
1285
await chrome.storage.local.set({
@@ -18,7 +91,8 @@ chrome.runtime.onInstalled.addListener(async (details) => {
1891

1992
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
2093
if (request.type === "finding") {
21-
saveFinding(request.data).then(() => sendResponse({ ok: true }));
94+
if (sender.tab?.id) setAlertIcon(sender.tab.id);
95+
enqueue(() => saveFinding(request.data)).then(() => sendResponse({ ok: true }));
2296
return true;
2397
}
2498
if (request.type === "getKeywords") {
@@ -30,19 +104,19 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
30104
return true;
31105
}
32106
if (request.type === "addKeyword") {
33-
addKeyword(request.keyword).then((result) => sendResponse(result));
107+
enqueue(() => addKeyword(request.keyword)).then((result) => sendResponse(result));
34108
return true;
35109
}
36110
if (request.type === "removeKeyword") {
37-
removeKeyword(request.keyword).then(() => sendResponse({ ok: true }));
111+
enqueue(() => removeKeyword(request.keyword)).then(() => sendResponse({ ok: true }));
38112
return true;
39113
}
40114
if (request.type === "removeFinding") {
41-
removeFinding(request.url).then(() => sendResponse({ ok: true }));
115+
enqueue(() => removeFinding(request.findingId)).then(() => sendResponse({ ok: true }));
42116
return true;
43117
}
44118
if (request.type === "clearFindings") {
45-
clearFindings().then(() => sendResponse({ ok: true }));
119+
enqueue(() => clearFindings()).then(() => sendResponse({ ok: true }));
46120
return true;
47121
}
48122
if (request.type === "exportFindings") {
@@ -60,6 +134,8 @@ async function addKeyword(keyword) {
60134
const keywords = await getKeywords();
61135
const normalized = keyword.trim().toLowerCase();
62136
if (!normalized) return { ok: false, error: "Keyword cannot be empty." };
137+
if (normalized.length > MAX_KEYWORD_LENGTH) return { ok: false, error: `Keyword must be ${MAX_KEYWORD_LENGTH} characters or fewer.` };
138+
if (keywords.length >= MAX_KEYWORDS) return { ok: false, error: `Maximum of ${MAX_KEYWORDS} keywords allowed.` };
63139
if (keywords.includes(normalized)) return { ok: false, error: "Keyword already exists." };
64140
keywords.push(normalized);
65141
await chrome.storage.local.set({ [KEYWORDS_KEY]: keywords });
@@ -82,17 +158,25 @@ async function saveFinding(finding) {
82158
(f) => f.url === finding.url && f.match === finding.match
83159
);
84160
if (isDuplicate) return;
161+
162+
finding.id = crypto.randomUUID();
85163
findings.push(finding);
164+
165+
// Evict oldest findings when cap is exceeded
166+
if (findings.length > MAX_FINDINGS) {
167+
findings.splice(0, findings.length - MAX_FINDINGS);
168+
}
169+
86170
await chrome.storage.local.set({ [FINDINGS_KEY]: findings });
87171

88172
const badgeCount = findings.length;
89173
chrome.action.setBadgeText({ text: badgeCount > 0 ? String(badgeCount) : "" });
90174
chrome.action.setBadgeBackgroundColor({ color: "#e74c3c" });
91175
}
92176

93-
async function removeFinding(url) {
177+
async function removeFinding(findingId) {
94178
const findings = await getFindings();
95-
const updated = findings.filter((f) => f.url !== url);
179+
const updated = findings.filter((f) => f.id !== findingId);
96180
await chrome.storage.local.set({ [FINDINGS_KEY]: updated });
97181
chrome.action.setBadgeText({ text: updated.length > 0 ? String(updated.length) : "" });
98182
}

0 commit comments

Comments
 (0)