8 Commits

Author SHA1 Message Date
moamen 998c9d314f ci: add release + validation workflows
- release.yml: on v* tag, build Chrome + Firefox zips, compute
  SHA256, upload artifacts, attach to GitHub Release with auto
  generated notes
- ci.yml: on PR/master push, validate manifest JSON, enforce
  version parity, build, web-ext lint Firefox bundle
2026-05-15 00:48:49 +03:00
moamen e324d69611 Sync Firefox manifest with v2.1.0 hardening and include loader
- Bump Firefox manifest to 2.1.0 alongside Chrome
- Switch Firefox interceptor injection to loader pattern so the nonce
  handoff to content.js works (MAIN-world content script alone has no
  documentElement nonce, causing all findings to be rejected)
- Add CSP and web_accessible_resources entries to match Chrome
- Include js/interceptor-loader.js in build.sh shared files
- Ignore dist/ build output
2026-05-15 00:48:18 +03:00
anthonyonazure 6742d6a827 Reduce false positives in DOM, storage, URL, and keyword scanners
- Skip known CSRF tokens (authenticity_token, csrf_token, etc.) in hidden input scanner
- Ignore GitHub localStorage caches (ref-selector:*, jump_to:*, soft-nav:*, COPILOT_*)
- Skip keyboard shortcut data-attributes (data-hotkey, data-hotkey-scope)
- Fix URL param scanner: use exact match instead of substring to prevent "author" matching "auth"
- Add word boundaries to keyword scanner so "key" doesn't match "hotkey", "monkey", etc.
- Skip camelCase JS identifiers in keyword value matches
- Lower Sentry DSN severity to "low" (public by design)
- Apply same fixes to MutationObserver for SPA consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-15 00:47:12 +03:00
anthonyonazure 0d5ca42faa Bump version to 2.1.0 2026-05-15 00:47:12 +03:00
anthonyonazure bfc73ba018 Security hardening, bug fixes, and per-tab alert icon
- Prevent fake finding injection via per-session nonce validation between
  MAIN world interceptor and ISOLATED world content script
