diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..43ec9f2 --- /dev/null +++ b/.github/dependabot.yml @@ -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"] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..40f7e97 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# 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). + +## [Unreleased] + +### 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 +- 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 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7445748 --- /dev/null +++ b/SECURITY.md @@ -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 (``) 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 | diff --git a/js/interceptor.js b/js/interceptor.js index f929b16..5c8ef7e 100644 --- a/js/interceptor.js +++ b/js/interceptor.js @@ -39,23 +39,39 @@ "__ENV__", "__CONFIG__", "ENV", "CONFIG", ]; - for (const name of globalNames) { - try { - const val = window[name]; - if (val === undefined || val === null) continue; - const str = typeof val === "object" ? JSON.stringify(val) : String(val); - if (str.length < 8 || str === "[object Object]") continue; - emit({ - match: `window.${name}=${str.substring(0, 200)}`, - type: "window-global", - patternName: "Exposed Global Variable", - severity: "high", - confidence: typeof val !== "object" && isHighEntropy(str.substring(0, 60)) ? "high" : "medium", - provider: "JS Global Scan", - isObject: typeof val === "object", - rawText: typeof val === "object" ? str.substring(0, 5000) : null, - }); - } catch {} + const scannedGlobals = new Set(); + function scanGlobals() { + for (const name of globalNames) { + if (scannedGlobals.has(name)) continue; + try { + const val = window[name]; + if (val === undefined || val === null) continue; + const str = typeof val === "object" ? JSON.stringify(val) : String(val); + if (str.length < 8 || str === "[object Object]") continue; + scannedGlobals.add(name); + emit({ + match: `window.${name}=${str.substring(0, 200)}`, + type: "window-global", + patternName: "Exposed Global Variable", + severity: "high", + confidence: typeof val !== "object" && isHighEntropy(str.substring(0, 60)) ? "high" : "medium", + 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; diff --git a/js/popup.js b/js/popup.js index 0665b37..c44c64a 100644 --- a/js/popup.js +++ b/js/popup.js @@ -1,6 +1,8 @@ document.addEventListener("DOMContentLoaded", init); async function init() { + const versionLabel = document.getElementById("versionLabel"); + if (versionLabel) versionLabel.textContent = "v" + chrome.runtime.getManifest().version; await renderKeywords(); await renderStats(); document.getElementById("keywordForm").addEventListener("submit", handleAddKeyword); diff --git a/js/results.js b/js/results.js index e35dc7c..a5af5a0 100644 --- a/js/results.js +++ b/js/results.js @@ -3,6 +3,8 @@ let allFindings = []; document.addEventListener("DOMContentLoaded", 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" }); allFindings = response.findings || []; @@ -214,7 +216,7 @@ function exportJson() { function csvSafe(value) { let str = String(value || ""); - if (/^[=+\-@\t\r]/.test(str)) str = "'" + str; + if (/^[=+\-@\t\r\n]/.test(str)) str = "'" + str; str = str.replace(/"/g, '""'); return `"${str}"`; } diff --git a/popup.html b/popup.html index dcd8d2a..8ddb08f 100644 --- a/popup.html +++ b/popup.html @@ -11,7 +11,7 @@
KeyFinder

KeyFinder

- v2.0 +

Passive API key & secret discovery

diff --git a/results.html b/results.html index 86a309b..5368f7a 100644 --- a/results.html +++ b/results.html @@ -10,7 +10,7 @@
KeyFinder -

KeyFinder v2.0

+

KeyFinder