Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 882e298d85 | |||
| 5d606109dc | |||
| 50a2f8b482 | |||
| 72f324adae | |||
| 806e0a4a7d | |||
| fdd3be3d99 | |||
| 8d2f3fc1e4 | |||
| 99e54bb93a | |||
| c922a1a166 | |||
| f25d07f97d | |||
| 7b7b68ae71 | |||
| 403ec3058a |
@@ -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"]
|
||||||
@@ -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
|
*.crx
|
||||||
*.pem
|
*.pem
|
||||||
*.zip
|
*.zip
|
||||||
|
dist/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 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.2.0] - 2026-07-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Ops-console UI redesign of the popup and findings dashboard: radar-sweep brand mark, ARMED/PASSIVE status LEDs, scanline texture, monospace UI chrome, and a semantic color system (amber = actions, teal = recovered secrets, red/orange/yellow/teal severity scale)
|
||||||
|
- Popup: count-up stat numerals, numbered watchlist entries, command-prompt keyword input with glowing focus ring, and a terminal status line typed with the live keyword/finding counts
|
||||||
|
- Dashboard: clickable severity stat tiles that filter the table, LATEST ticker for the most recent finding, `findings.log` table shell with severity-coded row edges, LED severity badges, click-to-copy match chips, relative timestamps (absolute on hover), skeleton loading rows, staggered first-paint rows, and an animated radar empty state that distinguishes "no findings yet" from "filters hide everything"
|
||||||
|
- Keyboard shortcuts on the dashboard: `/` focuses search, `Esc` clears and blurs it
|
||||||
|
- `prefers-reduced-motion` support across all animations
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Severity sort treated `critical` as lowest priority (`severityOrder[s] || 5` maps `critical: 0` to the fallback), so critical findings rendered last; they now lead the table
|
||||||
|
- Latest-finding ticker no longer stays visible when storage is empty (`display: flex` was overriding the `hidden` attribute)
|
||||||
|
|
||||||
|
## [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
|
||||||
@@ -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>
|
<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,15 +84,15 @@ 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
|
||||||
|
|
||||||
<img src="https://github.com/momenbasel/keyFinder/blob/master/installGif.gif?raw=true" alt="Installation demo"/>
|
**Firefox:**
|
||||||
|
1. Go to `about:debugging` > "This Firefox"
|
||||||
## Demo
|
2. Click **Load Temporary Add-on**
|
||||||
|
3. Select `manifest.firefox.json` from the `keyFinder` folder
|
||||||