- Fix CSV formula injection in export by sanitizing cell values
- Serialize storage writes to prevent race conditions across tabs
- Cap findings at 5000 with oldest-first eviction
- Delete findings by unique ID instead of URL to avoid collateral removal
- Validate keyword length (50 chars) and count (50 max)
- Add MutationObserver for SPA support (dynamic DOM scanning)
- Add explicit CSP to manifest
- Add per-tab alert icon with red dot overlay when secrets are found
2026-05-15 00:47:00 +03:00
moamen 8d2f3fc1e4 docs: update README and manifest for cross-browser support
- Change Chrome extension to browser extension throughout
- Add Chrome Web Store and Firefox Add-ons install links
- Add Firefox badge to README header
- Update architecture section with Firefox manifest and build script
- Update installation instructions for both browsers
2026-04-14 15:42:08 +02:00
moamen 99e54bb93a fix: add data_collection_permissions for AMO validation 2026-04-14 15:32:48 +02:00
moamen c922a1a166 feat: add Firefox support (MV3, Firefox 128+)
- Add manifest.firefox.json with gecko-specific settings and background scripts
- Add scripts/build.sh to generate Chrome and Firefox zip packages
- Zero JS changes needed: Firefox 128+ supports chrome.* namespace and world: MAIN
- Closes #10
2026-04-14 15:30:25 +02:00
13 changed files with 516 additions and 48 deletions
+32
View File
@@ -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
+71
View File
@@ -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
+1
View File
@@ -2,6 +2,7 @@
*.crx
*.pem
*.zip
dist/
.idea/
.vscode/
*.swp
+30 -20
View File
@@ -5,12 +5,13 @@
<h1 align="center">KeyFinder</h1>
<p align="center">
<strong>Passive API key and secret discovery for Chrome</strong>
<strong>Passive API key and secret discovery for Chrome and Firefox</strong>
</p>
<p align="center">
<img src="https://img.shields.io/badge/manifest-v3-blue"/>
<img src="https://img.shields.io/badge/Chrome-Extension-green"/>
<a href="https://chromewebstore.google.com/detail/keyfinder/oogbljkilkfgdlkbmajlolpanilnnkim"><img src="https://img.shields.io/badge/Chrome-Extension-green"/></a>
<a href="https://addons.mozilla.org/en-US/firefox/addon/keyfinder-original/"><img src="https://img.shields.io/badge/Firefox-Add--on-orange"/></a>
<img src="https://img.shields.io/github/license/momenbasel/keyFinder"/>
<img src="https://img.shields.io/github/v/release/momenbasel/keyFinder"/>
<img src="https://img.shields.io/github/downloads/momenbasel/keyFinder/total.svg"/>
@@ -18,7 +19,7 @@
<hr>
KeyFinder is a Chrome extension that passively scans every page you visit for leaked API keys, tokens, secrets, and credentials. It runs silently in the background with zero configuration required.
KeyFinder is a browser extension for Chrome and Firefox that passively scans every page you visit for leaked API keys, tokens, secrets, and credentials. It runs silently in the background with zero configuration required.
## What It Detects
@@ -57,7 +58,7 @@ Additionally, **Shannon entropy analysis** is applied to detect random high-entr
## Features
- **Zero dependencies** - Pure vanilla JavaScript, no jQuery, no external libraries
- **Manifest V3** - Built for modern Chrome with service worker architecture
- **Manifest V3** - Built for modern Chrome and Firefox with service worker architecture
- **Passive scanning** - Runs automatically on every page load
- **Custom keywords** - Add your own search terms to scan for
- **Dashboard** - Professional results page with filtering, sorting, and search
@@ -68,12 +69,11 @@ Additionally, **Shannon entropy analysis** is applied to detect random high-entr
## Installation
### From Release (Recommended)
### Chrome
- [Install from Chrome Web Store](https://chromewebstore.google.com/detail/keyfinder/oogbljkilkfgdlkbmajlolpanilnnkim)
1. Go to [Releases](https://github.com/momenbasel/keyFinder/releases) and download the latest `.crx` file
2. Open Chrome and navigate to `chrome://extensions`
3. Enable **Developer mode** (top right toggle)
4. Drag and drop the `.crx` file onto the page
### Firefox
- [Install from Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/keyfinder-original/)
### From Source
@@ -81,10 +81,16 @@ Additionally, **Shannon entropy analysis** is applied to detect random high-entr
git clone https://github.com/momenbasel/keyFinder.git
```
1. Open Chrome and go to `chrome://extensions`
**Chrome:**
1. Go to `chrome://extensions`
2. Enable **Developer mode**
3. Click **Load unpacked** and select the `keyFinder` folder
**Firefox:**
1. Go to `about:debugging` > "This Firefox"
2. Click **Load Temporary Add-on**
3. Select `manifest.firefox.json` from the `keyFinder` folder
## Usage
1. **Install** the extension
@@ -104,22 +110,26 @@ Default keywords: `key`, `api_key`, `apikey`, `api-key`, `secret`, `token`, `acc
```
keyFinder/
manifest.json # MV3 manifest
popup.html # Extension popup UI
results.html # Findings dashboard
manifest.json # Chrome MV3 manifest
manifest.firefox.json # Firefox MV3 manifest
popup.html # Extension popup UI
results.html # Findings dashboard
js/
background.js # Service worker - storage and message handling
patterns.js # 80+ secret detection regex patterns
content.js # Page scanner - DOM, scripts, network interception
popup.js # Popup logic
results.js # Dashboard logic with filtering and export
background.js # Service worker - storage and message handling
patterns.js # 80+ secret detection regex patterns
content.js # Page scanner - DOM, scripts, network interception
interceptor.js # XHR/Fetch hooking and window global scanning
popup.js # Popup logic
results.js # Dashboard logic with filtering and export
css/
popup.css # Popup styles
results.css # Dashboard styles
popup.css # Popup styles
results.css # Dashboard styles
icons/
icon16.png
icon48.png
icon128.png
scripts/
build.sh # Build Chrome and Firefox zip packages
```
## Disclaimer
+91 -7
View File
@@ -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) : "" });
}
+138 -5
View File
@@ -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}`);
}
+16
View File
@@ -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();
})();
+3
View File
@@ -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 }));
}
+1 -1
View File
@@ -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" },
+18 -11
View File
@@ -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" });
+55
View File
@@ -0,0 +1,55 @@
{
"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.1.0",
"manifest_version": 3,
"browser_specific_settings": {
"gecko": {
"id": "keyfinder@momenbasel.com",
"strict_min_version": "128.0",
"data_collection_permissions": {
"required": ["none"]
}
}
},
"action": {
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"default_popup": "popup.html"
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["js/patterns.js", "js/content.js"],
"run_at": "document_idle",
"all_frames": true
},
{
"matches": ["<all_urls>"],
"js": ["js/interceptor-loader.js"],
"run_at": "document_start",
"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": ["<all_urls>"]
}
],
"permissions": ["activeTab", "storage"]
}
+12 -4
View File
@@ -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,
"action": {
"default_icon": {
@@ -25,14 +25,22 @@
},
{
"matches": ["<all_urls>"],
"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": ["<all_urls>"]
}
],
"permissions": ["activeTab", "storage"]
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIST="$ROOT/dist"
VERSION=$(grep '"version"' "$ROOT/manifest.json" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
rm -rf "$DIST"
mkdir -p "$DIST"
SHARED_FILES=(
js/background.js
js/content.js
js/interceptor.js
js/interceptor-loader.js
js/patterns.js
js/popup.js
js/results.js
css/popup.css
css/results.css
icons/icon16.png
icons/icon48.png
icons/icon128.png
popup.html
results.html
)
# --- Chrome build ---
CHROME_DIR="$DIST/chrome"
mkdir -p "$CHROME_DIR"/{js,css,icons}
cp "$ROOT/manifest.json" "$CHROME_DIR/manifest.json"
for f in "${SHARED_FILES[@]}"; do
cp "$ROOT/$f" "$CHROME_DIR/$f"
done
(cd "$CHROME_DIR" && zip -r "$DIST/keyfinder-v${VERSION}-chrome.zip" . -x '.*')
echo "Built: dist/keyfinder-v${VERSION}-chrome.zip"
# --- Firefox build ---
FF_DIR="$DIST/firefox"
mkdir -p "$FF_DIR"/{js,css,icons}
cp "$ROOT/manifest.firefox.json" "$FF_DIR/manifest.json"
for f in "${SHARED_FILES[@]}"; do
cp "$ROOT/$f" "$FF_DIR/$f"
done
(cd "$FF_DIR" && zip -r "$DIST/keyfinder-v${VERSION}-firefox.zip" . -x '.*')
echo "Built: dist/keyfinder-v${VERSION}-firefox.zip"
echo "Done. Upload dist/keyfinder-v${VERSION}-firefox.zip to addons.mozilla.org"