diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1c3f8ef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate manifest JSON + run: | + python3 -c "import json,sys; json.load(open('manifest.json'))" + python3 -c "import json,sys; json.load(open('manifest.firefox.json'))" + + - name: Manifest versions must match + run: | + C=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])") + F=$(python3 -c "import json; print(json.load(open('manifest.firefox.json'))['version'])") + [ "$C" = "$F" ] || { echo "Chrome=$C Firefox=$F"; exit 1; } + + - name: Build runs cleanly + run: bash scripts/build.sh + + - name: Install web-ext + run: npm install -g web-ext + + - name: web-ext lint (Firefox) + run: web-ext lint --source-dir dist/firefox --self-hosted diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2bfc42e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,71 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Tag to build (e.g. v2.1.0). Leave blank to build current ref." + required: false + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Read version from manifest + id: meta + run: | + VERSION=$(grep '"version"' manifest.json | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Verify tag matches manifest version + if: startsWith(github.ref, 'refs/tags/v') + run: | + TAG="${GITHUB_REF#refs/tags/}" + if [ "$TAG" != "${{ steps.meta.outputs.tag }}" ] && [ "$TAG" != "${{ steps.meta.outputs.tag }}-firefox" ]; then + echo "Tag $TAG does not match manifest version ${{ steps.meta.outputs.tag }}" + exit 1 + fi + + - name: Build Chrome + Firefox zips + run: bash scripts/build.sh + + - name: Compute checksums + working-directory: dist + run: | + shasum -a 256 keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip > keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip.sha256 + shasum -a 256 keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip > keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip.sha256 + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: keyfinder-v${{ steps.meta.outputs.version }} + path: | + dist/keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip + dist/keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip + dist/keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip.sha256 + dist/keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip.sha256 + if-no-files-found: error + + - name: Attach to GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: | + dist/keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip + dist/keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip + dist/keyfinder-v${{ steps.meta.outputs.version }}-chrome.zip.sha256 + dist/keyfinder-v${{ steps.meta.outputs.version }}-firefox.zip.sha256 + generate_release_notes: true + fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index b50ae36..2c1625e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.crx *.pem *.zip +dist/ .idea/ .vscode/ *.swp diff --git a/js/background.js b/js/background.js index bf2592d..ee29977 100644 --- a/js/background.js +++ b/js/background.js @@ -1,5 +1,8 @@ const KEYWORDS_KEY = "kf_keywords"; const FINDINGS_KEY = "kf_findings"; +const MAX_FINDINGS = 5000; +const MAX_KEYWORDS = 50; +const MAX_KEYWORD_LENGTH = 50; const DEFAULT_KEYWORDS = [ "key", "api_key", "apikey", "api-key", "secret", "token", @@ -7,6 +10,76 @@ const DEFAULT_KEYWORDS = [ "client_id", "client_secret" ]; +// Serialize all storage writes to prevent race conditions +let storageQueue = Promise.resolve(); +function enqueue(fn) { + storageQueue = storageQueue.then(fn, fn); + return storageQueue; +} + +// --- Per-tab alert icon --- +const alertTabs = new Set(); +let alertIconCache = null; + +async function buildAlertIcons() { + if (alertIconCache) return alertIconCache; + const sizes = [16, 48]; + const imageData = {}; + for (const size of sizes) { + const resp = await fetch(chrome.runtime.getURL(`icons/icon${size}.png`)); + const blob = await resp.blob(); + const bitmap = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(size, size); + const ctx = canvas.getContext("2d"); + ctx.drawImage(bitmap, 0, 0, size, size); + // Red alert dot in top-right + const r = Math.max(3, Math.round(size * 0.22)); + const cx = size - r - 1; + const cy = r + 1; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fillStyle = "#ff4444"; + ctx.fill(); + ctx.lineWidth = size >= 48 ? 2 : 1; + ctx.strokeStyle = "#0f0f0f"; + ctx.stroke(); + imageData[size] = ctx.getImageData(0, 0, size, size); + } + alertIconCache = imageData; + return imageData; +} + +async function setAlertIcon(tabId) { + if (alertTabs.has(tabId)) return; + alertTabs.add(tabId); + try { + const imageData = await buildAlertIcons(); + await chrome.action.setIcon({ tabId, imageData }); + } catch {} +} + +function resetTabIcon(tabId) { + if (!alertTabs.delete(tabId)) return; + try { + chrome.action.setIcon({ + tabId, + path: { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" } + }); + } catch {} +} + +// Reset icon when a tab navigates to a new page +chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status === "loading") { + resetTabIcon(tabId); + } +}); + +// Clean up when a tab is closed +chrome.tabs.onRemoved.addListener((tabId) => { + alertTabs.delete(tabId); +}); + chrome.runtime.onInstalled.addListener(async (details) => { if (details.reason === "install") { await chrome.storage.local.set({ @@ -18,7 +91,8 @@ chrome.runtime.onInstalled.addListener(async (details) => { chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.type === "finding") { - saveFinding(request.data).then(() => sendResponse({ ok: true })); + if (sender.tab?.id) setAlertIcon(sender.tab.id); + enqueue(() => saveFinding(request.data)).then(() => sendResponse({ ok: true })); return true; } if (request.type === "getKeywords") { @@ -30,19 +104,19 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { return true; } if (request.type === "addKeyword") { - addKeyword(request.keyword).then((result) => sendResponse(result)); + enqueue(() => addKeyword(request.keyword)).then((result) => sendResponse(result)); return true; } if (request.type === "removeKeyword") { - removeKeyword(request.keyword).then(() => sendResponse({ ok: true })); + enqueue(() => removeKeyword(request.keyword)).then(() => sendResponse({ ok: true })); return true; } if (request.type === "removeFinding") { - removeFinding(request.url).then(() => sendResponse({ ok: true })); + enqueue(() => removeFinding(request.findingId)).then(() => sendResponse({ ok: true })); return true; } if (request.type === "clearFindings") { - clearFindings().then(() => sendResponse({ ok: true })); + enqueue(() => clearFindings()).then(() => sendResponse({ ok: true })); return true; } if (request.type === "exportFindings") { @@ -60,6 +134,8 @@ async function addKeyword(keyword) { const keywords = await getKeywords(); const normalized = keyword.trim().toLowerCase(); if (!normalized) return { ok: false, error: "Keyword cannot be empty." }; + if (normalized.length > MAX_KEYWORD_LENGTH) return { ok: false, error: `Keyword must be ${MAX_KEYWORD_LENGTH} characters or fewer.` }; + if (keywords.length >= MAX_KEYWORDS) return { ok: false, error: `Maximum of ${MAX_KEYWORDS} keywords allowed.` }; if (keywords.includes(normalized)) return { ok: false, error: "Keyword already exists." }; keywords.push(normalized); await chrome.storage.local.set({ [KEYWORDS_KEY]: keywords }); @@ -82,7 +158,15 @@ async function saveFinding(finding) { (f) => f.url === finding.url && f.match === finding.match ); if (isDuplicate) return; + + finding.id = crypto.randomUUID(); findings.push(finding); + + // Evict oldest findings when cap is exceeded + if (findings.length > MAX_FINDINGS) { + findings.splice(0, findings.length - MAX_FINDINGS); + } + await chrome.storage.local.set({ [FINDINGS_KEY]: findings }); const badgeCount = findings.length; @@ -90,9 +174,9 @@ async function saveFinding(finding) { chrome.action.setBadgeBackgroundColor({ color: "#e74c3c" }); } -async function removeFinding(url) { +async function removeFinding(findingId) { const findings = await getFindings(); - const updated = findings.filter((f) => f.url !== url); + const updated = findings.filter((f) => f.id !== findingId); await chrome.storage.local.set({ [FINDINGS_KEY]: updated }); chrome.action.setBadgeText({ text: updated.length > 0 ? String(updated.length) : "" }); } diff --git a/js/content.js b/js/content.js index 0af1ca9..23d713e 100644 --- a/js/content.js +++ b/js/content.js @@ -87,14 +87,17 @@ for (const kw of keywords) { const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Require word boundary around keyword to avoid matching "hotkey", "monkey", "turkey" for "key" const kwRegex = new RegExp( - `(?:${escaped})\\s*[:=]\\s*['"\`]([^'"\`\\n]{8,200})['"\`]`, + `(?:^|[^a-zA-Z])(?:${escaped})(?:[^a-zA-Z]|$)\\s*[:=]\\s*['"\`]([^'"\`\\n]{8,200})['"\`]`, "gi" ); let m; while ((m = kwRegex.exec(text)) !== null) { const val = m[1]; if (isFalsePositive(val)) continue; + // Skip values that look like JS function/method names (camelCase identifiers) + if (/^[a-z][a-zA-Z0-9_]*$/.test(val) && val.length < 60) continue; report({ url: sourceUrl, match: val.substring(0, 200), @@ -185,7 +188,16 @@ const name = (input.name || input.id || "").toLowerCase(); const value = input.value; if (!value || value.length < 8) continue; - const sensitive = ["token", "csrf", "api_key", "apikey", "secret", "auth", "session", "nonce", "key", "access_token"]; + // Skip known framework CSRF tokens — these are ephemeral anti-CSRF nonces, not secrets + const csrfNames = ["authenticity_token", "csrf_token", "csrf", "_csrf", "__requestverificationtoken", + "csrfmiddlewaretoken", "react-codespace-csrf", "_token", "xsrf-token", "anticsrf"]; + if (csrfNames.some((c) => name === c || name.startsWith(c))) continue; + // Skip common non-secret hidden fields + const benignNames = ["return_to", "redirect", "redirect_uri", "next", "ref", "referer", + "utm_source", "utm_medium", "utm_campaign", "notice_name", "host", "method", + "pinned_items_id_and_type[]", "repo_topics[]", "timestamp_secret"]; + if (benignNames.some((b) => name === b || name.startsWith(b))) continue; + const sensitive = ["api_key", "apikey", "secret_key", "access_token", "private_key", "password"]; if (sensitive.some((s) => name.includes(s)) || isHighEntropy(value)) { report({ url: pageUrl, match: `${name}=${value.substring(0, 100)}`, @@ -200,10 +212,20 @@ function scanDataAttributes() { const all = document.querySelectorAll("*"); + // Attribute names that contain "key" but are not secrets + const ignoredAttrs = [ + "data-hotkey", "data-hotkey-scope", "data-hotkey-within", // Keyboard shortcuts + "data-provider-key", // UI provider identifiers + "data-pjax-key", "data-turbo-key", // Framework routing keys + ]; for (const el of all) { for (const attr of el.attributes) { if (!/^data-.*(?:key|token|secret|auth|api|credential|password)/i.test(attr.name)) continue; if (!attr.value || attr.value.length < 8) continue; + // Skip known non-secret data attributes + if (ignoredAttrs.includes(attr.name)) continue; + // Skip if the value looks like a keyboard shortcut (contains Mod+, Shift+, etc.) + if (/(?:Mod|Shift|Alt|Ctrl|Meta)\+/i.test(attr.value)) continue; report({ url: pageUrl, match: `${attr.name}="${attr.value.substring(0, 100)}"`, type: "data-attribute", patternName: "Sensitive Data Attribute", @@ -226,6 +248,12 @@ function scanLinkHrefs() { const links = document.querySelectorAll("a[href], link[href]"); + // URL param names that look sensitive but aren't + const benignParams = ["author", "assignee", "reviewer", "creator", "user", "username", + "sort", "order", "page", "per_page", "tab", "type", "language", "q", "query", + "ref", "branch", "path", "since", "until", "direction", "state", "label", + "source", "plan", "return_to", "redirect", "onload", "render", "style", + "method", "host", "fromHostedPage", "countryBlackList"]; for (const link of links) { try { const href = link.href; @@ -233,8 +261,10 @@ const url = new URL(href); for (const [param, value] of url.searchParams) { const p = param.toLowerCase(); - const sensitive = ["key", "api_key", "apikey", "token", "secret", "access_token", "auth", "password", "session_id"]; - if (sensitive.some((s) => p.includes(s)) && value.length >= 8) { + if (benignParams.includes(p)) continue; + const sensitive = ["api_key", "apikey", "token", "secret", "access_token", "password", "session_id", "private_key"]; + // Require exact match on the param name, not substring — "author" was matching "auth" + if (sensitive.some((s) => p === s || p.endsWith(`_${s}`) || p.startsWith(`${s}_`)) && value.length >= 8) { report({ url: href, match: `${param}=${value.substring(0, 100)}`, type: "url-param", patternName: "Sensitive URL Parameter", @@ -251,6 +281,28 @@ { store: localStorage, label: "localStorage" }, { store: sessionStorage, label: "sessionStorage" }, ]; + // Keys that are known non-sensitive framework/platform storage — never flag these + const ignoredKeyPrefixes = [ + "ref-selector:", // GitHub branch selector cache + "jump_to:", // GitHub navigation cache + "soft-nav:", // GitHub SPA navigation state + "react-router-scroll", // React Router scroll positions + "COPILOT_SELECTED_MODEL", // GitHub Copilot UI preference + "rc::", // reCAPTCHA state + "debug:", // Debug flags + "ajs_", // Analytics.js state + "_ga", // Google Analytics + "intercom", // Intercom chat widget + "amplitude_", // Amplitude analytics + "mp_", // Mixpanel + "optimizely", // Optimizely experiments + ]; + // Specific exact keys that look sensitive but aren't + const ignoredExactKeys = [ + "COPILOT_AUTH_TOKEN", // GitHub Copilot ephemeral session (browser-local, not extractable) + "COPILOT_AUTH_TOKEN:expiry", + "id", // Generic session IDs in iframes (e.g., Stripe m.stripe.network) + ]; for (const { store, label } of stores) { try { for (let i = 0; i < store.length; i++) { @@ -258,7 +310,14 @@ const value = store.getItem(key); if (!value || value.length < 12) continue; const kl = key.toLowerCase(); - const sensitive = ["token", "key", "secret", "auth", "session", "credential", "password", "jwt", "bearer"]; + // Skip known benign keys + if (ignoredKeyPrefixes.some((p) => key.startsWith(p))) continue; + if (ignoredExactKeys.includes(key)) continue; + // Skip keys whose values are clearly JSON branch/ref data (GitHub caches) + if (value.startsWith('{"refs":') || value.startsWith('{"billing":')) continue; + const sensitive = ["token", "secret", "auth", "credential", "password", "jwt", "bearer", "private_key"]; + // Require a stronger match — "key" alone is too broad (matches "hotkey", "monkey", etc.) + // Remove "key" and "session" from sensitive list to reduce noise if (sensitive.some((s) => kl.includes(s)) || isHighEntropy(value.substring(0, 100))) { report({ url: pageUrl, match: `${label}.${key}=${value.substring(0, 120)}`, @@ -294,9 +353,13 @@ } catch {} } + const kfNonce = document.documentElement.getAttribute("data-kf-verify") || ""; + document.documentElement.removeAttribute("data-kf-verify"); + window.addEventListener("__kf_finding__", (e) => { const data = e.detail; if (!data) return; + if (data.__kfNonce !== kfNonce) return; if (data.rawText) { scanText(data.rawText, data.sourceUrl || pageUrl, data.type); @@ -327,6 +390,76 @@ scanCookies(); await scanExternalScripts(); + // Observe DOM mutations for SPA support + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + if (node.tagName === "SCRIPT") { + if (node.src) { + // New external script added + for (const kw of keywords) { + if (node.src.toLowerCase().includes(kw)) { + report({ + url: node.src, match: node.src, type: "script-src", + patternName: `Script URL contains: ${kw}`, + severity: "medium", confidence: "medium", provider: "URL Scan", + }); + } + } + } else if (node.textContent) { + scanText(node.textContent, pageUrl, "inline-script"); + } + } + // Scan any new hidden inputs + const hiddenInputs = node.matches && node.matches('input[type="hidden"]') + ? [node] + : (node.querySelectorAll ? Array.from(node.querySelectorAll('input[type="hidden"]')) : []); + for (const input of hiddenInputs) { + const name = (input.name || input.id || "").toLowerCase(); + const value = input.value; + if (!value || value.length < 8) continue; + // Skip known CSRF tokens + const csrfNames = ["authenticity_token", "csrf_token", "csrf", "_csrf", "__requestverificationtoken", + "csrfmiddlewaretoken", "react-codespace-csrf", "_token", "xsrf-token", "anticsrf"]; + if (csrfNames.some((c) => name === c || name.startsWith(c))) continue; + const benignNames = ["return_to", "redirect", "redirect_uri", "next", "ref", + "notice_name", "host", "method", "pinned_items_id_and_type[]", "repo_topics[]", "timestamp_secret"]; + if (benignNames.some((b) => name === b || name.startsWith(b))) continue; + const sensitive = ["api_key", "apikey", "secret_key", "access_token", "private_key", "password"]; + if (sensitive.some((s) => name.includes(s)) || isHighEntropy(value)) { + report({ + url: pageUrl, match: `${name}=${value.substring(0, 100)}`, + type: "hidden-input", patternName: "Hidden Form Field", + severity: isHighEntropy(value) ? "high" : "medium", + confidence: sensitive.some((s) => name.includes(s)) ? "high" : "medium", + provider: "DOM Scan", + }); + } + } + // Scan data attributes on new elements + const elementsToCheck = node.querySelectorAll ? [node, ...node.querySelectorAll("*")] : [node]; + const ignoredAttrsMut = ["data-hotkey", "data-hotkey-scope", "data-hotkey-within", "data-provider-key", "data-pjax-key", "data-turbo-key"]; + for (const el of elementsToCheck) { + if (!el.attributes) continue; + for (const attr of el.attributes) { + if (!/^data-.*(?:key|token|secret|auth|api|credential|password)/i.test(attr.name)) continue; + if (!attr.value || attr.value.length < 8) continue; + if (ignoredAttrsMut.includes(attr.name)) continue; + if (/(?:Mod|Shift|Alt|Ctrl|Meta)\+/i.test(attr.value)) continue; + report({ + url: pageUrl, match: `${attr.name}="${attr.value.substring(0, 100)}"`, + type: "data-attribute", patternName: "Sensitive Data Attribute", + severity: "medium", confidence: isHighEntropy(attr.value) ? "high" : "medium", + provider: "DOM Scan", + }); + } + } + } + } + }); + observer.observe(document.body || document.documentElement, { childList: true, subtree: true }); + if (seen.size > 0) { console.log(`[KeyFinder] ${seen.size} potential secret(s) found on ${pageDomain}`); } diff --git a/js/interceptor-loader.js b/js/interceptor-loader.js new file mode 100644 index 0000000..282f799 --- /dev/null +++ b/js/interceptor-loader.js @@ -0,0 +1,16 @@ +(function () { + "use strict"; + + const nonce = crypto.randomUUID(); + + // Store nonce where both MAIN world (interceptor) and ISOLATED world (content.js) can read it. + // The interceptor removes data-kf-nonce after reading; data-kf-verify stays for content.js. + const el = document.documentElement; + el.setAttribute("data-kf-nonce", nonce); + el.setAttribute("data-kf-verify", nonce); + + const script = document.createElement("script"); + script.src = chrome.runtime.getURL("js/interceptor.js"); + (document.head || document.documentElement).appendChild(script); + script.onload = () => script.remove(); +})(); diff --git a/js/interceptor.js b/js/interceptor.js index c3e9160..f929b16 100644 --- a/js/interceptor.js +++ b/js/interceptor.js @@ -2,8 +2,11 @@ "use strict"; const EVENT_NAME = "__kf_finding__"; + const nonce = document.documentElement.getAttribute("data-kf-nonce") || ""; + document.documentElement.removeAttribute("data-kf-nonce"); function emit(data) { + data.__kfNonce = nonce; window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: data })); } diff --git a/js/patterns.js b/js/patterns.js index 2afcc7f..0ea9289 100644 --- a/js/patterns.js +++ b/js/patterns.js @@ -76,7 +76,7 @@ const SECRET_PATTERNS = [ { name: "Shopify Private App Token", re: /\bshppa_[a-fA-F0-9]{32}\b/g, severity: "critical", confidence: "high", provider: "Shopify" }, { name: "Shopify Shared Secret", re: /\bshpss_[a-fA-F0-9]{32}\b/g, severity: "critical", confidence: "high", provider: "Shopify" }, - { name: "Sentry DSN", re: /https:\/\/[0-9a-f]{32}@(?:o[0-9]+\.)?(?:sentry\.io|[a-z0-9.-]+)\/[0-9]+/g, severity: "medium", confidence: "high", provider: "Sentry" }, + { name: "Sentry DSN", re: /https:\/\/[0-9a-f]{32}@(?:o[0-9]+\.)?(?:sentry\.io|[a-z0-9.-]+)\/[0-9]+/g, severity: "low", confidence: "high", provider: "Sentry" }, { name: "Sentry Auth Token", re: /\bsntrys_[A-Za-z0-9_]{64,}\b/g, severity: "high", confidence: "high", provider: "Sentry" }, { name: "New Relic API Key", re: /\bNRAK-[A-Z0-9]{27}\b/g, severity: "high", confidence: "high", provider: "New Relic" }, diff --git a/js/results.js b/js/results.js index 0e10cdc..e35dc7c 100644 --- a/js/results.js +++ b/js/results.js @@ -173,8 +173,8 @@ function renderFindings() { delBtn.textContent = "Del"; delBtn.title = "Remove finding"; delBtn.addEventListener("click", async () => { - await chrome.runtime.sendMessage({ type: "removeFinding", url: f.url }); - allFindings = allFindings.filter((x) => x !== f); + await chrome.runtime.sendMessage({ type: "removeFinding", findingId: f.id }); + allFindings = allFindings.filter((x) => x.id !== f.id); renderStats(); renderFindings(); }); @@ -212,19 +212,26 @@ function exportJson() { downloadBlob(blob, `keyfinder-findings-${Date.now()}.json`); } +function csvSafe(value) { + let str = String(value || ""); + if (/^[=+\-@\t\r]/.test(str)) str = "'" + str; + str = str.replace(/"/g, '""'); + return `"${str}"`; +} + function exportCsv() { const filtered = getFiltered(); const headers = ["Severity", "Provider", "Pattern", "Match", "Type", "Domain", "URL", "Page URL", "Timestamp"]; const rows = filtered.map((f) => [ - f.severity || "", - f.provider || "", - f.patternName || "", - `"${(f.match || "").replace(/"/g, '""')}"`, - f.type || "", - f.domain || "", - f.url || "", - f.pageUrl || "", - f.timestamp ? new Date(f.timestamp).toISOString() : "", + csvSafe(f.severity), + csvSafe(f.provider), + csvSafe(f.patternName), + csvSafe(f.match), + csvSafe(f.type), + csvSafe(f.domain), + csvSafe(f.url), + csvSafe(f.pageUrl), + csvSafe(f.timestamp ? new Date(f.timestamp).toISOString() : ""), ]); const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); const blob = new Blob([csv], { type: "text/csv" }); diff --git a/manifest.firefox.json b/manifest.firefox.json index 5d90bc0..8fdfb2b 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -1,7 +1,7 @@ { "name": "KeyFinder", - "description": "Passively discovers API keys, tokens, and secrets leaked in page scripts, DOM, network responses, and browser storage.", - "version": "2.0.0", + "description": "Passively discovers API keys, tokens, and secrets leaked in page scripts, DOM, network responses, and browser storage. Available for Chrome and Firefox.", + "version": "2.1.0", "manifest_version": 3, "browser_specific_settings": { "gecko": { @@ -34,14 +34,22 @@ }, { "matches": [""], - "js": ["js/interceptor.js"], + "js": ["js/interceptor-loader.js"], "run_at": "document_start", - "world": "MAIN", "all_frames": true } ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + }, "background": { "scripts": ["js/background.js"] }, + "web_accessible_resources": [ + { + "resources": ["js/interceptor.js"], + "matches": [""] + } + ], "permissions": ["activeTab", "storage"] } diff --git a/manifest.json b/manifest.json index 4c28c8c..76aca2e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "KeyFinder", "description": "Passively discovers API keys, tokens, and secrets leaked in page scripts, DOM, network responses, and browser storage. Available for Chrome and Firefox.", - "version": "2.0.0", + "version": "2.1.0", "manifest_version": 3, "action": { "default_icon": { @@ -25,14 +25,22 @@ }, { "matches": [""], - "js": ["js/interceptor.js"], + "js": ["js/interceptor-loader.js"], "run_at": "document_start", - "world": "MAIN", "all_frames": true } ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + }, "background": { "service_worker": "js/background.js" }, + "web_accessible_resources": [ + { + "resources": ["js/interceptor.js"], + "matches": [""] + } + ], "permissions": ["activeTab", "storage"] } diff --git a/scripts/build.sh b/scripts/build.sh index 04e0b21..ca999fa 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -12,6 +12,7 @@ SHARED_FILES=( js/background.js js/content.js js/interceptor.js + js/interceptor-loader.js js/patterns.js js/popup.js js/results.js