|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -110,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
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -1,240 +1,481 @@
|
|||||||
* {
|
/* ============================================================
|
||||||
margin: 0;
|
KeyFinder — ops console popup
|
||||||
padding: 0;
|
design system: dark instrument panel · amber = action
|
||||||
box-sizing: border-box;
|
teal = recovered secrets · mono chrome · 1px hairlines
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #05070c;
|
||||||
|
--bg-raise: #0a0e16;
|
||||||
|
--bg-overlay: #101623;
|
||||||
|
--line: #1a2233;
|
||||||
|
--line-strong: #2a3650;
|
||||||
|
--text: #d6dde8;
|
||||||
|
--text-dim: #8b96a8;
|
||||||
|
--text-faint: #525d70;
|
||||||
|
--amber: #f5a524;
|
||||||
|
--amber-hot: #ffc45c;
|
||||||
|
--teal: #45d0c4;
|
||||||
|
--red: #ff5c5c;
|
||||||
|
--green: #3ddc97;
|
||||||
|
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco,
|
||||||
|
Consolas, "Liberation Mono", monospace;
|
||||||
|
--ease: cubic-bezier(.24, .58, .47, .99);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { color-scheme: dark; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
width: 360px;
|
width: 372px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: var(--mono);
|
||||||
background: #0f0f0f;
|
background: var(--bg);
|
||||||
color: #e0e0e0;
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
font-feature-settings: "tnum";
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* vignette + scanlines — instrument-panel texture */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(130% 90% at 50% -10%, rgba(245,165,36,.05) 0%, transparent 45%),
|
||||||
|
radial-gradient(120% 100% at 50% 110%, rgba(0,0,0,.5) 0%, transparent 55%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: repeating-linear-gradient(0deg, rgba(255,255,255,.016) 0 1px, transparent 1px 3px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection { background: rgba(245,165,36,.28); color: #fff; }
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 1px var(--bg), 0 0 0 3px var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------ header */
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
padding: 16px 16px 12px;
|
position: relative;
|
||||||
border-bottom: 1px solid #1e1e1e;
|
z-index: 1;
|
||||||
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a2e 100%);
|
padding: 14px 14px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, rgba(245,165,36,.045) 0%, transparent 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-brand {
|
.header-brand { display: flex; align-items: center; gap: 11px; }
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-icon {
|
/* radar: rotating sweep ring + teal blip around the key */
|
||||||
width: 28px;
|
.radar {
|
||||||
height: 28px;
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: radial-gradient(circle at 50% 42%, #0d1320 0%, #070a11 72%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
.radar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(from 0deg,
|
||||||
|
rgba(245,165,36,.95) 0deg, rgba(245,165,36,.22) 60deg, transparent 95deg);
|
||||||
|
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 1.5px));
|
||||||
|
mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 1.5px));
|
||||||
|
animation: radar-sweep 4.2s linear infinite;
|
||||||
|
}
|
||||||
|
.radar::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
left: 9px;
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--teal);
|
||||||
|
box-shadow: 0 0 6px var(--teal);
|
||||||
|
animation: blip 2.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.header-icon { width: 20px; height: 20px; display: block; }
|
||||||
|
|
||||||
|
@keyframes radar-sweep { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes blip { 0%, 100% { opacity: .15; } 50% { opacity: 1; } }
|
||||||
|
|
||||||
|
.brand-text { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
.header-brand h1 {
|
.header-brand h1 {
|
||||||
font-size: 18px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ffffff;
|
color: #fff;
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: .22em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version {
|
.version {
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #666;
|
letter-spacing: .08em;
|
||||||
background: #1a1a1a;
|
color: var(--text-faint);
|
||||||
padding: 2px 6px;
|
border: 1px solid var(--line);
|
||||||
border-radius: 4px;
|
padding: 2px 5px;
|
||||||
margin-left: 4px;
|
border-radius: 2px;
|
||||||
|
background: var(--bg-raise);
|
||||||
}
|
}
|
||||||
|
.version::before { content: "["; color: var(--line-strong); }
|
||||||
|
.version::after { content: "]"; color: var(--line-strong); }
|
||||||
|
|
||||||
.header-tagline {
|
.header-tagline {
|
||||||
margin-top: 4px;
|
margin-top: 3px;
|
||||||
font-size: 11px;
|
font-size: 9.5px;
|
||||||
color: #666;
|
letter-spacing: .1em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.header-tagline::before { content: "// "; color: var(--amber); opacity: .7; }
|
||||||
|
|
||||||
|
/* armed badge — pulsing status LED */
|
||||||
|
.live-badge {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 8.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .2em;
|
||||||
|
color: var(--green);
|
||||||
|
border: 1px solid rgba(61,220,151,.32);
|
||||||
|
background: rgba(61,220,151,.06);
|
||||||
|
padding: 4px 7px 4px 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.live-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 8px var(--green);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
|
||||||
|
|
||||||
|
/* ------------------------------------------------ stats */
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
padding: 12px 16px;
|
padding: 10px 14px;
|
||||||
border-bottom: 1px solid #1e1e1e;
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 4px;
|
||||||
padding: 10px;
|
padding: 9px 11px 8px;
|
||||||
background: #141414;
|
background: var(--bg-raise);
|
||||||
border-radius: 8px;
|
border: 1px solid var(--line);
|
||||||
border: 1px solid #1e1e1e;
|
border-radius: 3px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--accent, var(--amber)), transparent 70%);
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
.stat-findings { --accent: var(--amber); }
|
||||||
|
.stat-keywords { --accent: var(--teal); }
|
||||||
|
|
||||||
|
.stat-top { display: flex; align-items: center; gap: 6px; }
|
||||||
|
|
||||||
|
.stat-led {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent, var(--amber));
|
||||||
|
box-shadow: 0 0 5px var(--accent, var(--amber));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 8.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .18em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-number {
|
.stat-number {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
/* ------------------------------------------------ watchlist */
|
||||||
font-size: 10px;
|
|
||||||
color: #666;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
.section { position: relative; z-index: 1; padding: 12px 14px 10px; }
|
||||||
padding: 12px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 11px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #666;
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.8px;
|
letter-spacing: .22em;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.section-sub {
|
||||||
|
font-size: 8.5px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* command-prompt input bar */
|
||||||
.keyword-form {
|
.keyword-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
align-items: stretch;
|
||||||
|
background: var(--bg-raise);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 3px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
transition: border-color .15s var(--ease), box-shadow .15s var(--ease);
|
||||||
|
}
|
||||||
|
.keyword-form:focus-within {
|
||||||
|
border-color: var(--amber);
|
||||||
|
box-shadow: 0 0 0 1px rgba(245,165,36,.35), 0 0 18px rgba(245,165,36,.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0 4px 0 10px;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-form input {
|
.keyword-form input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 7px 10px;
|
min-width: 0;
|
||||||
background: #141414;
|
padding: 8px 8px 8px 2px;
|
||||||
border: 1px solid #2a2a2a;
|
background: transparent;
|
||||||
border-radius: 6px;
|
border: none;
|
||||||
color: #e0e0e0;
|
color: var(--text);
|
||||||
font-size: 12px;
|
font-family: var(--mono);
|
||||||
|
font-size: 11.5px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
box-shadow: none;
|
||||||
}
|
|
||||||
|
|
||||||
.keyword-form input:focus {
|
|
||||||
border-color: #4a9eff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyword-form input::placeholder {
|
|
||||||
color: #444;
|
|
||||||
}
|
}
|
||||||
|
.keyword-form input::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
.keyword-form button {
|
.keyword-form button {
|
||||||
padding: 7px 14px;
|
margin: 3px;
|
||||||
background: #4a9eff;
|
padding: 0 13px;
|
||||||
color: #fff;
|
background: var(--amber);
|
||||||
|
color: #120a00;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 2px;
|
||||||
font-size: 12px;
|
font-family: var(--mono);
|
||||||
font-weight: 600;
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .12em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: filter .15s var(--ease), transform .05s var(--ease);
|
||||||
}
|
|
||||||
|
|
||||||
.keyword-form button:hover {
|
|
||||||
background: #3a8eef;
|
|
||||||
}
|
}
|
||||||
|
.keyword-form button:hover { filter: brightness(1.12); }
|
||||||
|
.keyword-form button:active { transform: translateY(1px); }
|
||||||
|
|
||||||
.error-msg {
|
.error-msg {
|
||||||
padding: 6px 10px;
|
padding: 6px 9px;
|
||||||
background: #2a1515;
|
background: rgba(255,92,92,.07);
|
||||||
border: 1px solid #4a2020;
|
border: 1px solid rgba(255,92,92,.3);
|
||||||
border-radius: 6px;
|
border-left-width: 2px;
|
||||||
color: #ff6b6b;
|
border-radius: 2px;
|
||||||
font-size: 11px;
|
color: var(--red);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .04em;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
animation: shake .3s var(--ease);
|
||||||
|
}
|
||||||
|
.error-msg::before { content: "ERR // "; font-weight: 700; opacity: .7; }
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-3px); }
|
||||||
|
75% { transform: translateX(3px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* keyword list — numbered watchlist entries */
|
||||||
.keyword-list {
|
.keyword-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
max-height: 240px;
|
counter-reset: kw;
|
||||||
|
max-height: 218px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
margin: 0 -4px;
|
||||||
|
padding: 0 4px;
|
||||||
.keyword-list::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyword-list::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyword-list::-webkit-scrollbar-thumb {
|
|
||||||
background: #333;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-item {
|
.keyword-item {
|
||||||
|
counter-increment: kw;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 6px 10px;
|
gap: 9px;
|
||||||
border-radius: 6px;
|
padding: 5px 7px;
|
||||||
transition: background 0.1s;
|
border-radius: 2px;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: background .12s var(--ease), border-color .12s var(--ease);
|
||||||
|
}
|
||||||
|
.keyword-item::before {
|
||||||
|
content: counter(kw, decimal-leading-zero);
|
||||||
|
font-size: 8.5px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: .05em;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-item:hover {
|
.keyword-item:hover {
|
||||||
background: #1a1a1a;
|
background: var(--bg-raise);
|
||||||
|
border-left-color: var(--teal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-label {
|
.keyword-label {
|
||||||
font-family: "SF Mono", "Fira Code", "Consolas", monospace;
|
flex: 1;
|
||||||
font-size: 12px;
|
min-width: 0;
|
||||||
color: #ccc;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-remove {
|
.keyword-remove {
|
||||||
width: 22px;
|
flex: none;
|
||||||
height: 22px;
|
width: 20px;
|
||||||
display: flex;
|
height: 20px;
|
||||||
align-items: center;
|
display: grid;
|
||||||
justify-content: center;
|
place-items: center;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
color: #555;
|
color: var(--text-faint);
|
||||||
font-size: 16px;
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
transition: all 0.1s;
|
opacity: .45;
|
||||||
|
transition: all .12s var(--ease);
|
||||||
}
|
}
|
||||||
|
.keyword-item:hover .keyword-remove { opacity: 1; }
|
||||||
.keyword-remove:hover {
|
.keyword-remove:hover {
|
||||||
background: #2a1515;
|
background: rgba(255,92,92,.1);
|
||||||
color: #ff6b6b;
|
border-color: rgba(255,92,92,.35);
|
||||||
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 16px;
|
padding: 14px 10px;
|
||||||
color: #444;
|
color: var(--text-faint);
|
||||||
font-size: 12px;
|
font-size: 10.5px;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
.empty-state::before { content: "[ empty watchlist ]"; display: block; margin-bottom: 2px; opacity: .6; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------ footer */
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
padding: 12px 16px;
|
position: relative;
|
||||||
border-top: 1px solid #1e1e1e;
|
z-index: 1;
|
||||||
|
padding: 10px 14px 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* typed terminal status line */
|
||||||
|
.term-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 16px;
|
||||||
|
margin-bottom: 9px;
|
||||||
|
font-size: 9.5px;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.term-text { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.term-cursor {
|
||||||
|
flex: none;
|
||||||
|
width: 6px;
|
||||||
|
height: 11px;
|
||||||
|
margin-left: 3px;
|
||||||
|
background: var(--amber);
|
||||||
|
animation: cursor-blink 1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes cursor-blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
|
||||||
|
|
||||||
.results-btn {
|
.results-btn {
|
||||||
display: block;
|
display: flex;
|
||||||
text-align: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
background: var(--amber);
|
||||||
color: #4a9eff;
|
color: #120a00;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 8px;
|
border-radius: 3px;
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
border: 1px solid #1e2d4a;
|
text-transform: uppercase;
|
||||||
transition: all 0.15s;
|
letter-spacing: .16em;
|
||||||
|
transition: filter .15s var(--ease), transform .05s var(--ease);
|
||||||
}
|
}
|
||||||
|
.results-btn:hover { filter: brightness(1.12); }
|
||||||
|
.results-btn:active { transform: translateY(1px); }
|
||||||
|
.btn-arrow { transition: transform .18s var(--ease); }
|
||||||
|
.results-btn:hover .btn-arrow { transform: translateX(3px); }
|
||||||
|
|
||||||
.results-btn:hover {
|
/* ------------------------------------------------ scrollbars */
|
||||||
background: linear-gradient(135deg, #1e2040 0%, #1a2848 100%);
|
|
||||||
border-color: #2a4070;
|
::-webkit-scrollbar { width: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #1c2536; border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #2a3650; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------ reduced motion */
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: .01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: .01ms !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,341 +1,730 @@
|
|||||||
* {
|
/* ============================================================
|
||||||
margin: 0;
|
KeyFinder — findings console
|
||||||
padding: 0;
|
dark instrument panel · amber = action · teal = secrets
|
||||||
box-sizing: border-box;
|
severity scale: red > orange > yellow > teal
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #05070c;
|
||||||
|
--bg-raise: #0a0e16;
|
||||||
|
--bg-overlay: #101623;
|
||||||
|
--line: #1a2233;
|
||||||
|
--line-strong: #2a3650;
|
||||||
|
--text: #d6dde8;
|
||||||
|
--text-dim: #8b96a8;
|
||||||
|
--text-faint: #525d70;
|
||||||
|
--amber: #f5a524;
|
||||||
|
--amber-hot: #ffc45c;
|
||||||
|
--teal: #45d0c4;
|
||||||
|
--red: #ff5c5c;
|
||||||
|
--orange: #ff9431;
|
||||||
|
--yellow: #ffd166;
|
||||||
|
--green: #3ddc97;
|
||||||
|
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco,
|
||||||
|
Consolas, "Liberation Mono", monospace;
|
||||||
|
--ease: cubic-bezier(.24, .58, .47, .99);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { color-scheme: dark; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: var(--mono);
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
color: #e0e0e0;
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
font-feature-settings: "tnum";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* vignette + scanlines */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(130% 70% at 50% -10%, rgba(245,165,36,.05) 0%, transparent 45%),
|
||||||
|
radial-gradient(120% 100% at 50% 115%, rgba(0,0,0,.45) 0%, transparent 55%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: repeating-linear-gradient(0deg, rgba(255,255,255,.014) 0 1px, transparent 1px 3px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection { background: rgba(245,165,36,.28); color: #fff; }
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 1px var(--bg), 0 0 0 3px var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------ header */
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
padding: 16px 24px;
|
position: relative;
|
||||||
background: #0f0f0f;
|
z-index: 1;
|
||||||
border-bottom: 1px solid #1e1e1e;
|
padding: 14px 24px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, rgba(245,165,36,.04) 0%, transparent 100%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icon {
|
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left h1 {
|
/* radar sweep around the key */
|
||||||
font-size: 20px;
|
.radar {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: radial-gradient(circle at 50% 42%, #0d1320 0%, #070a11 72%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.radar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(from 0deg,
|
||||||
|
rgba(245,165,36,.95) 0deg, rgba(245,165,36,.22) 60deg, transparent 95deg);
|
||||||
|
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 1.5px));
|
||||||
|
mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 1.5px));
|
||||||
|
animation: radar-sweep 4.2s linear infinite;
|
||||||
|
}
|
||||||
|
.radar::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 10px;
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--teal);
|
||||||
|
box-shadow: 0 0 6px var(--teal);
|
||||||
|
animation: blip 2.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.header-icon { width: 22px; height: 22px; display: block; }
|
||||||
|
|
||||||
|
@keyframes radar-sweep { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes blip { 0%, 100% { opacity: .15; } 50% { opacity: 1; } }
|
||||||
|
|
||||||
|
.brand-text h1 {
|
||||||
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
letter-spacing: .22em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version {
|
.version {
|
||||||
font-size: 11px;
|
font-size: 9px;
|
||||||
color: #555;
|
font-weight: 500;
|
||||||
font-weight: 400;
|
letter-spacing: .08em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-raise);
|
||||||
}
|
}
|
||||||
|
.version::before { content: "["; color: var(--line-strong); }
|
||||||
|
.version::after { content: "]"; color: var(--line-strong); }
|
||||||
|
|
||||||
|
.header-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.header-sub::before { content: "// "; color: var(--amber); opacity: .7; }
|
||||||
|
|
||||||
|
.live-badge {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 8.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .2em;
|
||||||
|
color: var(--green);
|
||||||
|
border: 1px solid rgba(61,220,151,.32);
|
||||||
|
background: rgba(61,220,151,.06);
|
||||||
|
padding: 4px 8px 4px 7px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.live-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 8px var(--green);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
|
||||||
|
|
||||||
|
/* ------------------------------------------------ header actions */
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* terminal-style search bar */
|
||||||
|
.search-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
background: var(--bg-raise);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 8px;
|
||||||
|
transition: border-color .15s var(--ease), box-shadow .15s var(--ease);
|
||||||
|
}
|
||||||
|
.search-wrap:focus-within {
|
||||||
|
border-color: var(--amber);
|
||||||
|
box-shadow: 0 0 0 1px rgba(245,165,36,.3), 0 0 18px rgba(245,165,36,.06);
|
||||||
|
}
|
||||||
|
.search-glyph { color: var(--amber); font-size: 13px; user-select: none; }
|
||||||
|
.search-wrap input {
|
||||||
|
width: 210px;
|
||||||
|
padding: 7px 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.search-wrap input::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.kbd {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group { display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s var(--ease);
|
||||||
|
}
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
color: var(--amber-hot);
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: rgba(245,165,36,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
border-color: rgba(255,92,92,.35);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: rgba(255,92,92,.1); border-color: var(--red); }
|
||||||
|
|
||||||
|
/* ------------------------------------------------ filter row */
|
||||||
|
|
||||||
|
.header-row-filters { gap: 10px; }
|
||||||
|
|
||||||
.filter-group {
|
.filter-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group select,
|
.filter {
|
||||||
.filter-group input {
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: #141414;
|
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #ccc;
|
|
||||||
font-size: 12px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-group select:focus,
|
|
||||||
.filter-group input:focus {
|
|
||||||
border-color: #4a9eff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-group input {
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--bg-raise);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color .15s var(--ease);
|
||||||
|
}
|
||||||
|
.filter:focus-within { border-color: var(--amber); }
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 8.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--amber);
|
||||||
|
background: rgba(245,165,36,.07);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
padding: 7px 7px 7px 9px;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.filter select {
|
||||||
padding: 6px 14px;
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
padding: 6px 24px 6px 9px;
|
||||||
|
background-color: transparent;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%238b96a8' fill='none'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
color: var(--text);
|
||||||
font-size: 12px;
|
font-family: var(--mono);
|
||||||
font-weight: 600;
|
font-size: 10.5px;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
max-width: 180px;
|
||||||
}
|
}
|
||||||
|
.filter select option { background: var(--bg-overlay); color: var(--text); }
|
||||||
|
|
||||||
.btn-secondary {
|
.meta-line {
|
||||||
background: #1a1a1a;
|
margin-left: auto;
|
||||||
color: #ccc;
|
font-size: 9.5px;
|
||||||
border: 1px solid #2a2a2a;
|
letter-spacing: .1em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.meta-line strong { color: var(--text-dim); font-weight: 600; }
|
||||||
|
|
||||||
.btn-secondary:hover {
|
/* ------------------------------------------------ stats bar */
|
||||||
background: #222;
|
|
||||||
border-color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #2a1515;
|
|
||||||
color: #ff6b6b;
|
|
||||||
border: 1px solid #4a2020;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: #3a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bar {
|
.stats-bar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1px;
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
background: #0f0f0f;
|
border-bottom: 1px solid var(--line);
|
||||||
border-bottom: 1px solid #1e1e1e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 96px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 4px;
|
||||||
padding: 10px;
|
padding: 9px 12px 8px;
|
||||||
background: #111;
|
background: var(--bg-raise);
|
||||||
border-radius: 6px;
|
border: 1px solid var(--line);
|
||||||
margin: 0 4px;
|
border-radius: 3px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color .15s var(--ease), background .15s var(--ease);
|
||||||
}
|
}
|
||||||
|
.stat-item::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--accent, var(--text-dim)), transparent 75%);
|
||||||
|
}
|
||||||
|
.stat-item[data-filter] { cursor: pointer; }
|
||||||
|
.stat-item[data-filter]:hover { background: var(--bg-overlay); border-color: var(--line-strong); }
|
||||||
|
.stat-item.active { border-color: var(--accent, var(--amber)); box-shadow: 0 0 0 1px var(--accent, var(--amber)) inset; }
|
||||||
|
|
||||||
|
.stat-total { --accent: #d6dde8; }
|
||||||
|
.stat-critical { --accent: var(--red); }
|
||||||
|
.stat-high { --accent: var(--orange); }
|
||||||
|
.stat-medium { --accent: var(--yellow); }
|
||||||
|
.stat-low { --accent: var(--teal); }
|
||||||
|
.stat-domains { --accent: var(--text-faint); }
|
||||||
|
|
||||||
|
.stat-top { display: flex; align-items: center; gap: 6px; }
|
||||||
|
|
||||||
|
.stat-led {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent, var(--text-dim));
|
||||||
|
box-shadow: 0 0 5px var(--accent, var(--text-dim));
|
||||||
|
}
|
||||||
|
.stat-critical .stat-led { animation: pulse 1.5s ease-in-out infinite; }
|
||||||
|
|
||||||
.stat-num {
|
.stat-num {
|
||||||
font-size: 20px;
|
font-size: 21px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
line-height: 1.1;
|
||||||
|
color: var(--accent, #fff);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
.stat-total .stat-num { color: #fff; }
|
||||||
|
|
||||||
.stat-lbl {
|
.stat-lbl {
|
||||||
font-size: 10px;
|
font-size: 8.5px;
|
||||||
color: #555;
|
font-weight: 600;
|
||||||
|
color: var(--text-faint);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: .18em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-critical .stat-num { color: #ff4444; }
|
/* ------------------------------------------------ ticker */
|
||||||
.stat-high .stat-num { color: #ff8c42; }
|
|
||||||
.stat-medium .stat-num { color: #ffd166; }
|
|
||||||
.stat-low .stat-num { color: #4ecdc4; }
|
|
||||||
|
|
||||||
.main {
|
.ticker {
|
||||||
padding: 16px 24px;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 7px 24px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(245,165,36,.03);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ticker[hidden] { display: none; }
|
||||||
|
|
||||||
|
.ticker-dot {
|
||||||
|
flex: none;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--amber);
|
||||||
|
box-shadow: 0 0 6px var(--amber);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.ticker-label {
|
||||||
|
flex: none;
|
||||||
|
font-size: 8.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .2em;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
.ticker-text {
|
||||||
|
color: var(--text-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ticker-text code { color: var(--teal); }
|
||||||
|
|
||||||
|
/* ------------------------------------------------ table shell */
|
||||||
|
|
||||||
|
.main { position: relative; z-index: 1; padding: 16px 24px 20px; }
|
||||||
|
|
||||||
|
.table-shell {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(10,14,22,.55);
|
||||||
|
}
|
||||||
|
/* HUD corner brackets */
|
||||||
|
.table-shell::before,
|
||||||
|
.table-shell::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border: 0 solid var(--amber);
|
||||||
|
opacity: .55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.table-shell::before { top: -1px; left: -1px; border-top-width: 1px; border-left-width: 1px; }
|
||||||
|
.table-shell::after { bottom: -1px; right: -1px; border-bottom-width: 1px; border-right-width: 1px; }
|
||||||
|
|
||||||
|
.shell-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.shell-title {
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.shell-title::before { content: "● "; color: var(--amber); font-size: 7px; vertical-align: 1.5px; }
|
||||||
|
.shell-meta {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.findings-table {
|
.findings-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 980px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.findings-table thead th {
|
.findings-table thead th {
|
||||||
padding: 8px 10px;
|
padding: 8px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 10px;
|
font-size: 8.5px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #555;
|
color: var(--text-faint);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: .16em;
|
||||||
border-bottom: 1px solid #1e1e1e;
|
border-bottom: 1px solid var(--line);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.findings-table tbody tr {
|
.findings-table tbody tr {
|
||||||
border-bottom: 1px solid #141414;
|
border-bottom: 1px solid rgba(26,34,51,.5);
|
||||||
transition: background 0.1s;
|
transition: background .1s var(--ease);
|
||||||
}
|
}
|
||||||
|
.findings-table tbody tr:last-child { border-bottom: none; }
|
||||||
|
.findings-table tbody tr:hover { background: rgba(255,255,255,.022); }
|
||||||
|
|
||||||
.findings-table tbody tr:hover {
|
/* severity accent on the row's leading edge */
|
||||||
background: #111;
|
.findings-table tbody tr td:first-child { box-shadow: inset 2px 0 0 transparent; }
|
||||||
|
.findings-table tbody tr.sev-critical td:first-child { box-shadow: inset 2px 0 0 var(--red); }
|
||||||
|
.findings-table tbody tr.sev-high td:first-child { box-shadow: inset 2px 0 0 var(--orange); }
|
||||||
|
.findings-table tbody tr.sev-medium td:first-child { box-shadow: inset 2px 0 0 var(--yellow); }
|
||||||
|
.findings-table tbody tr.sev-low td:first-child { box-shadow: inset 2px 0 0 var(--teal); }
|
||||||
|
.findings-table tbody tr.sev-info td:first-child { box-shadow: inset 2px 0 0 var(--text-faint); }
|
||||||
|
|
||||||
|
/* first-paint stagger */
|
||||||
|
.findings-table tbody.fresh tr {
|
||||||
|
opacity: 0;
|
||||||
|
animation: row-in .35s var(--ease) forwards;
|
||||||
|
animation-delay: calc(var(--i, 0) * 16ms);
|
||||||
|
}
|
||||||
|
@keyframes row-in {
|
||||||
|
from { opacity: 0; transform: translateY(3px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.findings-table td {
|
.findings-table td {
|
||||||
padding: 8px 10px;
|
padding: 7px 12px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
max-width: 280px;
|
max-width: 220px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.td-num { color: var(--text-faint); font-size: 9.5px; }
|
||||||
|
|
||||||
|
/* severity badge — LED + label */
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
padding: 2px 8px;
|
align-items: center;
|
||||||
border-radius: 4px;
|
gap: 6px;
|
||||||
font-size: 10px;
|
padding: 3px 8px 3px 7px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 8.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: .14em;
|
||||||
|
color: var(--sev, var(--text-dim));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--sev, #8b96a8) 38%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--sev, #8b96a8) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
.badge::before {
|
||||||
.badge-critical {
|
content: "";
|
||||||
background: #3a0a0a;
|
width: 4px;
|
||||||
color: #ff4444;
|
height: 4px;
|
||||||
border: 1px solid #5a1515;
|
border-radius: 50%;
|
||||||
}
|
background: var(--sev, var(--text-dim));
|
||||||
|
box-shadow: 0 0 5px var(--sev, transparent);
|
||||||
.badge-high {
|
|
||||||
background: #3a1f0a;
|
|
||||||
color: #ff8c42;
|
|
||||||
border: 1px solid #5a3015;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-medium {
|
|
||||||
background: #3a3a0a;
|
|
||||||
color: #ffd166;
|
|
||||||
border: 1px solid #5a5a15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-low {
|
|
||||||
background: #0a3a30;
|
|
||||||
color: #4ecdc4;
|
|
||||||
border: 1px solid #155a4a;
|
|
||||||
}
|
}
|
||||||
|
.badge-critical { --sev: var(--red); }
|
||||||
|
.badge-critical::before { animation: pulse 1.5s ease-in-out infinite; }
|
||||||
|
.badge-high { --sev: var(--orange); }
|
||||||
|
.badge-medium { --sev: var(--yellow); }
|
||||||
|
.badge-low { --sev: var(--teal); }
|
||||||
|
.badge-info { --sev: var(--text-dim); }
|
||||||
|
|
||||||
.type-badge {
|
.type-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 6px;
|
padding: 2px 7px;
|
||||||
background: #1a1a1a;
|
background: var(--bg-raise);
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid var(--line);
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
color: #888;
|
letter-spacing: .06em;
|
||||||
font-family: "SF Mono", "Fira Code", monospace;
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* the recovered secret — teal mono chip, click to copy */
|
||||||
.match-value {
|
.match-value {
|
||||||
font-family: "SF Mono", "Fira Code", "Consolas", monospace;
|
font-family: var(--mono);
|
||||||
font-size: 11px;
|
font-size: 10.5px;
|
||||||
background: #141414;
|
background: rgba(69,208,196,.06);
|
||||||
padding: 2px 6px;
|
padding: 3px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
border: 1px solid #1e1e1e;
|
border: 1px solid rgba(69,208,196,.22);
|
||||||
color: #4ecdc4;
|
color: var(--teal);
|
||||||
max-width: 260px;
|
max-width: 220px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: copy;
|
||||||
|
transition: all .12s var(--ease);
|
||||||
|
}
|
||||||
|
.match-value:hover {
|
||||||
|
background: rgba(69,208,196,.12);
|
||||||
|
border-color: var(--teal);
|
||||||
|
box-shadow: 0 0 12px rgba(69,208,196,.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.td-provider {
|
.td-provider { font-weight: 700; color: var(--text); font-size: 10.5px; letter-spacing: .04em; max-width: 130px; }
|
||||||
font-weight: 600;
|
.td-pattern { color: var(--text-dim); font-size: 10px; max-width: 170px; }
|
||||||
color: #aaa;
|
.td-domain { color: var(--amber); font-size: 10.5px; max-width: 180px; }
|
||||||
}
|
.td-source { max-width: 200px; }
|
||||||
|
.td-time { color: var(--text-faint); font-size: 10px; white-space: nowrap; }
|
||||||
|
|
||||||
.td-pattern {
|
a { color: var(--teal); text-decoration: none; border-bottom: 1px dashed rgba(69,208,196,.35); }
|
||||||
color: #888;
|
a:hover { color: #8be8de; border-bottom-style: solid; }
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-domain {
|
/* row actions — revealed on row hover */
|
||||||
color: #4a9eff;
|
.td-actions { white-space: nowrap; text-align: right; }
|
||||||
}
|
|
||||||
|
|
||||||
.td-time {
|
|
||||||
color: #555;
|
|
||||||
font-size: 11px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #4a9eff;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
|
font-family: var(--mono);
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
background: #1a1a1a;
|
background: transparent;
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid var(--line-strong);
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
color: #888;
|
color: var(--text-faint);
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
transition: all 0.1s;
|
opacity: .35;
|
||||||
|
transition: all .12s var(--ease);
|
||||||
}
|
}
|
||||||
|
.findings-table tbody tr:hover .btn-icon,
|
||||||
|
.btn-icon:focus-visible { opacity: 1; }
|
||||||
|
.btn-icon:hover { color: var(--amber-hot); border-color: var(--amber); background: rgba(245,165,36,.06); }
|
||||||
|
.btn-icon.copied { color: var(--green); border-color: var(--green); }
|
||||||
|
.btn-icon-danger:hover { color: var(--red); border-color: var(--red); background: rgba(255,92,92,.08); }
|
||||||
|
|
||||||
.btn-icon:hover {
|
/* skeleton loading rows */
|
||||||
background: #222;
|
.skl-row td { padding: 9px 12px; }
|
||||||
color: #ccc;
|
.skl {
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(90deg, #0c1119 25%, #151c2b 50%, #0c1119 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.15s linear infinite;
|
||||||
}
|
}
|
||||||
|
.skl-w1 { width: 34%; } .skl-w2 { width: 62%; } .skl-w3 { width: 48%; } .skl-w4 { width: 78%; }
|
||||||
|
@keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
||||||
|
|
||||||
.btn-icon-danger:hover {
|
/* ------------------------------------------------ empty state */
|
||||||
background: #2a1515;
|
|
||||||
color: #ff6b6b;
|
|
||||||
border-color: #4a2020;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 56px 20px 50px;
|
||||||
color: #444;
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-radar { width: 72px; height: 72px; margin-bottom: 18px; opacity: .8; }
|
||||||
font-size: 48px;
|
.er-ring { fill: none; stroke: var(--line-strong); stroke-width: 1; }
|
||||||
margin-bottom: 16px;
|
.er-cross { stroke: var(--line); stroke-width: 1; }
|
||||||
opacity: 0.3;
|
.er-core { fill: var(--teal); }
|
||||||
|
.er-sweep {
|
||||||
|
fill: rgba(245,165,36,.14);
|
||||||
|
transform-origin: 36px 36px;
|
||||||
|
animation: radar-sweep 4.2s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-title {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
max-width: 400px;
|
font-weight: 700;
|
||||||
margin: 0 auto;
|
letter-spacing: .18em;
|
||||||
line-height: 1.6;
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
.empty-copy {
|
||||||
|
font-size: 10.5px;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 9.5px;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
color: var(--amber);
|
||||||
|
opacity: .75;
|
||||||
|
}
|
||||||
|
.empty-hint::before { content: "» "; }
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
/* ------------------------------------------------ page footer */
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
.page-footer {
|
||||||
background: transparent;
|
margin-top: 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: .7;
|
||||||
}
|
}
|
||||||
|
.footer-sep { margin: 0 8px; color: var(--line-strong); }
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
/* ------------------------------------------------ scrollbars */
|
||||||
background: #333;
|
|
||||||
border-radius: 4px;
|
::-webkit-scrollbar { width: 7px; height: 7px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #1c2536; border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #2a3650; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------ reduced motion */
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: .01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: .01ms !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
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 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) : "" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
"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;
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -1,21 +1,71 @@
|
|||||||
document.addEventListener("DOMContentLoaded", init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
|
const REDUCE_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
let lastKeywords = 0;
|
||||||
|
let lastFindings = 0;
|
||||||
|
let termBooted = false;
|
||||||
|
|
||||||
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();
|
||||||
|
updateTermLine(true);
|
||||||
|
termBooted = true;
|
||||||
document.getElementById("keywordForm").addEventListener("submit", handleAddKeyword);
|
document.getElementById("keywordForm").addEventListener("submit", handleAddKeyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* eased count-up for stat numerals */
|
||||||
|
function setNumber(el, target) {
|
||||||
|
if (!el) return;
|
||||||
|
const from = parseInt(el.textContent, 10);
|
||||||
|
const start = Number.isFinite(from) ? from : 0;
|
||||||
|
if (REDUCE_MOTION || start === target) {
|
||||||
|
el.textContent = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const duration = 380;
|
||||||
|
const t0 = performance.now();
|
||||||
|
(function tick(t) {
|
||||||
|
const p = Math.min(1, (t - t0) / duration);
|
||||||
|
const eased = 1 - Math.pow(1 - p, 3);
|
||||||
|
el.textContent = Math.round(start + (target - start) * eased);
|
||||||
|
if (p < 1) requestAnimationFrame(tick);
|
||||||
|
})(t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* terminal status line — types once per popup open, instant after */
|
||||||
|
function updateTermLine(typed) {
|
||||||
|
const el = document.getElementById("termText");
|
||||||
|
if (!el) return;
|
||||||
|
const text = `kf> scan armed :: ${lastKeywords} keywords :: ${lastFindings} findings`;
|
||||||
|
if (!typed || REDUCE_MOTION) {
|
||||||
|
el.textContent = text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = 0;
|
||||||
|
(function type() {
|
||||||
|
el.textContent = text.slice(0, ++i);
|
||||||
|
if (i < text.length) setTimeout(type, 14);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
async function renderKeywords() {
|
async function renderKeywords() {
|
||||||
const response = await chrome.runtime.sendMessage({ type: "getKeywords" });
|
const response = await chrome.runtime.sendMessage({ type: "getKeywords" });
|
||||||
const keywords = response.keywords || [];
|
const keywords = response.keywords || [];
|
||||||
const list = document.getElementById("keywordList");
|
const list = document.getElementById("keywordList");
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
|
|
||||||
document.getElementById("keywordCount").textContent = keywords.length;
|
lastKeywords = keywords.length;
|
||||||
|
setNumber(document.getElementById("keywordCount"), keywords.length);
|
||||||
|
if (termBooted) updateTermLine(false);
|
||||||
|
|
||||||
if (keywords.length === 0) {
|
if (keywords.length === 0) {
|
||||||
list.innerHTML = '<li class="empty-state">No keywords configured</li>';
|
const empty = document.createElement("li");
|
||||||
|
empty.className = "empty-state";
|
||||||
|
empty.textContent = "no keywords configured";
|
||||||
|
list.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +79,7 @@ async function renderKeywords() {
|
|||||||
|
|
||||||
const removeBtn = document.createElement("button");
|
const removeBtn = document.createElement("button");
|
||||||
removeBtn.className = "keyword-remove";
|
removeBtn.className = "keyword-remove";
|
||||||
removeBtn.textContent = "\u00D7";
|
removeBtn.textContent = "×";
|
||||||
removeBtn.title = `Remove "${kw}"`;
|
removeBtn.title = `Remove "${kw}"`;
|
||||||
removeBtn.addEventListener("click", () => handleRemoveKeyword(kw));
|
removeBtn.addEventListener("click", () => handleRemoveKeyword(kw));
|
||||||
|
|
||||||
@@ -42,7 +92,9 @@ async function renderKeywords() {
|
|||||||
async function renderStats() {
|
async function renderStats() {
|
||||||
const response = await chrome.runtime.sendMessage({ type: "getFindings" });
|
const response = await chrome.runtime.sendMessage({ type: "getFindings" });
|
||||||
const findings = response.findings || [];
|
const findings = response.findings || [];
|
||||||
document.getElementById("findingCount").textContent = findings.length;
|
lastFindings = findings.length;
|
||||||
|
setNumber(document.getElementById("findingCount"), findings.length);
|
||||||
|
if (termBooted) updateTermLine(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddKeyword(e) {
|
async function handleAddKeyword(e) {
|
||||||
@@ -66,6 +118,7 @@ async function handleAddKeyword(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input.value = "";
|
input.value = "";
|
||||||
|
input.focus();
|
||||||
await renderKeywords();
|
await renderKeywords();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
let allFindings = [];
|
let allFindings = [];
|
||||||
|
let firstRenderDone = false;
|
||||||
|
|
||||||
|
const REDUCE_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
renderSkeleton();
|
||||||
|
|
||||||
const response = await chrome.runtime.sendMessage({ type: "getFindings" });
|
const response = await chrome.runtime.sendMessage({ type: "getFindings" });
|
||||||
allFindings = response.findings || [];
|
allFindings = response.findings || [];
|
||||||
|
|
||||||
populateFilters();
|
populateFilters();
|
||||||
renderStats();
|
renderStats();
|
||||||
renderFindings();
|
renderFindings();
|
||||||
|
renderTicker();
|
||||||
|
firstRenderDone = true;
|
||||||
|
|
||||||
document.getElementById("severityFilter").addEventListener("change", renderFindings);
|
document.getElementById("severityFilter").addEventListener("change", renderFindings);
|
||||||
document.getElementById("typeFilter").addEventListener("change", renderFindings);
|
document.getElementById("typeFilter").addEventListener("change", renderFindings);
|
||||||
@@ -17,6 +27,57 @@ async function init() {
|
|||||||
document.getElementById("exportJsonBtn").addEventListener("click", exportJson);
|
document.getElementById("exportJsonBtn").addEventListener("click", exportJson);
|
||||||
document.getElementById("exportCsvBtn").addEventListener("click", exportCsv);
|
document.getElementById("exportCsvBtn").addEventListener("click", exportCsv);
|
||||||
document.getElementById("clearBtn").addEventListener("click", clearAll);
|
document.getElementById("clearBtn").addEventListener("click", clearAll);
|
||||||
|
|
||||||
|
const searchBox = document.getElementById("searchBox");
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
const tag = document.activeElement && document.activeElement.tagName;
|
||||||
|
const typing = tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA";
|
||||||
|
if (e.key === "/" && !typing) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchBox.focus();
|
||||||
|
} else if (e.key === "Escape" && document.activeElement === searchBox) {
|
||||||
|
searchBox.value = "";
|
||||||
|
renderFindings();
|
||||||
|
searchBox.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eased count-up for stat numerals */
|
||||||
|
function setNumber(el, target) {
|
||||||
|
if (!el) return;
|
||||||
|
const from = parseInt(el.textContent, 10);
|
||||||
|
const start = Number.isFinite(from) ? from : 0;
|
||||||
|
if (REDUCE_MOTION || start === target) {
|
||||||
|
el.textContent = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const duration = 400;
|
||||||
|
const t0 = performance.now();
|
||||||
|
(function tick(t) {
|
||||||
|
const p = Math.min(1, (t - t0) / duration);
|
||||||
|
const eased = 1 - Math.pow(1 - p, 3);
|
||||||
|
el.textContent = Math.round(start + (target - start) * eased);
|
||||||
|
if (p < 1) requestAnimationFrame(tick);
|
||||||
|
})(t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSkeleton() {
|
||||||
|
const tbody = document.getElementById("findingsBody");
|
||||||
|
tbody.classList.remove("fresh");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
const widths = ["skl-w2", "skl-w1", "skl-w3", "skl-w4", "skl-w2", "skl-w3"];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "skl-row";
|
||||||
|
const td = document.createElement("td");
|
||||||
|
td.colSpan = 10;
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = "skl " + widths[i % widths.length];
|
||||||
|
td.appendChild(bar);
|
||||||
|
tr.appendChild(td);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFiltered() {
|
function getFiltered() {
|
||||||
@@ -65,54 +126,96 @@ function renderStats() {
|
|||||||
|
|
||||||
bar.innerHTML = "";
|
bar.innerHTML = "";
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "Total", value: allFindings.length, cls: "stat-total" },
|
{ label: "Total", value: allFindings.length, cls: "stat-total", filter: "all" },
|
||||||
{ label: "Critical", value: critical, cls: "stat-critical" },
|
{ label: "Critical", value: critical, cls: "stat-critical", filter: "critical" },
|
||||||
{ label: "High", value: high, cls: "stat-high" },
|
{ label: "High", value: high, cls: "stat-high", filter: "high" },
|
||||||
{ label: "Medium", value: medium, cls: "stat-medium" },
|
{ label: "Medium", value: medium, cls: "stat-medium", filter: "medium" },
|
||||||
{ label: "Low", value: low, cls: "stat-low" },
|
{ label: "Low", value: low, cls: "stat-low", filter: "low" },
|
||||||
{ label: "Domains", value: domains, cls: "stat-domains" },
|
{ label: "Domains", value: domains, cls: "stat-domains", filter: null },
|
||||||
];
|
];
|
||||||
for (const s of stats) {
|
for (const s of stats) {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = `stat-item ${s.cls}`;
|
el.className = `stat-item ${s.cls}`;
|
||||||
const num = document.createElement("span");
|
|
||||||
num.className = "stat-num";
|
const top = document.createElement("span");
|
||||||
num.textContent = s.value;
|
top.className = "stat-top";
|
||||||
|
const led = document.createElement("span");
|
||||||
|
led.className = "stat-led";
|
||||||
const lbl = document.createElement("span");
|
const lbl = document.createElement("span");
|
||||||
lbl.className = "stat-lbl";
|
lbl.className = "stat-lbl";
|
||||||
lbl.textContent = s.label;
|
lbl.textContent = s.label;
|
||||||
|
top.appendChild(led);
|
||||||
|
top.appendChild(lbl);
|
||||||
|
|
||||||
|
const num = document.createElement("span");
|
||||||
|
num.className = "stat-num";
|
||||||
|
|
||||||
|
el.appendChild(top);
|
||||||
el.appendChild(num);
|
el.appendChild(num);
|
||||||
el.appendChild(lbl);
|
setNumber(num, s.value);
|
||||||
|
|
||||||
|
if (s.filter) {
|
||||||
|
el.dataset.filter = s.filter;
|
||||||
|
el.title = s.filter === "all" ? "Show all severities" : `Filter: ${s.label.toLowerCase()} only`;
|
||||||
|
el.addEventListener("click", () => {
|
||||||
|
document.getElementById("severityFilter").value = s.filter;
|
||||||
|
renderFindings();
|
||||||
|
});
|
||||||
|
}
|
||||||
bar.appendChild(el);
|
bar.appendChild(el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncStatActive() {
|
||||||
|
const current = document.getElementById("severityFilter").value;
|
||||||
|
document.querySelectorAll(".stat-item[data-filter]").forEach((el) => {
|
||||||
|
el.classList.toggle("active", el.dataset.filter === current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderFindings() {
|
function renderFindings() {
|
||||||
const filtered = getFiltered();
|
const filtered = getFiltered();
|
||||||
const tbody = document.getElementById("findingsBody");
|
const tbody = document.getElementById("findingsBody");
|
||||||
const empty = document.getElementById("emptyState");
|
const empty = document.getElementById("emptyState");
|
||||||
|
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
|
tbody.classList.toggle("fresh", !firstRenderDone && !REDUCE_MOTION);
|
||||||
|
|
||||||
|
updateMeta(filtered.length);
|
||||||
|
syncStatActive();
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
|
const title = document.getElementById("emptyTitle");
|
||||||
|
const copy = document.getElementById("emptyCopy");
|
||||||
|
if (allFindings.length === 0) {
|
||||||
|
title.textContent = "No findings yet";
|
||||||
|
copy.textContent = "KeyFinder passively scans every page you visit. Browse around — leaked keys, tokens and credentials will surface here.";
|
||||||
|
} else {
|
||||||
|
title.textContent = "No matches in view";
|
||||||
|
copy.textContent = "The current filters hide every finding. Adjust or clear them to bring results back.";
|
||||||
|
}
|
||||||
empty.hidden = false;
|
empty.hidden = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
empty.hidden = true;
|
empty.hidden = true;
|
||||||
|
|
||||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||||
filtered.sort((a, b) => (severityOrder[a.severity] || 5) - (severityOrder[b.severity] || 5));
|
filtered.sort((a, b) => (severityOrder[a.severity] ?? 5) - (severityOrder[b.severity] ?? 5));
|
||||||
|
|
||||||
filtered.forEach((f, i) => {
|
filtered.forEach((f, i) => {
|
||||||
|
const sev = f.severity || "medium";
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "sev-" + sev;
|
||||||
|
if (i < 30) tr.style.setProperty("--i", i);
|
||||||
|
|
||||||
const tdNum = document.createElement("td");
|
const tdNum = document.createElement("td");
|
||||||
tdNum.textContent = i + 1;
|
tdNum.textContent = String(i + 1).padStart(2, "0");
|
||||||
|
tdNum.className = "td-num";
|
||||||
|
|
||||||
const tdSev = document.createElement("td");
|
const tdSev = document.createElement("td");
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = `badge badge-${f.severity || "medium"}`;
|
badge.className = `badge badge-${sev}`;
|
||||||
badge.textContent = (f.severity || "medium").toUpperCase();
|
badge.textContent = sev.toUpperCase();
|
||||||
tdSev.appendChild(badge);
|
tdSev.appendChild(badge);
|
||||||
|
|
||||||
const tdProvider = document.createElement("td");
|
const tdProvider = document.createElement("td");
|
||||||
@@ -127,7 +230,11 @@ function renderFindings() {
|
|||||||
const matchCode = document.createElement("code");
|
const matchCode = document.createElement("code");
|
||||||
matchCode.textContent = f.match || "-";
|
matchCode.textContent = f.match || "-";
|
||||||
matchCode.className = "match-value";
|
matchCode.className = "match-value";
|
||||||
matchCode.title = f.match || "";
|
matchCode.title = "click to copy\n" + (f.match || "");
|
||||||
|
matchCode.addEventListener("click", () => {
|
||||||
|
navigator.clipboard.writeText(f.match || "");
|
||||||
|
flashCopied(copyBtn);
|
||||||
|
});
|
||||||
tdMatch.appendChild(matchCode);
|
tdMatch.appendChild(matchCode);
|
||||||
|
|
||||||
const tdType = document.createElement("td");
|
const tdType = document.createElement("td");
|
||||||
@@ -141,42 +248,45 @@ function renderFindings() {
|
|||||||
tdDomain.className = "td-domain";
|
tdDomain.className = "td-domain";
|
||||||
|
|
||||||
const tdSource = document.createElement("td");
|
const tdSource = document.createElement("td");
|
||||||
|
tdSource.className = "td-source";
|
||||||
if (f.url && f.url.startsWith("http")) {
|
if (f.url && f.url.startsWith("http")) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = f.url;
|
a.href = f.url;
|
||||||
a.target = "_blank";
|
a.target = "_blank";
|
||||||
a.rel = "noopener";
|
a.rel = "noopener";
|
||||||
a.textContent = truncateUrl(f.url, 40);
|
a.textContent = truncateUrl(f.url, 30);
|
||||||
a.title = f.url;
|
a.title = f.url;
|
||||||
tdSource.appendChild(a);
|
tdSource.appendChild(a);
|
||||||
} else {
|
} else {
|
||||||
tdSource.textContent = f.url ? truncateUrl(f.url, 40) : "-";
|
tdSource.textContent = f.url ? truncateUrl(f.url, 30) : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
const tdTime = document.createElement("td");
|
const tdTime = document.createElement("td");
|
||||||
tdTime.textContent = f.timestamp ? formatTime(f.timestamp) : "-";
|
tdTime.textContent = f.timestamp ? formatTime(f.timestamp) : "-";
|
||||||
tdTime.className = "td-time";
|
tdTime.className = "td-time";
|
||||||
|
if (f.timestamp) tdTime.title = new Date(f.timestamp).toLocaleString();
|
||||||
|
|
||||||
const tdActions = document.createElement("td");
|
const tdActions = document.createElement("td");
|
||||||
|
tdActions.className = "td-actions";
|
||||||
const copyBtn = document.createElement("button");
|
const copyBtn = document.createElement("button");
|
||||||
copyBtn.className = "btn-icon";
|
copyBtn.className = "btn-icon";
|
||||||
copyBtn.textContent = "Copy";
|
copyBtn.textContent = "copy";
|
||||||
copyBtn.title = "Copy match value";
|
copyBtn.title = "Copy match value";
|
||||||
copyBtn.addEventListener("click", () => {
|
copyBtn.addEventListener("click", () => {
|
||||||
navigator.clipboard.writeText(f.match || "");
|
navigator.clipboard.writeText(f.match || "");
|
||||||
copyBtn.textContent = "Done";
|
flashCopied(copyBtn);
|
||||||
setTimeout(() => (copyBtn.textContent = "Copy"), 1500);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const delBtn = document.createElement("button");
|
const delBtn = document.createElement("button");
|
||||||
delBtn.className = "btn-icon btn-icon-danger";
|
delBtn.className = "btn-icon btn-icon-danger";
|
||||||
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();
|
||||||
|
renderTicker();
|
||||||
});
|
});
|
||||||
|
|
||||||
tdActions.appendChild(copyBtn);
|
tdActions.appendChild(copyBtn);
|
||||||
@@ -196,6 +306,58 @@ function renderFindings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flashCopied(btn) {
|
||||||
|
if (!btn) return;
|
||||||
|
btn.textContent = "copied";
|
||||||
|
btn.classList.add("copied");
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = "copy";
|
||||||
|
btn.classList.remove("copied");
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMeta(shown) {
|
||||||
|
const metaLine = document.getElementById("metaLine");
|
||||||
|
if (metaLine) {
|
||||||
|
metaLine.innerHTML = "";
|
||||||
|
metaLine.append(
|
||||||
|
"showing ",
|
||||||
|
strong(shown),
|
||||||
|
" of ",
|
||||||
|
strong(allFindings.length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const shellMeta = document.getElementById("shellMeta");
|
||||||
|
if (shellMeta) shellMeta.textContent = shown + (shown === 1 ? " row" : " rows");
|
||||||
|
}
|
||||||
|
|
||||||
|
function strong(n) {
|
||||||
|
const el = document.createElement("strong");
|
||||||
|
el.textContent = n;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTicker() {
|
||||||
|
const ticker = document.getElementById("ticker");
|
||||||
|
const tickerText = document.getElementById("tickerText");
|
||||||
|
if (!ticker || !tickerText) return;
|
||||||
|
if (!allFindings.length) {
|
||||||
|
ticker.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latest = [...allFindings].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
|
||||||
|
tickerText.innerHTML = "";
|
||||||
|
const head = document.createElement("span");
|
||||||
|
head.textContent = `${latest.provider || "unknown"} · ${latest.patternName || "match"} @ ${latest.domain || "—"} `;
|
||||||
|
tickerText.appendChild(head);
|
||||||
|
if (latest.match) {
|
||||||
|
const code = document.createElement("code");
|
||||||
|
code.textContent = truncateUrl(latest.match, 28);
|
||||||
|
tickerText.appendChild(code);
|
||||||
|
}
|
||||||
|
ticker.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
function truncateUrl(url, max) {
|
function truncateUrl(url, max) {
|
||||||
if (url.length <= max) return url;
|
if (url.length <= max) return url;
|
||||||
return url.substring(0, max - 3) + "...";
|
return url.substring(0, max - 3) + "...";
|
||||||
@@ -203,7 +365,15 @@ function truncateUrl(url, max) {
|
|||||||
|
|
||||||
function formatTime(ts) {
|
function formatTime(ts) {
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
const diff = Date.now() - d.getTime();
|
||||||
|
const m = Math.floor(diff / 60000);
|
||||||
|
if (m < 1) return "just now";
|
||||||
|
if (m < 60) return m + "m ago";
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return h + "h ago";
|
||||||
|
const days = Math.floor(h / 24);
|
||||||
|
if (days < 7) return days + "d ago";
|
||||||
|
return d.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportJson() {
|
function exportJson() {
|
||||||
@@ -212,19 +382,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" });
|
||||||
@@ -246,4 +423,5 @@ async function clearAll() {
|
|||||||
allFindings = [];
|
allFindings = [];
|
||||||
renderStats();
|
renderStats();
|
||||||
renderFindings();
|
renderFindings();
|
||||||
|
renderTicker();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.2.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",
|
"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.2.0",
|
||||||
"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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,31 +9,36 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-brand">
|
<div class="header-brand">
|
||||||
<img src="icons/icon48.png" alt="KeyFinder" class="header-icon">
|
<span class="radar" aria-hidden="true">
|
||||||
<h1>KeyFinder</h1>
|
<img src="icons/icon48.png" alt="" class="header-icon">
|
||||||
<span class="version">v2.0</span>
|
</span>
|
||||||
|
<div class="brand-text">
|
||||||
|
<h1>KEYFINDER<span class="version" id="versionLabel"></span></h1>
|
||||||
|
<p class="header-tagline">passive secret discovery</p>
|
||||||
|
</div>
|
||||||
|
<span class="live-badge" title="Passive scanning is active"><span class="live-dot"></span>ARMED</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="header-tagline">Passive API key & secret discovery</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="stats" id="stats">
|
<section class="stats" id="stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card stat-findings">
|
||||||
<span class="stat-number" id="findingCount">-</span>
|
<span class="stat-top"><span class="stat-led" aria-hidden="true"></span><span class="stat-label">Findings</span></span>
|
||||||
<span class="stat-label">Findings</span>
|
<span class="stat-number" id="findingCount">–</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card stat-keywords">
|
||||||
<span class="stat-number" id="keywordCount">-</span>
|
<span class="stat-top"><span class="stat-led" aria-hidden="true"></span><span class="stat-label">Keywords</span></span>
|
||||||
<span class="stat-label">Keywords</span>
|
<span class="stat-number" id="keywordCount">–</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="section-title">Keywords</h2>
|
<h2 class="section-title">Watchlist<span class="section-sub">// custom keywords</span></h2>
|
||||||
<form id="keywordForm" class="keyword-form">
|
<form id="keywordForm" class="keyword-form">
|
||||||
|
<span class="prompt" aria-hidden="true">›</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="keywordInput"
|
id="keywordInput"
|
||||||
placeholder="Add a keyword (e.g. api_key)"
|
placeholder="add keyword, e.g. api_key"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
>
|
>
|
||||||
@@ -44,8 +49,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
|
<div class="term-line" aria-live="polite"><span class="term-text" id="termText"></span><span class="term-cursor" aria-hidden="true"></span></div>
|
||||||
<a href="results.html" target="_blank" id="resultsLink" class="results-btn">
|
<a href="results.html" target="_blank" id="resultsLink" class="results-btn">
|
||||||
View Findings
|
View Findings <span class="btn-arrow" aria-hidden="true">→</span>
|
||||||
</a>
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KeyFinder</title>
|
||||||
|
<link rel="stylesheet" href="../css/popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-brand">
|
||||||
|
<span class="radar" aria-hidden="true">
|
||||||
|
<img src="../icons/icon48.png" alt="" class="header-icon">
|
||||||
|
</span>
|
||||||
|
<div class="brand-text">
|
||||||
|
<h1>KEYFINDER<span class="version" id="versionLabel"></span></h1>
|
||||||
|
<p class="header-tagline">passive secret discovery</p>
|
||||||
|
</div>
|
||||||
|
<span class="live-badge" title="Passive scanning is active"><span class="live-dot"></span>ARMED</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="stats" id="stats">
|
||||||
|
<div class="stat-card stat-findings">
|
||||||
|
<span class="stat-top"><span class="stat-led" aria-hidden="true"></span><span class="stat-label">Findings</span></span>
|
||||||
|
<span class="stat-number" id="findingCount">–</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-keywords">
|
||||||
|
<span class="stat-top"><span class="stat-led" aria-hidden="true"></span><span class="stat-label">Keywords</span></span>
|
||||||
|
<span class="stat-number" id="keywordCount">–</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">Watchlist<span class="section-sub">// custom keywords</span></h2>
|
||||||
|
<form id="keywordForm" class="keyword-form">
|
||||||
|
<span class="prompt" aria-hidden="true">›</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="keywordInput"
|
||||||
|
placeholder="add keyword, e.g. api_key"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
>
|
||||||
|
<button type="submit" id="addBtn">Add</button>
|
||||||
|
</form>
|
||||||
|
<div id="errorMsg" class="error-msg" hidden></div>
|
||||||
|
<ul id="keywordList" class="keyword-list"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="term-line" aria-live="polite"><span class="term-text" id="termText"></span><span class="term-cursor" aria-hidden="true"></span></div>
|
||||||
|
<a href="../results.html" target="_blank" id="resultsLink" class="results-btn">
|
||||||
|
View Findings <span class="btn-arrow" aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="preview-stub.js"></script>
|
||||||
|
<script src="../js/popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Preview-only stub for the chrome.* extension API with realistic sample data.
|
||||||
|
// Loaded before the real popup.js / results.js in preview/*.html files.
|
||||||
|
// NOT shipped — build.sh only copies the explicit SHARED_FILES list.
|
||||||
|
(function () {
|
||||||
|
const NOW = Date.now();
|
||||||
|
const MIN = 60 * 1000;
|
||||||
|
const HOUR = 60 * MIN;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
|
||||||
|
const SAMPLE_FINDINGS = [
|
||||||
|
{ id: "f1", severity: "critical", provider: "AWS", patternName: "AWS Access Key ID", match: "AKIAIOSFODNN7EXAMPLE", type: "inline-script", domain: "dashboard.acme.io", url: "https://dashboard.acme.io/assets/app.js", pageUrl: "https://dashboard.acme.io/", timestamp: NOW - 4 * MIN },
|
||||||
|
{ id: "f2", severity: "critical", provider: "Stripe", patternName: "Stripe Live Secret Key", match: "sk_live_4eC39HqLyj…T1zdp7dc", type: "network-response", domain: "api.shopfront.dev", url: "https://api.shopfront.dev/v2/config", pageUrl: "https://shopfront.dev/", timestamp: NOW - 26 * MIN },
|
||||||
|
{ id: "f3", severity: "critical", provider: "OpenAI", patternName: "OpenAI API Key", match: "sk-proj-abc123def456ghi789jkl", type: "web-storage", domain: "playground.internal.tools", url: "https://playground.internal.tools/", pageUrl: "https://playground.internal.tools/", timestamp: NOW - 2 * HOUR },
|
||||||
|
{ id: "f4", severity: "high", provider: "GitHub", patternName: "GitHub Personal Access Token", match: "ghp_16C7e42F292c6912E7710c838347Ae178B4a", type: "html-comment", domain: "gitmirror.example.org", url: "https://gitmirror.example.org/login", pageUrl: "https://gitmirror.example.org/", timestamp: NOW - 3 * HOUR },
|
||||||
|
{ id: "f5", severity: "high", provider: "Slack", patternName: "Slack Bot Token", match: "xoxb-17653672481-19874698323-pdLjLl…CwXgE", type: "inline-script", domain: "status.teamchat.app", url: "https://status.teamchat.app/embed.js", pageUrl: "https://status.teamchat.app/", timestamp: NOW - 5 * HOUR },
|
||||||
|
{ id: "f6", severity: "high", provider: "MongoDB", patternName: "MongoDB Connection String", match: "mongodb+srv://admin:p4ssw0rd@cluster0.mongodb.net/prod", type: "data-attribute", domain: "metrics.saasly.co", url: "https://metrics.saasly.co/", pageUrl: "https://metrics.saasly.co/", timestamp: NOW - 9 * HOUR },
|
||||||
|
{ id: "f7", severity: "high", provider: "DOM Scan", patternName: "Sensitive Meta Tag", match: "csrf-token=9f8e7d6c5b4a3210", type: "meta-tag", domain: "legacy.corpnet.com", url: "https://legacy.corpnet.com/index.html", pageUrl: "https://legacy.corpnet.com/", timestamp: NOW - 14 * HOUR },
|
||||||
|
{ id: "f8", severity: "medium", provider: "Generic", patternName: "JSON Web Token (JWT)", match: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", type: "url-param", domain: "auth.redirectlab.net", url: "https://auth.redirectlab.net/callback?token=eyJhbGciOi...", pageUrl: "https://redirectlab.net/", timestamp: NOW - 20 * HOUR },
|
||||||
|
{ id: "f9", severity: "medium", provider: "DOM Scan", patternName: "Hidden Form Field", match: "session_id=a8f5f167f44f4964e6c998dee827110c", type: "hidden-input", domain: "shop.example.com", url: "https://shop.example.com/checkout", pageUrl: "https://shop.example.com/", timestamp: NOW - 1 * DAY },
|
||||||
|
{ id: "f10", severity: "medium", provider: "URL Scan", patternName: "High-Entropy URL Parameter", match: "sig=4c4f6e6f2d9a4b8c8d7e6f5a4b3c2d1e", type: "url-param", domain: "cdn.medialab.io", url: "https://cdn.medialab.io/v/assets?sig=4c4f6e...", pageUrl: "https://medialab.io/", timestamp: NOW - 2 * DAY },
|
||||||
|
{ id: "f11", severity: "medium", provider: "Web Storage", patternName: "localStorage Secret", match: "refresh_token=def502001a2b3c4d5e6f", type: "web-storage", domain: "app.notesync.dev", url: "https://app.notesync.dev/", pageUrl: "https://app.notesync.dev/", timestamp: NOW - 3 * DAY },
|
||||||
|
{ id: "f12", severity: "low", provider: "Generic", patternName: "Keyword: client_id", match: "client_id=Iv1.8a61f9b3a7aba766", type: "script-src", domain: "login.oauthflow.app", url: "https://login.oauthflow.app/js/oauth.js?client_id=Iv1.8a61f9b3a7aba766", pageUrl: "https://login.oauthflow.app/", timestamp: NOW - 4 * DAY },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SAMPLE_KEYWORDS = [
|
||||||
|
"key", "api_key", "apikey", "api-key", "secret", "token",
|
||||||
|
"access_token", "auth", "credential", "password", "client_id", "client_secret",
|
||||||
|
];
|
||||||
|
|
||||||
|
window.chrome = {
|
||||||
|
runtime: {
|
||||||
|
getManifest: () => ({ version: "2.1.1" }),
|
||||||
|
sendMessage: async (msg) => {
|
||||||
|
switch (msg && msg.type) {
|
||||||
|
case "getFindings": return { findings: SAMPLE_FINDINGS.slice() };
|
||||||
|
case "getKeywords": return { keywords: SAMPLE_KEYWORDS.slice() };
|
||||||
|
case "addKeyword": return { ok: true };
|
||||||
|
case "removeKeyword": return { ok: true };
|
||||||
|
case "removeFinding": return { ok: true };
|
||||||
|
case "clearFindings": return { ok: true };
|
||||||
|
default: return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KeyFinder — Findings Console</title>
|
||||||
|
<link rel="stylesheet" href="../css/results.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-row">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="radar" aria-hidden="true">
|
||||||
|
<img src="../icons/icon48.png" alt="" class="header-icon">
|
||||||
|
</span>
|
||||||
|
<div class="brand-text">
|
||||||
|
<h1>KEYFINDER<span class="version" id="versionLabel"></span></h1>
|
||||||
|
<p class="header-sub">findings console</p>
|
||||||
|
</div>
|
||||||
|
<span class="live-badge" title="Passive scanning is active"><span class="live-dot"></span>PASSIVE</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<span class="search-glyph" aria-hidden="true">⚲</span>
|
||||||
|
<input type="text" id="searchBox" placeholder="search findings…" autocomplete="off" spellcheck="false">
|
||||||
|
<kbd class="kbd" title="Press / to focus search">/</kbd>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button id="exportJsonBtn" class="btn btn-secondary" title="Download filtered findings as JSON">↓ JSON</button>
|
||||||
|
<button id="exportCsvBtn" class="btn btn-secondary" title="Download filtered findings as CSV">↓ CSV</button>
|
||||||
|
<button id="clearBtn" class="btn btn-danger" title="Remove all findings">× Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-row header-row-filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label class="filter">
|
||||||
|
<span class="filter-label">sev</span>
|
||||||
|
<select id="severityFilter">
|
||||||
|
<option value="all">all</option>
|
||||||
|
<option value="critical">critical</option>
|
||||||
|
<option value="high">high</option>
|
||||||
|
<option value="medium">medium</option>
|
||||||
|
<option value="low">low</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="filter">
|
||||||
|
<span class="filter-label">type</span>
|
||||||
|
<select id="typeFilter">
|
||||||
|
<option value="all">all</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="filter">
|
||||||
|
<span class="filter-label">provider</span>
|
||||||
|
<select id="providerFilter">
|
||||||
|
<option value="all">all</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="meta-line" id="metaLine"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats-bar" id="statsBar"></div>
|
||||||
|
|
||||||
|
<div class="ticker" id="ticker" hidden>
|
||||||
|
<span class="ticker-dot" aria-hidden="true"></span>
|
||||||
|
<span class="ticker-label">LATEST</span>
|
||||||
|
<span class="ticker-text" id="tickerText"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="table-shell">
|
||||||
|
<div class="shell-head">
|
||||||
|
<span class="shell-title">findings.log</span>
|
||||||
|
<span class="shell-meta" id="shellMeta"></span>
|
||||||
|
</div>
|
||||||
|
<table class="findings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Provider</th>
|
||||||
|
<th>Pattern</th>
|
||||||
|
<th>Match</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="findingsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="emptyState" class="empty-state" hidden>
|
||||||
|
<svg class="empty-radar" viewBox="0 0 72 72" aria-hidden="true">
|
||||||
|
<circle cx="36" cy="36" r="30" class="er-ring"/>
|
||||||
|
<circle cx="36" cy="36" r="20" class="er-ring"/>
|
||||||
|
<circle cx="36" cy="36" r="10" class="er-ring"/>
|
||||||
|
<line x1="36" y1="6" x2="36" y2="66" class="er-cross"/>
|
||||||
|
<line x1="6" y1="36" x2="66" y2="36" class="er-cross"/>
|
||||||
|
<path d="M36 36 L36 6 A30 30 0 0 1 57.2 14.8 Z" class="er-sweep"/>
|
||||||
|
<circle cx="36" cy="36" r="2.5" class="er-core"/>
|
||||||
|
</svg>
|
||||||
|
<p class="empty-title" id="emptyTitle">No findings yet</p>
|
||||||
|
<p class="empty-copy" id="emptyCopy">KeyFinder passively scans every page you visit. Browse around — leaked keys, tokens and credentials will surface here.</p>
|
||||||
|
<p class="empty-hint">tip · add custom keywords from the extension popup</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="page-footer">
|
||||||
|
<span>local storage only</span>
|
||||||
|
<span class="footer-sep">·</span>
|
||||||
|
<span>5000-finding ring buffer</span>
|
||||||
|
<span class="footer-sep">·</span>
|
||||||
|
<span>no network egress</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="preview-stub.js"></script>
|
||||||
|
<script src="../js/results.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 258 KiB |
@@ -3,64 +3,117 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>KeyFinder - Findings</title>
|
<title>KeyFinder — Findings Console</title>
|
||||||
<link rel="stylesheet" href="css/results.css">
|
<link rel="stylesheet" href="css/results.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-left">
|
<div class="header-row">
|
||||||
<img src="icons/icon48.png" alt="KeyFinder" class="header-icon">
|
<div class="header-left">
|
||||||
<h1>KeyFinder <span class="version">v2.0</span></h1>
|
<span class="radar" aria-hidden="true">
|
||||||
|
<img src="icons/icon48.png" alt="" class="header-icon">
|
||||||
|
</span>
|
||||||
|
<div class="brand-text">
|
||||||
|
<h1>KEYFINDER<span class="version" id="versionLabel"></span></h1>
|
||||||
|
<p class="header-sub">findings console</p>
|
||||||
|
</div>
|
||||||
|
<span class="live-badge" title="Passive scanning is active"><span class="live-dot"></span>PASSIVE</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<span class="search-glyph" aria-hidden="true">⚲</span>
|
||||||
|
<input type="text" id="searchBox" placeholder="search findings…" autocomplete="off" spellcheck="false">
|
||||||
|
<kbd class="kbd" title="Press / to focus search">/</kbd>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button id="exportJsonBtn" class="btn btn-secondary" title="Download filtered findings as JSON">↓ JSON</button>
|
||||||
|
<button id="exportCsvBtn" class="btn btn-secondary" title="Download filtered findings as CSV">↓ CSV</button>
|
||||||
|
<button id="clearBtn" class="btn btn-danger" title="Remove all findings">× Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-row header-row-filters">
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<select id="severityFilter">
|
<label class="filter">
|
||||||
<option value="all">All Severities</option>
|
<span class="filter-label">sev</span>
|
||||||
<option value="critical">Critical</option>
|
<select id="severityFilter">
|
||||||
<option value="high">High</option>
|
<option value="all">all</option>
|
||||||
<option value="medium">Medium</option>
|
<option value="critical">critical</option>
|
||||||
<option value="low">Low</option>
|
<option value="high">high</option>
|
||||||
</select>
|
<option value="medium">medium</option>
|
||||||
<select id="typeFilter">
|
<option value="low">low</option>
|
||||||
<option value="all">All Types</option>
|
</select>
|
||||||
</select>
|
</label>
|
||||||
<select id="providerFilter">
|
<label class="filter">
|
||||||
<option value="all">All Providers</option>
|
<span class="filter-label">type</span>
|
||||||
</select>
|
<select id="typeFilter">
|
||||||
<input type="text" id="searchBox" placeholder="Search findings...">
|
<option value="all">all</option>
|
||||||
</div>
|
</select>
|
||||||
<div class="btn-group">
|
</label>
|
||||||
<button id="exportJsonBtn" class="btn btn-secondary">Export JSON</button>
|
<label class="filter">
|
||||||
<button id="exportCsvBtn" class="btn btn-secondary">Export CSV</button>
|
<span class="filter-label">provider</span>
|
||||||
<button id="clearBtn" class="btn btn-danger">Clear All</button>
|
<select id="providerFilter">
|
||||||
|
<option value="all">all</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="meta-line" id="metaLine"></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="stats-bar" id="statsBar"></div>
|
<div class="stats-bar" id="statsBar"></div>
|
||||||
|
|
||||||
|
<div class="ticker" id="ticker" hidden>
|
||||||
|
<span class="ticker-dot" aria-hidden="true"></span>
|
||||||
|
<span class="ticker-label">LATEST</span>
|
||||||
|
<span class="ticker-text" id="tickerText"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<table class="findings-table">
|
<div class="table-shell">
|
||||||
<thead>
|
<div class="shell-head">
|
||||||
<tr>
|
<span class="shell-title">findings.log</span>
|
||||||
<th>#</th>
|
<span class="shell-meta" id="shellMeta"></span>
|
||||||
<th>Severity</th>
|
</div>
|
||||||
<th>Provider</th>
|
<table class="findings-table">
|
||||||
<th>Pattern</th>
|
<thead>
|
||||||
<th>Match</th>
|
<tr>
|
||||||
<th>Type</th>
|
<th>#</th>
|
||||||
<th>Domain</th>
|
<th>Severity</th>
|
||||||
<th>Source</th>
|
<th>Provider</th>
|
||||||
<th>Time</th>
|
<th>Pattern</th>
|
||||||
<th></th>
|
<th>Match</th>
|
||||||
</tr>
|
<th>Type</th>
|
||||||
</thead>
|
<th>Domain</th>
|
||||||
<tbody id="findingsBody"></tbody>
|
<th>Source</th>
|
||||||
</table>
|
<th>Time</th>
|
||||||
<div id="emptyState" class="empty-state" hidden>
|
<th></th>
|
||||||
<div class="empty-icon">🔍</div>
|
</tr>
|
||||||
<p>No findings yet. Browse some pages and KeyFinder will passively scan for leaked secrets.</p>
|
</thead>
|
||||||
|
<tbody id="findingsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="emptyState" class="empty-state" hidden>
|
||||||
|
<svg class="empty-radar" viewBox="0 0 72 72" aria-hidden="true">
|
||||||
|
<circle cx="36" cy="36" r="30" class="er-ring"/>
|
||||||
|
<circle cx="36" cy="36" r="20" class="er-ring"/>
|
||||||
|
<circle cx="36" cy="36" r="10" class="er-ring"/>
|
||||||
|
<line x1="36" y1="6" x2="36" y2="66" class="er-cross"/>
|
||||||
|
<line x1="6" y1="36" x2="66" y2="36" class="er-cross"/>
|
||||||
|
<path d="M36 36 L36 6 A30 30 0 0 1 57.2 14.8 Z" class="er-sweep"/>
|
||||||
|
<circle cx="36" cy="36" r="2.5" class="er-core"/>
|
||||||
|
</svg>
|
||||||
|
<p class="empty-title" id="emptyTitle">No findings yet</p>
|
||||||
|
<p class="empty-copy" id="emptyCopy">KeyFinder passively scans every page you visit. Browse around — leaked keys, tokens and credentials will surface here.</p>
|
||||||
|
<p class="empty-hint">tip · add custom keywords from the extension popup</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<footer class="page-footer">
|
||||||
|
<span>local storage only</span>
|
||||||
|
<span class="footer-sep">·</span>
|
||||||
|
<span>5000-finding ring buffer</span>
|
||||||
|
<span class="footer-sep">·</span>
|
||||||
|
<span>no network egress</span>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="js/results.js"></script>
|
<script src="js/results.js"></script>
|
||||||
|
|||||||
@@ -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"
|
||||||