9 Commits

Author SHA1 Message Date
dependabot[bot] 7b01208b00 build(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-23 14:43:48 +00:00
Moamen Basel 5d606109dc Add GreyCore Labs professional services section to README 2026-06-11 02:41:01 +03:00
moamen 50a2f8b482 Update README: reflect SPA support, nonce bridge, loader, SECURITY links 2026-05-15 01:28:47 +03:00
moamen 72f324adae v2.1.1: deferred globals scan + CSV LF + version label fix
See CHANGELOG.md for full notes. Tagged release will fire the
GH Actions release pipeline added in v2.1.0.
2026-05-15 01:27:53 +03:00
Moamen Basel 806e0a4a7d Post-merge hardening: CSV LF, version label, deferred globals, SECURITY.md, CHANGELOG, dependabot (#16)
Five follow-ups from auditing #15: CSV LF prefix, runtime version label, deferred window-global scan, SECURITY.md threat model, CHANGELOG.md, dependabot.
2026-05-15 01:27:20 +03:00
Moamen Basel fdd3be3d99 v2.1.0: security hardening + cross-browser parity + release CI (#15)
Cherry-picks @anthonyonazure's closed PR #11 onto master post-Firefox port, adds Firefox parity for the nonce-validated interceptor bridge, and ships GH Actions for tag-driven releases plus PR validation.

Closes #11

Co-Authored-By: Anthony <anthony@anthonyonazure.com>
2026-05-15 00:50:48 +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
19 changed files with 699 additions and 70 deletions
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
actions-minor:
update-types: ["minor", "patch"]
+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@v7
- 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@v7
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 *.crx
*.pem *.pem
*.zip *.zip
dist/
.idea/ .idea/
.vscode/ .vscode/
*.swp *.swp
+58
View File
@@ -0,0 +1,58 @@
# Changelog
All notable changes to KeyFinder are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [SemVer](https://semver.org/spec/v2.0.0.html).
## [2.1.1] - 2026-05-14
### Added
- `SECURITY.md` with threat model, disclosure policy, and known limitations of the MAIN <-> ISOLATED nonce bridge
- `.github/dependabot.yml` for weekly GitHub Actions version bumps
- `CHANGELOG.md`
### Changed
- CSV export sanitiser now also prefixes cells starting with LF (`\n`), not just `=`, `+`, `-`, `@`, tab, CR
- Popup and results page version label is now read from the manifest at runtime instead of being hardcoded
### Fixed
- Window-global scan in `js/interceptor.js` now runs at `document_start`, `DOMContentLoaded`, and `load`, with per-name dedupe. The previous implementation only scanned at `document_start` when page globals had not yet been assigned, making the entire pass dead code on most real pages
## [2.1.0] - 2026-04-14
### Added
- Per-session nonce validation between MAIN-world interceptor and ISOLATED content script to prevent forged finding injection
- CSV formula-injection sanitiser on findings export
- Serialised storage writes to eliminate cross-tab race conditions
- 5000-finding cap with FIFO eviction
- Per-tab alert badge with red-dot icon overlay when secrets are detected
- MutationObserver scans dynamically-injected DOM nodes for SPA coverage
- Explicit Content Security Policy in Chrome and Firefox manifests
- `js/interceptor-loader.js` for both browsers, replacing direct MAIN-world content script on Firefox so the nonce handoff actually works
- GitHub Actions release pipeline (`.github/workflows/release.yml`): on `v*` tag, build Chrome + Firefox zips, compute SHA256, attach to GitHub Release
- GitHub Actions CI pipeline (`.github/workflows/ci.yml`): manifest JSON validation, Chrome <-> Firefox version parity check, build verification, `web-ext lint` on the Firefox bundle
### Changed
- Keyword input validation: 50 character maximum, 50 keyword maximum
- Findings are now deleted by unique ID instead of URL substring match
- URL parameter scanner uses exact match instead of substring (was matching `author` as `auth`)
- Keyword scanner enforces word boundaries (was matching `key` inside `hotkey`, `monkey`)
- camelCase JS identifiers are now skipped in keyword value matches
- Sentry DSN downgraded from `high` to `low` severity (public by design)
### Fixed
- Stored finding race conditions across concurrent tabs
- False positives from GitHub localStorage caches (`ref-selector:*`, `jump_to:*`, `soft-nav:*`, `COPILOT_*`)
- False positives from common CSRF tokens (`authenticity_token`, `csrf_token`, `__RequestVerificationToken`)
- False positives from keyboard shortcut data attributes (`data-hotkey`, `data-hotkey-scope`)
## [2.0.0] - 2026-04-07
### Added
- Complete rewrite to Manifest V3
- Enterprise-grade secret detection with 80+ regex patterns covering AWS, GCP, Azure, GitHub, GitLab, Stripe, PayPal, Square, Slack, Discord, and more
- Firefox support (MV3, Firefox 128+)
- Privacy policy
- Replaced demo gifs with professional logo
### Removed
- Manifest V2 background page
- Legacy jQuery dependency
+49 -23
View File
@@ -5,12 +5,13 @@
<h1 align="center">KeyFinder</h1> <h1 align="center">KeyFinder</h1>
<p align="center"> <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>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/manifest-v3-blue"/> <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/license/momenbasel/keyFinder"/>
<img src="https://img.shields.io/github/v/release/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"/> <img src="https://img.shields.io/github/downloads/momenbasel/keyFinder/total.svg"/>
@@ -18,7 +19,7 @@
<hr> <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 ## What It Detects
@@ -57,23 +58,25 @@ Additionally, **Shannon entropy analysis** is applied to detect random high-entr
## Features ## Features
- **Zero dependencies** - Pure vanilla JavaScript, no jQuery, no external libraries - **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 - **Passive scanning** - Runs automatically on every page load
- **Custom keywords** - Add your own search terms to scan for - **SPA-aware** - MutationObserver re-scans dynamically injected DOM
- **Per-tab alert badge** - Red-dot icon overlay when a tab has findings
- **Custom keywords** - Add your own search terms to scan for (validated, 50 max)
- **Dashboard** - Professional results page with filtering, sorting, and search - **Dashboard** - Professional results page with filtering, sorting, and search
- **Export** - Download findings as JSON or CSV - **Export** - Download findings as JSON or CSV (with formula-injection sanitiser)
- **Badge counter** - Shows finding count on the extension icon - **Hardened bridge** - Per-page nonce on MAIN <-> ISOLATED CustomEvent channel
- **Bounded storage** - 5000-finding cap with FIFO eviction; serialised writes across tabs
- **Low footprint** - Minimal CPU and memory usage - **Low footprint** - Minimal CPU and memory usage
- **All frames** - Scans iframes and embedded content - **All frames** - Scans iframes and embedded content
## Installation ## 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 ### Firefox
2. Open Chrome and navigate to `chrome://extensions` - [Install from Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/keyfinder-original/)
3. Enable **Developer mode** (top right toggle)
4. Drag and drop the `.crx` file onto the page
### From Source ### From Source
@@ -81,10 +84,16 @@ Additionally, **Shannon entropy analysis** is applied to detect random high-entr
git clone https://github.com/momenbasel/keyFinder.git 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** 2. Enable **Developer mode**
3. Click **Load unpacked** and select the `keyFinder` folder 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 ## Usage
1. **Install** the extension 1. **Install** the extension
@@ -104,28 +113,45 @@ Default keywords: `key`, `api_key`, `apikey`, `api-key`, `secret`, `token`, `acc
``` ```
keyFinder/ keyFinder/
manifest.json # MV3 manifest manifest.json # Chrome MV3 manifest
popup.html # Extension popup UI manifest.firefox.json # Firefox MV3 manifest
results.html # Findings dashboard popup.html # Extension popup UI
results.html # Findings dashboard
js/ js/
background.js # Service worker - storage and message handling background.js # Service worker - storage and message handling
patterns.js # 80+ secret detection regex patterns patterns.js # 80+ secret detection regex patterns
content.js # Page scanner - DOM, scripts, network interception content.js # ISOLATED-world page scanner - DOM, scripts, network
popup.js # Popup logic interceptor-loader.js # ISOLATED loader - sets nonce, injects MAIN-world interceptor
results.js # Dashboard logic with filtering and export interceptor.js # MAIN-world XHR/Fetch hooks + window global scanning
popup.js # Popup logic
results.js # Dashboard logic with filtering and export
css/ css/
popup.css # Popup styles popup.css # Popup styles
results.css # Dashboard styles results.css # Dashboard styles
icons/ icons/
icon16.png icon16.png
icon48.png icon48.png
icon128.png icon128.png
scripts/
build.sh # Build Chrome and Firefox zip packages
``` ```
## Security
See [SECURITY.md](SECURITY.md) for the threat model, disclosure policy, and known limitations. Release notes are in [CHANGELOG.md](CHANGELOG.md).
## Disclaimer ## Disclaimer
This tool is intended for **security research and authorized testing only**. Use it to identify leaked secrets on your own applications or during authorized penetration tests. You are responsible for your own actions. This tool is intended for **security research and authorized testing only**. Use it to identify leaked secrets on your own applications or during authorized penetration tests. You are responsible for your own actions.
## Professional services
keyFinder is built and maintained by [GreyCore Labs](https://greycorelabs.com), a US-incorporated offensive security firm. Want the same eye on your own product?
- [Penetration testing](https://greycorelabs.com/#plans) - web, API, mobile, cloud. Fixed quote within 24 hours, redacted sample report on request.
- [Free external attack-surface scan](https://greycorelabs.com/#free-scan) - one-page report in 48 hours, no strings attached.
## License ## License
MIT MIT
+57
View File
@@ -0,0 +1,57 @@
# Security Policy
## Reporting a vulnerability
Email **security reports** privately to the address on the maintainer's GitHub profile.
Do **not** open a public issue for unpatched vulnerabilities.
When reporting, include:
- Affected version (`manifest.json` `version` field)
- Browser and version
- Steps to reproduce
- Impact assessment (what an attacker gains)
A response is targeted within 7 days.
## Threat model
KeyFinder runs as a content script in every page the user visits and reports findings to a service worker. It is **client-side, passive, and read-only** with respect to the page.
### In scope
- Privilege escalation from a malicious page into the extension's service worker
- Persistent storage poisoning via crafted findings
- Cross-tab data leakage through `chrome.storage`
- CSV / JSON export injection (formulas, embedded HTML, JS)
- Manifest / CSP weaknesses enabling code injection into the extension's own pages
- Pattern-rule false positives that consistently leak benign data into findings
### Out of scope
- A malicious page generating **fake findings** in the user's results view. The MAIN-world interceptor and the ISOLATED content script communicate over `CustomEvent` with a per-page nonce stored as a `data-kf-verify` attribute on `documentElement`. The nonce is removed once the content script consumes it, but a page script that runs between `document_start` and `document_idle` can read the attribute and forge events. Mitigation cost is high (Symbols don't cross realms; postMessage is also page-visible). The impact is limited to **showing the user a finding that isn't real** - no data is exfiltrated, no privileged API is reached. Treat findings on a hostile page as advisory.
- Detection accuracy of individual regex rules. False positives and false negatives are expected; report a tuning issue rather than a CVE.
- Extension being uninstalled or disabled by the user.
## What the extension can see
| Surface | Scope |
|---|---|
| Page DOM | All pages (`<all_urls>`) at `document_idle` (ISOLATED world) and `document_start` (MAIN world interceptor) |
| Network | `fetch` and `XMLHttpRequest` responses initiated by the page (response bodies up to 500 KB are scanned) |
| Web storage | `localStorage`, `sessionStorage`, `document.cookie` on the page |
| Inter-extension | None. No host permissions beyond `activeTab` and `storage` |
The extension makes **no outbound network requests** other than fetching same-origin scripts already referenced by the page.
## Known limitations
- **Per-tab badge dot** is set on the first finding only; subsequent findings update the count but not the icon
- **5000 finding cap** with FIFO eviction. High-volume scans (heavy SPAs over a long session) will drop oldest findings
- **CSV export** prefixes a single-quote on cells starting with `=`, `+`, `-`, `@`, tab, or carriage return to neutralise Excel / Sheets formula injection. Line-feed prefix is not currently neutralised
- **Service worker restarts** drop the in-flight `storageQueue` Promise chain. Subsequent storage writes are still serialised; only pending writes from the killed worker are lost
## Supported versions
| Version | Supported |
|---|---|
| 2.1.x | yes |
| 2.0.x | no |
| < 2.0 | no |
+91 -7
View File
@@ -1,5 +1,8 @@
const KEYWORDS_KEY = "kf_keywords"; const KEYWORDS_KEY = "kf_keywords";
const FINDINGS_KEY = "kf_findings"; const FINDINGS_KEY = "kf_findings";
const MAX_FINDINGS = 5000;
const MAX_KEYWORDS = 50;
const MAX_KEYWORD_LENGTH = 50;
const DEFAULT_KEYWORDS = [ const DEFAULT_KEYWORDS = [
"key", "api_key", "apikey", "api-key", "secret", "token", "key", "api_key", "apikey", "api-key", "secret", "token",
@@ -7,6 +10,76 @@ const DEFAULT_KEYWORDS = [
"client_id", "client_secret" "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) => { chrome.runtime.onInstalled.addListener(async (details) => {
if (details.reason === "install") { if (details.reason === "install") {
await chrome.storage.local.set({ await chrome.storage.local.set({
@@ -18,7 +91,8 @@ chrome.runtime.onInstalled.addListener(async (details) => {
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.type === "finding") { 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; return true;
} }
if (request.type === "getKeywords") { if (request.type === "getKeywords") {
@@ -30,19 +104,19 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
return true; return true;
} }
if (request.type === "addKeyword") { if (request.type === "addKeyword") {
addKeyword(request.keyword).then((result) => sendResponse(result)); enqueue(() => addKeyword(request.keyword)).then((result) => sendResponse(result));
return true; return true;
} }
if (request.type === "removeKeyword") { if (request.type === "removeKeyword") {
removeKeyword(request.keyword).then(() => sendResponse({ ok: true })); enqueue(() => removeKeyword(request.keyword)).then(() => sendResponse({ ok: true }));
return true; return true;
} }
if (request.type === "removeFinding") { if (request.type === "removeFinding") {
removeFinding(request.url).then(() => sendResponse({ ok: true })); enqueue(() => removeFinding(request.findingId)).then(() => sendResponse({ ok: true }));
return true; return true;
} }
if (request.type === "clearFindings") { if (request.type === "clearFindings") {
clearFindings().then(() => sendResponse({ ok: true })); enqueue(() => clearFindings()).then(() => sendResponse({ ok: true }));
return true; return true;
} }
if (request.type === "exportFindings") { if (request.type === "exportFindings") {
@@ -60,6 +134,8 @@ async function addKeyword(keyword) {
const keywords = await getKeywords(); const keywords = await getKeywords();
const normalized = keyword.trim().toLowerCase(); const normalized = keyword.trim().toLowerCase();
if (!normalized) return { ok: false, error: "Keyword cannot be empty." }; 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." }; if (keywords.includes(normalized)) return { ok: false, error: "Keyword already exists." };
keywords.push(normalized); keywords.push(normalized);
await chrome.storage.local.set({ [KEYWORDS_KEY]: keywords }); 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 (f) => f.url === finding.url && f.match === finding.match
); );
if (isDuplicate) return; if (isDuplicate) return;
finding.id = crypto.randomUUID();
findings.push(finding); 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 }); await chrome.storage.local.set({ [FINDINGS_KEY]: findings });
const badgeCount = findings.length; const badgeCount = findings.length;
@@ -90,9 +174,9 @@ async function saveFinding(finding) {
chrome.action.setBadgeBackgroundColor({ color: "#e74c3c" }); chrome.action.setBadgeBackgroundColor({ color: "#e74c3c" });
} }
async function removeFinding(url) { async function removeFinding(findingId) {
const findings = await getFindings(); 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 }); await chrome.storage.local.set({ [FINDINGS_KEY]: updated });
chrome.action.setBadgeText({ text: updated.length > 0 ? String(updated.length) : "" }); chrome.action.setBadgeText({ text: updated.length > 0 ? String(updated.length) : "" });
} }
+138 -5
View File
@@ -87,14 +87,17 @@
for (const kw of keywords) { for (const kw of keywords) {
const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Require word boundary around keyword to avoid matching "hotkey", "monkey", "turkey" for "key"
const kwRegex = new RegExp( const kwRegex = new RegExp(
`(?:${escaped})\\s*[:=]\\s*['"\`]([^'"\`\\n]{8,200})['"\`]`, `(?:^|[^a-zA-Z])(?:${escaped})(?:[^a-zA-Z]|$)\\s*[:=]\\s*['"\`]([^'"\`\\n]{8,200})['"\`]`,
"gi" "gi"
); );
let m; let m;
while ((m = kwRegex.exec(text)) !== null) { while ((m = kwRegex.exec(text)) !== null) {
const val = m[1]; const val = m[1];
if (isFalsePositive(val)) continue; 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({ report({
url: sourceUrl, url: sourceUrl,
match: val.substring(0, 200), match: val.substring(0, 200),
@@ -185,7 +188,16 @@
const name = (input.name || input.id || "").toLowerCase(); const name = (input.name || input.id || "").toLowerCase();
const value = input.value; const value = input.value;
if (!value || value.length < 8) continue; 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)) { if (sensitive.some((s) => name.includes(s)) || isHighEntropy(value)) {
report({ report({
url: pageUrl, match: `${name}=${value.substring(0, 100)}`, url: pageUrl, match: `${name}=${value.substring(0, 100)}`,
@@ -200,10 +212,20 @@
function scanDataAttributes() { function scanDataAttributes() {
const all = document.querySelectorAll("*"); 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 el of all) {
for (const attr of el.attributes) { for (const attr of el.attributes) {
if (!/^data-.*(?:key|token|secret|auth|api|credential|password)/i.test(attr.name)) continue; if (!/^data-.*(?:key|token|secret|auth|api|credential|password)/i.test(attr.name)) continue;
if (!attr.value || attr.value.length < 8) 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({ report({
url: pageUrl, match: `${attr.name}="${attr.value.substring(0, 100)}"`, url: pageUrl, match: `${attr.name}="${attr.value.substring(0, 100)}"`,
type: "data-attribute", patternName: "Sensitive Data Attribute", type: "data-attribute", patternName: "Sensitive Data Attribute",
@@ -226,6 +248,12 @@
function scanLinkHrefs() { function scanLinkHrefs() {
const links = document.querySelectorAll("a[href], link[href]"); 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) { for (const link of links) {
try { try {
const href = link.href; const href = link.href;
@@ -233,8 +261,10 @@
const url = new URL(href); const url = new URL(href);
for (const [param, value] of url.searchParams) { for (const [param, value] of url.searchParams) {
const p = param.toLowerCase(); const p = param.toLowerCase();
const sensitive = ["key", "api_key", "apikey", "token", "secret", "access_token", "auth", "password", "session_id"]; if (benignParams.includes(p)) continue;
if (sensitive.some((s) => p.includes(s)) && value.length >= 8) { 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({ report({
url: href, match: `${param}=${value.substring(0, 100)}`, url: href, match: `${param}=${value.substring(0, 100)}`,
type: "url-param", patternName: "Sensitive URL Parameter", type: "url-param", patternName: "Sensitive URL Parameter",
@@ -251,6 +281,28 @@
{ store: localStorage, label: "localStorage" }, { store: localStorage, label: "localStorage" },
{ store: sessionStorage, label: "sessionStorage" }, { 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) { for (const { store, label } of stores) {
try { try {
for (let i = 0; i < store.length; i++) { for (let i = 0; i < store.length; i++) {
@@ -258,7 +310,14 @@
const value = store.getItem(key); const value = store.getItem(key);
if (!value || value.length < 12) continue; if (!value || value.length < 12) continue;
const kl = key.toLowerCase(); 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))) { if (sensitive.some((s) => kl.includes(s)) || isHighEntropy(value.substring(0, 100))) {
report({ report({
url: pageUrl, match: `${label}.${key}=${value.substring(0, 120)}`, url: pageUrl, match: `${label}.${key}=${value.substring(0, 120)}`,
@@ -294,9 +353,13 @@
} catch {} } catch {}
} }
const kfNonce = document.documentElement.getAttribute("data-kf-verify") || "";
document.documentElement.removeAttribute("data-kf-verify");
window.addEventListener("__kf_finding__", (e) => { window.addEventListener("__kf_finding__", (e) => {
const data = e.detail; const data = e.detail;
if (!data) return; if (!data) return;
if (data.__kfNonce !== kfNonce) return;
if (data.rawText) { if (data.rawText) {
scanText(data.rawText, data.sourceUrl || pageUrl, data.type); scanText(data.rawText, data.sourceUrl || pageUrl, data.type);
@@ -327,6 +390,76 @@
scanCookies(); scanCookies();
await scanExternalScripts(); 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) { if (seen.size > 0) {
console.log(`[KeyFinder] ${seen.size} potential secret(s) found on ${pageDomain}`); 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();
})();
+36 -17
View File
@@ -2,8 +2,11 @@
"use strict"; "use strict";
const EVENT_NAME = "__kf_finding__"; const EVENT_NAME = "__kf_finding__";
const nonce = document.documentElement.getAttribute("data-kf-nonce") || "";
document.documentElement.removeAttribute("data-kf-nonce");
function emit(data) { function emit(data) {
data.__kfNonce = nonce;
window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: data })); window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: data }));
} }
@@ -36,23 +39,39 @@
"__ENV__", "__CONFIG__", "ENV", "CONFIG", "__ENV__", "__CONFIG__", "ENV", "CONFIG",
]; ];
for (const name of globalNames) { const scannedGlobals = new Set();
try { function scanGlobals() {
const val = window[name]; for (const name of globalNames) {
if (val === undefined || val === null) continue; if (scannedGlobals.has(name)) continue;
const str = typeof val === "object" ? JSON.stringify(val) : String(val); try {
if (str.length < 8 || str === "[object Object]") continue; const val = window[name];
emit({ if (val === undefined || val === null) continue;
match: `window.${name}=${str.substring(0, 200)}`, const str = typeof val === "object" ? JSON.stringify(val) : String(val);
type: "window-global", if (str.length < 8 || str === "[object Object]") continue;
patternName: "Exposed Global Variable", scannedGlobals.add(name);
severity: "high", emit({
confidence: typeof val !== "object" && isHighEntropy(str.substring(0, 60)) ? "high" : "medium", match: `window.${name}=${str.substring(0, 200)}`,
provider: "JS Global Scan", type: "window-global",
isObject: typeof val === "object", patternName: "Exposed Global Variable",
rawText: typeof val === "object" ? str.substring(0, 5000) : null, severity: "high",
}); confidence: typeof val !== "object" && isHighEntropy(str.substring(0, 60)) ? "high" : "medium",
} catch {} provider: "JS Global Scan",
isObject: typeof val === "object",
rawText: typeof val === "object" ? str.substring(0, 5000) : null,
});
} catch {}
}
}
// Run at document_start (likely empty), DOMContentLoaded, and load to catch
// globals set at any phase. Each name reports at most once via scannedGlobals.
scanGlobals();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", scanGlobals, { once: true });
}
if (document.readyState !== "complete") {
window.addEventListener("load", scanGlobals, { once: true });
} else {
scanGlobals();
} }
const origXhrOpen = XMLHttpRequest.prototype.open; const origXhrOpen = XMLHttpRequest.prototype.open;
+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 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: "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: "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" }, { name: "New Relic API Key", re: /\bNRAK-[A-Z0-9]{27}\b/g, severity: "high", confidence: "high", provider: "New Relic" },
+2
View File
@@ -1,6 +1,8 @@
document.addEventListener("DOMContentLoaded", init); document.addEventListener("DOMContentLoaded", init);
async function init() { async function init() {
const versionLabel = document.getElementById("versionLabel");
if (versionLabel) versionLabel.textContent = "v" + chrome.runtime.getManifest().version;
await renderKeywords(); await renderKeywords();
await renderStats(); await renderStats();
document.getElementById("keywordForm").addEventListener("submit", handleAddKeyword); document.getElementById("keywordForm").addEventListener("submit", handleAddKeyword);
+20 -11
View File
@@ -3,6 +3,8 @@ let allFindings = [];
document.addEventListener("DOMContentLoaded", init); document.addEventListener("DOMContentLoaded", init);
async function init() { async function init() {
const versionLabel = document.getElementById("versionLabel");
if (versionLabel) versionLabel.textContent = "v" + chrome.runtime.getManifest().version;
const response = await chrome.runtime.sendMessage({ type: "getFindings" }); const response = await chrome.runtime.sendMessage({ type: "getFindings" });
allFindings = response.findings || []; allFindings = response.findings || [];
@@ -173,8 +175,8 @@ function renderFindings() {
delBtn.textContent = "Del"; delBtn.textContent = "Del";
delBtn.title = "Remove finding"; delBtn.title = "Remove finding";
delBtn.addEventListener("click", async () => { delBtn.addEventListener("click", async () => {
await chrome.runtime.sendMessage({ type: "removeFinding", url: f.url }); await chrome.runtime.sendMessage({ type: "removeFinding", findingId: f.id });
allFindings = allFindings.filter((x) => x !== f); allFindings = allFindings.filter((x) => x.id !== f.id);
renderStats(); renderStats();
renderFindings(); renderFindings();
}); });
@@ -212,19 +214,26 @@ function exportJson() {
downloadBlob(blob, `keyfinder-findings-${Date.now()}.json`); downloadBlob(blob, `keyfinder-findings-${Date.now()}.json`);
} }
function csvSafe(value) {
let str = String(value || "");
if (/^[=+\-@\t\r\n]/.test(str)) str = "'" + str;
str = str.replace(/"/g, '""');
return `"${str}"`;
}
function exportCsv() { function exportCsv() {
const filtered = getFiltered(); const filtered = getFiltered();
const headers = ["Severity", "Provider", "Pattern", "Match", "Type", "Domain", "URL", "Page URL", "Timestamp"]; const headers = ["Severity", "Provider", "Pattern", "Match", "Type", "Domain", "URL", "Page URL", "Timestamp"];
const rows = filtered.map((f) => [ const rows = filtered.map((f) => [
f.severity || "", csvSafe(f.severity),
f.provider || "", csvSafe(f.provider),
f.patternName || "", csvSafe(f.patternName),
`"${(f.match || "").replace(/"/g, '""')}"`, csvSafe(f.match),
f.type || "", csvSafe(f.type),
f.domain || "", csvSafe(f.domain),
f.url || "", csvSafe(f.url),
f.pageUrl || "", csvSafe(f.pageUrl),
f.timestamp ? new Date(f.timestamp).toISOString() : "", csvSafe(f.timestamp ? new Date(f.timestamp).toISOString() : ""),
]); ]);
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
const blob = new Blob([csv], { type: "text/csv" }); 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.1",
"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", "name": "KeyFinder",
"description": "Passively discovers API keys, tokens, and secrets leaked in page scripts, DOM, network responses, and browser storage.", "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.1",
"manifest_version": 3, "manifest_version": 3,
"action": { "action": {
"default_icon": { "default_icon": {
@@ -25,14 +25,22 @@
}, },
{ {
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
"js": ["js/interceptor.js"], "js": ["js/interceptor-loader.js"],
"run_at": "document_start", "run_at": "document_start",
"world": "MAIN",
"all_frames": true "all_frames": true
} }
], ],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"background": { "background": {
"service_worker": "js/background.js" "service_worker": "js/background.js"
}, },
"web_accessible_resources": [
{
"resources": ["js/interceptor.js"],
"matches": ["<all_urls>"]
}
],
"permissions": ["activeTab", "storage"] "permissions": ["activeTab", "storage"]
} }
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="header-brand"> <div class="header-brand">
<img src="icons/icon48.png" alt="KeyFinder" class="header-icon"> <img src="icons/icon48.png" alt="KeyFinder" class="header-icon">
<h1>KeyFinder</h1> <h1>KeyFinder</h1>
<span class="version">v2.0</span> <span class="version" id="versionLabel"></span>
</div> </div>
<p class="header-tagline">Passive API key & secret discovery</p> <p class="header-tagline">Passive API key & secret discovery</p>
</header> </header>
+1 -1
View File
@@ -10,7 +10,7 @@
<header class="header"> <header class="header">
<div class="header-left"> <div class="header-left">
<img src="icons/icon48.png" alt="KeyFinder" class="header-icon"> <img src="icons/icon48.png" alt="KeyFinder" class="header-icon">
<h1>KeyFinder <span class="version">v2.0</span></h1> <h1>KeyFinder <span class="version" id="versionLabel"></span></h1>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<div class="filter-group"> <div class="filter-group">
+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"