Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 998c9d314f | |||
| e324d69611 | |||
| 6742d6a827 | |||
| 0d5ca42faa | |||
| bfc73ba018 | |||
| 8d2f3fc1e4 | |||
| 99e54bb93a | |||
| c922a1a166 | |||
| f25d07f97d | |||
| 7b7b68ae71 | |||
| 403ec3058a |
@@ -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
|
||||
@@ -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
|
||||
@@ -2,6 +2,7 @@
|
||||
*.crx
|
||||
*.pem
|
||||
*.zip
|
||||
dist/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Privacy Policy - KeyFinder Chrome Extension
|
||||
|
||||
**Last Updated:** April 7, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
KeyFinder is a browser extension that scans web pages for leaked API keys, tokens, and secrets. This privacy policy explains how the extension handles data.
|
||||
|
||||
## Data Collection
|
||||
|
||||
KeyFinder does **not** collect, transmit, or share any personal data or browsing data with external servers. The extension operates entirely on your local device.
|
||||
|
||||
## What Data is Stored Locally
|
||||
|
||||
The extension stores the following data locally on your device using Chrome's built-in storage API (`chrome.storage.local`):
|
||||
|
||||
- **Search keywords**: User-configured keywords used to identify potential secrets on web pages.
|
||||
- **Scan findings**: When a potential secret is detected, the extension stores the pattern name, severity level, matched value, page URL, and domain where it was found.
|
||||
|
||||
This data never leaves your device.
|
||||
|
||||
## How Data is Processed
|
||||
|
||||
- All page scanning happens locally within your browser.
|
||||
- The extension reads page content (scripts, meta tags, form fields, HTML comments, browser storage, and network responses) to match against known secret patterns.
|
||||
- No page content, scan results, or browsing activity is sent to any external server, API, or third party.
|
||||
|
||||
## Data Sharing
|
||||
|
||||
KeyFinder does **not** share any data with third parties. Specifically:
|
||||
|
||||
- No data is sold to third parties.
|
||||
- No data is used for advertising or marketing purposes.
|
||||
- No data is transferred to third parties for reasons unrelated to the extension's core functionality.
|
||||
- No analytics, telemetry, or tracking is implemented.
|
||||
|
||||
## Data Retention
|
||||
|
||||
All stored data remains on your local device until you choose to delete it. You can clear all findings at any time using the "Clear All" button in the findings dashboard. Uninstalling the extension removes all stored data.
|
||||
|
||||
## Permissions
|
||||
|
||||
- **activeTab**: Used to access the current page's content for scanning when the extension is active.
|
||||
- **storage**: Used to save your keyword preferences and scan findings locally.
|
||||
- **Host permissions**: The extension runs on all URLs because leaked secrets can appear on any website. No data from these pages is transmitted externally.
|
||||
|
||||
## Third-Party Services
|
||||
|
||||
KeyFinder does not integrate with, connect to, or send data to any third-party services.
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
Any changes to this privacy policy will be reflected in the extension's GitHub repository and the "Last Updated" date above.
|
||||
|
||||
## Contact
|
||||
|
||||
If you have questions about this privacy policy, contact the developer:
|
||||
|
||||
- GitHub: [github.com/momenbasel](https://github.com/momenbasel)
|
||||
- X: [@momenbassel](https://x.com/momenbassel)
|
||||
- LinkedIn: [linkedin.com/in/momenbasel](https://www.linkedin.com/in/momenbasel/)
|
||||
@@ -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,15 +81,15 @@ 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
|
||||
|
||||
<img src="https://github.com/momenbasel/keyFinder/blob/master/installGif.gif?raw=true" alt="Installation demo"/>
|
||||
|
||||
## Demo
|
||||
|
||||

|
||||
**Firefox:**
|
||||
1. Go to `about:debugging` > "This Firefox"
|
||||
2. Click **Load Temporary Add-on**
|
||||
3. Select `manifest.firefox.json` from the `keyFinder` folder
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -110,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
|
||||
|
||||
|
Before Width: | Height: | Size: 10 MiB |
|
After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 228 B After Width: | Height: | Size: 362 B |
|
Before Width: | Height: | Size: 612 B After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 281 KiB |
@@ -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) : "" });
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||