diff --git a/CLAUDE.md b/CLAUDE.md index 1ed90d9..6c7a160 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,12 @@ These are absolute rules — never violate them: - **NMH no plaintext queue**: When both WS and NMH fail for `submit_captured_key`, the key is NOT queued to `chrome.storage.local`. Storing plaintext API keys in browser storage violates security red line #1. The key is lost and must be re-captured. - **NMH isConnected semantics**: NMH relay success does NOT set `state.isConnected = true` — NMH is a one-shot relay, not a persistent connection. Popup shows "Connected (NMH)" via `connectionPath` field only, while WS reconnect continues independently. - **NMHInstaller**: Core auto-installs NMH binary + Chrome manifest from app bundle Resources on startup. Uses binary file size comparison for version checking. `install.sh` retained as manual fallback. +- **Smart Key Extraction confirmation dialog**: Three-tier confidence strategy: >= 0.7 auto-store, 0.35~0.7 mask + confirmation dialog, < 0.35 ignore. Dialog is inline content script overlay with editable service name, 30s auto-dismiss, Escape support, queue for multiple pending. rejectedKeys Set persists for page lifetime. +- **Universal Masking/Detection toggles**: Two popup toggles (default OFF) extend masking and detection to non-supported platforms. Supported platforms unaffected — Demo Mode auto-enables both. Stored in chrome.storage.local. +- **Generic key pattern**: `generic-key` pattern (confidence 0.50) matches common prefixes (key-, token-, api-, secret-, sk-, pk-, rk-) + 30+ char alphanumeric. prefix is empty string — KEY_PREFIXES filters empty prefixes to prevent containsFullKey() false positives. +- **Turbo navigation pre-hide**: `turbo:before-render` listener in pre-hide.ts hides key elements in incoming body BEFORE Turbo renders. Prevents GitHub PAT flash on partial page transition. +- **OpenAI pre-hide scope**: preHideCSS must NOT include `td.api-key-token .api-key-token-value` — these are truncated previews (sk-...QngA) that never match full patterns, causing permanent hidden state. +- **Toast duration**: 25 seconds (changed from 10s for better visibility during demos). ## Documentation diff --git a/docs/01-product-spec/implementation-status.md b/docs/01-product-spec/implementation-status.md index a5dbc8b..14fa857 100644 --- a/docs/01-product-spec/implementation-status.md +++ b/docs/01-product-spec/implementation-status.md @@ -85,6 +85,10 @@ | React SPA masking | ✅ | Dialog input 保持隱藏,不替換 value | | AWS dual-key capture | ✅ | Access Key ID (DOM) + Secret Key (clipboard) | | Toast stacking | ✅ | 連續 capture 堆疊顯示 | +| Smart Key Extraction 確認對話框 | ✅ | 三區間信心度策略 + 確認 UI + reject 恢復 | +| Universal Masking / Detection | ✅ | Popup 雙開關,擴展非已知平台 | +| Generic key pattern | ✅ | 通用 prefix 偵測(confidence 0.50) | +| Turbo 導航防閃現 | ✅ | turbo:before-render pre-hide | | Capture Mode (popup) | ✅ | Start/Stop capture + countdown timer | | E2E 測試 (8 平台) | ✅ | GitHub, HuggingFace, GitLab, OpenAI, Anthropic, AI Studio, Google Cloud, AWS | | Stripe / Slack / SendGrid | 🔶 | Pattern 已定義,未測試 | diff --git a/docs/04-ui-ux/confirmation-dialog-spec.md b/docs/04-ui-ux/confirmation-dialog-spec.md new file mode 100644 index 0000000..6d2acc9 --- /dev/null +++ b/docs/04-ui-ux/confirmation-dialog-spec.md @@ -0,0 +1,170 @@ +# Smart Key Extraction 確認對話框規格 + +> 最後更新:2026-03-18 + +## 概述 + +Smart Key Extraction 確認對話框為 Chrome Extension 的 content script 內嵌 UI,用於處理中信心度 API key 的確認流程。當偵測到的 key 信心度介於 0.35~0.7 之間時,系統先遮蔽該 key,再顯示確認對話框讓使用者決定是否儲存。 + +--- + +## 三區間信心度策略 + +| 信心度範圍 | 動作 | 說明 | +|-----------|------|------| +| >= 0.7(高) | 自動儲存 | 直接送 `submit_captured_key` 至 Core,顯示 toast | +| 0.35 ~ 0.7(中) | 遮蔽 + 確認對話框 | 先遮蔽明文,彈出 inline 對話框讓使用者確認 | +| < 0.35(低) | 忽略 | 不處理,不遮蔽 | + +--- + +## 確認對話框 UI 設計 + +``` ++--------------------------------------------------+ +| DemoSafe: Detected possible API key | +| | +| Service name: [__openai______________] (editable)| +| Key preview: sk-proj-...xxxx | +| | +| [ Confirm & Store ] [ Reject ] (27s) | ++--------------------------------------------------+ +``` + +### UI 元素 + +| 元素 | 說明 | +|------|------| +| 標題 | "DemoSafe: Detected possible API key" | +| Service name | 可編輯文字輸入框,預設為偵測到的 platform 名稱 | +| Key preview | 遮蔽後的 key 預覽(前綴 + ...末四碼) | +| Confirm & Store | 確認按鈕,提交至 Core 儲存 | +| Reject | 拒絕按鈕,恢復原文並加入 rejectedKeys | +| 倒數計時 | 右下角顯示剩餘秒數,30s 自動 dismiss | + +### 行為表 + +| 操作 | 行為 | +|------|------| +| Confirm | 送 `submit_captured_key` 至 background → Core,關閉對話框,key 保持遮蔽 | +| Reject | 恢復該 key 的原始文字,加入 `rejectedKeys` Set,關閉對話框 | +| Escape | 等同 Reject | +| 30s 超時 | 自動 dismiss,key 保持遮蔽狀態(不提交也不恢復) | + +--- + +## 佇列機制 + +當頁面同時偵測到多個中信心度 key 時,對話框以佇列方式依序顯示: + +1. 第一個 key 的對話框立即顯示 +2. 後續 key 加入 `pendingConfirmations` 佇列 +3. 前一個對話框關閉後,自動彈出下一個 +4. 每個對話框各自有獨立的 30s 倒數 + +--- + +## 去重機制 + +三組 Set 防止重複處理: + +| Set | 作用 | 生命週期 | +|-----|------|---------| +| `submittedKeys` | 已提交至 Core 的 key(高信心度自動 + 確認) | 頁面生命週期 | +| `rejectedKeys` | 使用者拒絕的 key | 頁面生命週期 | +| `isAlreadyStoredKey()` | 查詢 Core 已知 key(透過 pattern 匹配) | 即時查詢 | + +流程:偵測到 key → 檢查是否在 submittedKeys / rejectedKeys / isAlreadyStoredKey → 若已存在則跳過 → 否則依信心度分流。 + +--- + +## Universal Masking / Detection 開關 + +### Popup UI + +兩個獨立 toggle 開關位於 popup 面板: + +- **Universal Masking**:在非已知平台頁面也進行 DOM 遮蔽(預設 OFF) +- **Universal Detection**:在非已知平台頁面也進行 key 偵測(預設 OFF) + +### 預設行為 + +- 已知平台(capture-patterns.ts 定義的 11+ 平台):Demo Mode 開啟時自動啟用 masking 和 detection,不受 toggle 影響 +- 非已知平台:僅在 toggle 開啟時啟用對應功能 + +### 決策函式 + +| 函式 | 邏輯 | +|------|------| +| `shouldMask(url)` | `isSupportedPlatform(url) \|\| universalMaskingEnabled` | +| `shouldAutoCapture(url)` | `isSupportedPlatform(url) \|\| universalDetectionEnabled` | + +### 儲存 + +使用 `chrome.storage.local` 儲存,key 為 `universalMasking` 和 `universalDetection`。 + +--- + +## Generic Key Pattern + +| 屬性 | 值 | +|------|---| +| pattern ID | `generic-key` | +| confidence | 0.50 | +| prefix | `""` (空字串) | +| 匹配規則 | 常見 prefix(key-, token-, api-, secret-, sk-, pk-, rk-)+ 30+ 字元英數 | +| 特殊處理 | `KEY_PREFIXES` 過濾空字串 prefix,避免 `containsFullKey()` 誤判 | + +此 pattern 用於捕獲非已知平台的 API key,搭配 Universal Detection 使用。因 confidence 為 0.50(中區間),會觸發確認對話框。 + +--- + +## IPC 流程圖(中信心度 key) + +``` +Content Script Background SW Core Engine + | | | + | 偵測到 key (0.35~0.7) | | + | 遮蔽明文 | | + | 顯示確認對話框 | | + | | | + | [使用者按 Confirm] | | + | | | + |-- submit_captured_key ------->| | + | {key, serviceName} | | + | |-- submit_captured_key -->| + | | {key, serviceName} | + | | | + | |<-- key_stored ----------| + | | {keyId, masked} | + |<-- key_stored ---------------| | + | | | + | 顯示 toast | | +``` + +--- + +## 新增 Message Types + +| Type | 方向 | Payload | 說明 | +|------|------|---------|------| +| `submit_captured_key` | Content → BG → Core | `{ key: string, serviceName: string, platform: string }` | 提交捕獲的 key | +| `key_stored` | Core → BG → Content | `{ keyId: string, masked: string }` | key 已儲存確認 | +| `get_universal_settings` | Popup → BG | `{}` | 取得 universal toggle 狀態 | +| `set_universal_settings` | Popup → BG | `{ masking: boolean, detection: boolean }` | 設定 universal toggle | +| `universal_settings_changed` | BG → Content | `{ masking: boolean, detection: boolean }` | 廣播 toggle 狀態變更 | + +--- + +## 檔案變更表 + +| 檔案 | 變更 | +|------|------| +| `packages/chrome-extension/src/content/masker.ts` | 三區間信心度判斷、確認對話框 UI、佇列機制、rejectedKeys | +| `packages/chrome-extension/src/content/confirmation-dialog.ts` | 對話框元件:建立、事件、倒數、Escape | +| `packages/chrome-extension/src/content/capture-patterns.ts` | 新增 `generic-key` pattern | +| `packages/chrome-extension/src/background/service-worker.ts` | universal settings 儲存/廣播 | +| `packages/chrome-extension/src/popup/popup.ts` | Universal Masking / Detection toggle UI | +| `packages/chrome-extension/src/popup/popup.html` | toggle HTML 結構 | +| `packages/chrome-extension/src/content/pre-hide.ts` | `turbo:before-render` 監聽 | +| `packages/chrome-extension/src/content/toast.ts` | 持續時間改為 25s | diff --git a/docs/en/01-product-spec/implementation-status.md b/docs/en/01-product-spec/implementation-status.md index a38f7bf..a1de60e 100644 --- a/docs/en/01-product-spec/implementation-status.md +++ b/docs/en/01-product-spec/implementation-status.md @@ -85,6 +85,10 @@ | React SPA masking | ✅ | Dialog inputs stay hidden, no value replacement | | AWS dual-key capture | ✅ | Access Key ID (DOM) + Secret Key (clipboard) | | Toast stacking | ✅ | Consecutive captures show stacked toasts | +| Smart Key Extraction confirmation dialog | ✅ | Three-tier confidence strategy + confirmation UI + reject restore | +| Universal Masking / Detection | ✅ | Popup dual toggles, extend to non-supported platforms | +| Generic key pattern | ✅ | Generic prefix detection (confidence 0.50) | +| Turbo navigation anti-flash | ✅ | turbo:before-render pre-hide | | Capture Mode (popup) | ✅ | Start/Stop capture + countdown timer | | E2E tested (8 platforms) | ✅ | GitHub, HuggingFace, GitLab, OpenAI, Anthropic, AI Studio, Google Cloud, AWS | | Stripe / Slack / SendGrid | 🔶 | Patterns defined, untested | diff --git a/docs/en/04-ui-ux/confirmation-dialog-spec.md b/docs/en/04-ui-ux/confirmation-dialog-spec.md new file mode 100644 index 0000000..91b73ad --- /dev/null +++ b/docs/en/04-ui-ux/confirmation-dialog-spec.md @@ -0,0 +1,170 @@ +# Smart Key Extraction Confirmation Dialog Spec + +> Last updated: 2026-03-18 + +## Overview + +The Smart Key Extraction confirmation dialog is an inline content script UI in the Chrome Extension. When a detected key's confidence falls between 0.35 and 0.7 (medium tier), the system masks the key first, then displays a confirmation dialog for the user to decide whether to store it. + +--- + +## Three-Tier Confidence Strategy + +| Confidence Range | Action | Description | +|-----------------|--------|-------------| +| >= 0.7 (high) | Auto-store | Send `submit_captured_key` to Core directly, show toast | +| 0.35 ~ 0.7 (medium) | Mask + confirmation dialog | Mask plaintext first, show inline dialog for user confirmation | +| < 0.35 (low) | Ignore | No processing, no masking | + +--- + +## Confirmation Dialog UI Design + +``` ++--------------------------------------------------+ +| DemoSafe: Detected possible API key | +| | +| Service name: [__openai______________] (editable)| +| Key preview: sk-proj-...xxxx | +| | +| [ Confirm & Store ] [ Reject ] (27s) | ++--------------------------------------------------+ +``` + +### UI Elements + +| Element | Description | +|---------|-------------| +| Title | "DemoSafe: Detected possible API key" | +| Service name | Editable text input, defaults to detected platform name | +| Key preview | Masked key preview (prefix + ...last 4 chars) | +| Confirm & Store | Confirm button, submits to Core for storage | +| Reject | Reject button, restores original text and adds to rejectedKeys | +| Countdown | Bottom-right displays remaining seconds, 30s auto-dismiss | + +### Behavior Table + +| Action | Behavior | +|--------|----------| +| Confirm | Send `submit_captured_key` to background -> Core, close dialog, key stays masked | +| Reject | Restore key's original text, add to `rejectedKeys` Set, close dialog | +| Escape | Same as Reject | +| 30s timeout | Auto-dismiss, key stays masked (neither submitted nor restored) | + +--- + +## Queue Mechanism + +When multiple medium-confidence keys are detected simultaneously on a page, dialogs are shown sequentially via a queue: + +1. First key's dialog appears immediately +2. Subsequent keys are added to the `pendingConfirmations` queue +3. After previous dialog closes, the next one automatically appears +4. Each dialog has its own independent 30s countdown + +--- + +## Deduplication Mechanism + +Three Sets prevent duplicate processing: + +| Set | Purpose | Lifetime | +|-----|---------|----------| +| `submittedKeys` | Keys already submitted to Core (high-confidence auto + confirmed) | Page lifetime | +| `rejectedKeys` | Keys rejected by the user | Page lifetime | +| `isAlreadyStoredKey()` | Query Core for known keys (via pattern matching) | Real-time query | + +Flow: key detected -> check submittedKeys / rejectedKeys / isAlreadyStoredKey -> skip if exists -> otherwise route by confidence tier. + +--- + +## Universal Masking / Detection Toggles + +### Popup UI + +Two independent toggle switches in the popup panel: + +- **Universal Masking**: Enable DOM masking on non-supported platform pages (default OFF) +- **Universal Detection**: Enable key detection on non-supported platform pages (default OFF) + +### Default Behavior + +- Supported platforms (11+ platforms defined in capture-patterns.ts): Demo Mode automatically enables masking and detection, unaffected by toggles +- Non-supported platforms: Only enabled when corresponding toggle is ON + +### Decision Functions + +| Function | Logic | +|----------|-------| +| `shouldMask(url)` | `isSupportedPlatform(url) \|\| universalMaskingEnabled` | +| `shouldAutoCapture(url)` | `isSupportedPlatform(url) \|\| universalDetectionEnabled` | + +### Storage + +Stored in `chrome.storage.local` with keys `universalMasking` and `universalDetection`. + +--- + +## Generic Key Pattern + +| Property | Value | +|----------|-------| +| Pattern ID | `generic-key` | +| Confidence | 0.50 | +| Prefix | `""` (empty string) | +| Match rule | Common prefixes (key-, token-, api-, secret-, sk-, pk-, rk-) + 30+ alphanumeric chars | +| Special handling | `KEY_PREFIXES` filters empty string prefix to prevent `containsFullKey()` false positives | + +This pattern captures API keys on non-supported platforms, used in conjunction with Universal Detection. Since confidence is 0.50 (medium tier), it triggers the confirmation dialog. + +--- + +## IPC Flow Diagram (Medium-Confidence Key) + +``` +Content Script Background SW Core Engine + | | | + | Key detected (0.35~0.7) | | + | Mask plaintext | | + | Show confirmation dialog | | + | | | + | [User clicks Confirm] | | + | | | + |-- submit_captured_key ------->| | + | {key, serviceName} | | + | |-- submit_captured_key -->| + | | {key, serviceName} | + | | | + | |<-- key_stored ----------| + | | {keyId, masked} | + |<-- key_stored ---------------| | + | | | + | Show toast | | +``` + +--- + +## New Message Types + +| Type | Direction | Payload | Description | +|------|-----------|---------|-------------| +| `submit_captured_key` | Content -> BG -> Core | `{ key: string, serviceName: string, platform: string }` | Submit captured key | +| `key_stored` | Core -> BG -> Content | `{ keyId: string, masked: string }` | Key stored confirmation | +| `get_universal_settings` | Popup -> BG | `{}` | Get universal toggle states | +| `set_universal_settings` | Popup -> BG | `{ masking: boolean, detection: boolean }` | Set universal toggles | +| `universal_settings_changed` | BG -> Content | `{ masking: boolean, detection: boolean }` | Broadcast toggle state changes | + +--- + +## File Changes Table + +| File | Changes | +|------|---------| +| `packages/chrome-extension/src/content/masker.ts` | Three-tier confidence routing, confirmation dialog UI, queue mechanism, rejectedKeys | +| `packages/chrome-extension/src/content/confirmation-dialog.ts` | Dialog component: creation, events, countdown, Escape | +| `packages/chrome-extension/src/content/capture-patterns.ts` | Add `generic-key` pattern | +| `packages/chrome-extension/src/background/service-worker.ts` | Universal settings storage/broadcast | +| `packages/chrome-extension/src/popup/popup.ts` | Universal Masking / Detection toggle UI | +| `packages/chrome-extension/src/popup/popup.html` | Toggle HTML structure | +| `packages/chrome-extension/src/content/pre-hide.ts` | `turbo:before-render` listener | +| `packages/chrome-extension/src/content/toast.ts` | Duration changed to 25s | diff --git a/packages/chrome-extension/src/background/service-worker.ts b/packages/chrome-extension/src/background/service-worker.ts index d4d9466..253ac9a 100644 --- a/packages/chrome-extension/src/background/service-worker.ts +++ b/packages/chrome-extension/src/background/service-worker.ts @@ -43,6 +43,8 @@ interface DemoSafeState { captureTimeoutEnd: number | null; // timestamp ms capturedCount: number; connectionPath: ConnectionPath; + isUniversalMasking: boolean; + isUniversalDetection: boolean; } // NMH relay actions — these can be forwarded via Native Messaging Host when WS is down @@ -64,8 +66,15 @@ const state: DemoSafeState = { captureTimeoutEnd: null, capturedCount: 0, connectionPath: 'offline', + isUniversalMasking: false, + isUniversalDetection: false, }; +chrome.storage.local.get(['universalMasking', 'universalDetection'], (result) => { + state.isUniversalMasking = result.universalMasking ?? false; + state.isUniversalDetection = result.universalDetection ?? false; +}); + // MARK: - Native Messaging Host async function getIPCConfig(): Promise { @@ -445,7 +454,7 @@ function broadcastStateToPopup() { } // Listen for messages from popup and content scripts -chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'get_state') { // If WS is disconnected, try NMH to get fresh state if (!state.isConnected) { @@ -517,6 +526,41 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { return true; } + if (message.type === 'toggle_universal_masking') { + state.isUniversalMasking = !state.isUniversalMasking; + chrome.storage.local.set({ universalMasking: state.isUniversalMasking }); + broadcastStateToPopup(); + sendResponse({ ok: true }); + return true; + } + + if (message.type === 'toggle_universal_detection') { + state.isUniversalDetection = !state.isUniversalDetection; + chrome.storage.local.set({ universalDetection: state.isUniversalDetection }); + broadcastStateToPopup(); + sendResponse({ ok: true }); + return true; + } + + if (message.type === 'confirm_captured_key') { + const payload = { ...message.payload, confidence: 1.0 }; + sendRequest('submit_captured_key', payload).then((result) => { + if (result && result.status === 'success') { + const tabId = sender.tab?.id; + if (tabId) { + chrome.tabs.sendMessage(tabId, { + action: 'key_confirmed', + payload: { serviceName: message.payload.suggestedService }, + }).catch(() => {}); + } + } + }); + state.capturedCount++; + broadcastStateToPopup(); + sendResponse({ ok: true }); + return true; + } + return false; }); diff --git a/packages/chrome-extension/src/content-scripts/capture-patterns.ts b/packages/chrome-extension/src/content-scripts/capture-patterns.ts index 3b52c4a..be4c50a 100644 --- a/packages/chrome-extension/src/content-scripts/capture-patterns.ts +++ b/packages/chrome-extension/src/content-scripts/capture-patterns.ts @@ -55,7 +55,7 @@ export const CAPTURE_PATTERNS: CapturePattern[] = [ regex: /sk-proj-[A-Za-z0-9_-]{20,}/g, confidence: 0.95, minLength: 28, - preHideCSS: `[data-state="open"] code, td.api-key-token .api-key-token-value { visibility: hidden !important; }`, + preHideCSS: `[data-state="open"] code { visibility: hidden !important; }`, platformSelectors: [{ hostname: 'platform.openai.com', selectors: [ @@ -366,6 +366,16 @@ export const CAPTURE_PATTERNS: CapturePattern[] = [ strategy: 'flash_notice', }], }, + // Generic key-like pattern: common prefixes + long alphanumeric + // Low confidence — triggers confirmation dialog instead of auto-store + { + id: 'generic-key', + serviceName: 'Unknown', + prefix: '', + regex: /(?:key|token|api|secret|sk|pk|rk)[-_][A-Za-z0-9_-]{30,}/g, + confidence: 0.50, + minLength: 34, + }, ]; // MARK: - Domain Mapping @@ -393,8 +403,8 @@ const DOMAIN_CONFIDENCE_PENALTY = -0.1; // MARK: - Derived Exports (auto-generated from CAPTURE_PATTERNS) -/** All unique key prefixes — derived from CAPTURE_PATTERNS */ -export const KEY_PREFIXES: string[] = [...new Set(CAPTURE_PATTERNS.map(p => p.prefix))]; +/** All unique key prefixes — derived from CAPTURE_PATTERNS (empty prefixes excluded) */ +export const KEY_PREFIXES: string[] = [...new Set(CAPTURE_PATTERNS.map(p => p.prefix).filter(p => p.length > 0))]; /** * Get pre-hide CSS rules for a hostname. diff --git a/packages/chrome-extension/src/content-scripts/masker.ts b/packages/chrome-extension/src/content-scripts/masker.ts index 749f7f8..2a08bfa 100644 --- a/packages/chrome-extension/src/content-scripts/masker.ts +++ b/packages/chrome-extension/src/content-scripts/masker.ts @@ -36,6 +36,8 @@ interface MaskRecord { const MASK_ATTR = 'data-demosafe-masked'; const MASK_CLASS = 'demosafe-mask'; const CAPTURE_TIMEOUT_DEFAULT = 300; // 5 minutes in seconds +const CONFIDENCE_HIGH = 0.7; +const CONFIDENCE_MIN = 0.35; // MARK: - Passive Masking State @@ -47,8 +49,11 @@ let maskRecords: MaskRecord[] = []; // MARK: - Active Capture State let isCaptureMode = false; +let isUniversalMasking = false; +let isUniversalDetection = false; let captureTimeoutTimer: ReturnType | null = null; const submittedKeys: Set = new Set(); +const rejectedKeys: Set = new Set(); // MARK: - Shared State @@ -98,6 +103,30 @@ style.textContent = ` } .demosafe-toast .toast-icon { margin-right: 8px; } .demosafe-toast .toast-service { color: #f59e0b; font-weight: 600; } + .demosafe-confirm-dialog { + position: fixed; top: 16px; right: 16px; z-index: 2147483647; + width: 360px; background: #1a1a2e; color: #fff; + border: 1px solid #f59e0b; border-radius: 10px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 13px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); + opacity: 0; transform: translateY(-12px); + transition: opacity 0.3s, transform 0.3s; pointer-events: auto; + } + .demosafe-confirm-dialog .dsc-header { padding: 12px 16px 8px; font-weight: 600; font-size: 14px; color: #f59e0b; } + .demosafe-confirm-dialog .dsc-body { padding: 0 16px 12px; } + .demosafe-confirm-dialog .dsc-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } + .demosafe-confirm-dialog .dsc-label { color: #999; font-size: 12px; } + .demosafe-confirm-dialog .dsc-key { font-family: monospace; background: #2a2a4e; padding: 2px 6px; border-radius: 4px; } + .demosafe-confirm-dialog .dsc-confidence { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; background: rgba(245,158,11,0.2); color: #f59e0b; } + .demosafe-confirm-dialog .dsc-input { width: 100%; padding: 6px 8px; border: 1px solid #444; border-radius: 4px; background: #2a2a4e; color: #fff; font-size: 13px; outline: none; } + .demosafe-confirm-dialog .dsc-input:focus { border-color: #f59e0b; } + .demosafe-confirm-dialog .dsc-source { color: #888; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; } + .demosafe-confirm-dialog .dsc-actions { display: flex; gap: 8px; padding: 0 16px 12px; justify-content: flex-end; } + .demosafe-confirm-dialog .dsc-btn { padding: 6px 16px; border: none; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; } + .demosafe-confirm-dialog .dsc-btn-confirm { background: #22c55e; color: #fff; } + .demosafe-confirm-dialog .dsc-btn-confirm:hover { background: #16a34a; } + .demosafe-confirm-dialog .dsc-btn-reject { background: #374151; color: #d1d5db; } + .demosafe-confirm-dialog .dsc-btn-reject:hover { background: #4b5563; } `; document.head.appendChild(style); @@ -122,13 +151,18 @@ chrome.runtime.onMessage.addListener((message) => { // Persist for pre-hide.ts on next page load chrome.storage.local.set({ demosafeDemoMode: isDemoMode }); if (isDemoMode) { - enablePreHide(); // Re-enable manifest CSS protection - scanAndMask(); + if (isOnSupportedPlatform() || isUniversalMasking) { + enablePreHide(); + scanAndMask(); + } if (isOnSupportedPlatform()) { startPlatformWatcher(); startClipboardInterceptor(); startInputPolling(); scanForNewKeys(); + } else if (shouldAutoCapture()) { + startInputPolling(); + scanForNewKeys(); } } else { unmaskAll(); @@ -162,6 +196,10 @@ chrome.runtime.onMessage.addListener((message) => { case 'capture_mode_changed': handleCaptureModeChanged(message.payload?.isActive ?? false, message.payload?.timeout ?? CAPTURE_TIMEOUT_DEFAULT); break; + + case 'key_confirmed': + showToast(message.payload?.serviceName ?? 'Unknown', 'key stored'); + break; } }); @@ -356,9 +394,12 @@ function isOnSupportedPlatform(): boolean { return false; } -/** Auto-capture should run when Demo Mode is ON and on a supported platform */ +/** Auto-capture should run when Demo Mode is ON and on a supported platform, or universal detection is enabled */ function shouldAutoCapture(): boolean { - return (isDemoMode && isOnSupportedPlatform()) || isCaptureMode; + if (isCaptureMode) return true; + if (isDemoMode && isOnSupportedPlatform()) return true; + if (isDemoMode && isUniversalDetection) return true; + return false; } // MARK: - Active Capture: Mode Management @@ -730,35 +771,146 @@ function restoreClipboardWriteText() { // MARK: - Active Capture: Submission +function generateMaskedPreview(rawValue: string): string { + return rawValue.length > 12 ? rawValue.slice(0, 8) + '****...' : '****...****'; +} + +function isAlreadyStoredKey(value: string): boolean { + for (const [, regex] of compiledPatterns) { + regex.lastIndex = 0; + if (regex.test(value)) return true; + } + return false; +} + function submitCapturedKey(match: CaptureMatch) { const trimmedValue = match.rawValue.trim(); - // Dedup: skip if already submitted in this capture session if (submittedKeys.has(trimmedValue)) return; + if (rejectedKeys.has(trimmedValue)) return; + if (isAlreadyStoredKey(trimmedValue)) return; submittedKeys.add(trimmedValue); match.rawValue = trimmedValue; - // Mask preview: show prefix + **** - const preview = match.rawValue.length > 12 - ? match.rawValue.slice(0, 8) + '****...' - : '****...****'; - - chrome.runtime.sendMessage({ - type: 'submit_captured_key', - payload: { - rawValue: match.rawValue, - suggestedService: match.serviceName, - sourceURL: window.location.href, - confidence: match.confidence, - captureMethod: match.captureMethod, - }, - }).catch(() => {}); + const preview = generateMaskedPreview(trimmedValue); + + if (match.confidence >= CONFIDENCE_HIGH) { + // High confidence — submit + mask + toast (original behavior) + chrome.runtime.sendMessage({ + type: 'submit_captured_key', + payload: { + rawValue: match.rawValue, + suggestedService: match.serviceName, + sourceURL: window.location.href, + confidence: match.confidence, + captureMethod: match.captureMethod, + }, + }).catch(() => {}); + if (isDemoMode) { + immediatelyMaskValue(trimmedValue, match.serviceName, preview); + } + showToast(match.serviceName, preview); + + } else if (match.confidence >= CONFIDENCE_MIN) { + // Medium confidence — mask first (prevent leak), then show confirmation + if (isDemoMode) { + immediatelyMaskValue(trimmedValue, match.serviceName, preview); + } + showConfirmationDialog(match, preview); + } + // Below CONFIDENCE_MIN — silently ignore +} + +// MARK: - Active Capture: Confirmation Dialog - // Immediately mask in DOM without waiting for Core round-trip - if (isDemoMode) { - immediatelyMaskValue(trimmedValue, match.serviceName, preview); +const pendingConfirmations: { match: CaptureMatch; preview: string }[] = []; +let confirmDialogActive = false; + +function showConfirmationDialog(match: CaptureMatch, preview: string) { + if (confirmDialogActive) { + pendingConfirmations.push({ match, preview }); + return; } + confirmDialogActive = true; + document.querySelector('.demosafe-confirm-dialog')?.remove(); + + const dialog = document.createElement('div'); + dialog.className = 'demosafe-confirm-dialog'; + dialog.innerHTML = ` +
⚠ Possible API Key Detected
+
+
Key${preview}
+
Confidence${Math.round(match.confidence * 100)}%
+
Service
+
Source${window.location.hostname}
+
+
+ + +
`; + + const serviceInput = dialog.querySelector('.dsc-input')!; + + const closeDialog = (reject: boolean) => { + dialog.style.opacity = '0'; + dialog.style.transform = 'translateY(-12px)'; + document.removeEventListener('keydown', escHandler); + if (reject) { + rejectedKeys.add(match.rawValue); + removeMaskForValue(match.rawValue); + } + setTimeout(() => { + dialog.remove(); + confirmDialogActive = false; + const next = pendingConfirmations.shift(); + if (next) showConfirmationDialog(next.match, next.preview); + }, 300); + }; + + dialog.querySelector('.dsc-btn-confirm')!.addEventListener('click', () => { + chrome.runtime.sendMessage({ + type: 'confirm_captured_key', + payload: { + rawValue: match.rawValue, + suggestedService: serviceInput.value.trim() || match.serviceName, + sourceURL: window.location.href, + captureMethod: match.captureMethod, + }, + }).catch(() => {}); + closeDialog(false); + }); + + dialog.querySelector('.dsc-btn-reject')!.addEventListener('click', () => closeDialog(true)); + + const escHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') closeDialog(true); }; + document.addEventListener('keydown', escHandler); + setTimeout(() => { if (dialog.parentNode) closeDialog(true); }, 30000); - showToast(match.serviceName, preview); + document.body.appendChild(dialog); + requestAnimationFrame(() => { dialog.style.opacity = '1'; dialog.style.transform = 'translateY(0)'; }); +} + +// MARK: - Active Capture: Remove Mask for Rejected Key + +function removeMaskForValue(rawValue: string) { + const toRemove: MaskRecord[] = []; + const remaining: MaskRecord[] = []; + for (const record of maskRecords) { + if (record.originalText === rawValue) toRemove.push(record); + else remaining.push(record); + } + for (const record of toRemove) { + const parent = record.element.parentNode; + if (!parent) continue; + parent.replaceChild(document.createTextNode(record.originalText), record.element); + parent.normalize(); + } + maskRecords = remaining; + document.querySelectorAll('input[data-demosafe-original], textarea[data-demosafe-original]').forEach(el => { + if (el.getAttribute('data-demosafe-original') === rawValue) { + el.value = rawValue; + el.removeAttribute('data-demosafe-original'); + } + }); } /** @@ -923,7 +1075,7 @@ function showToast(serviceName: string, preview: string) { toast.style.opacity = '0'; toast.style.transform = 'translateY(-8px)'; setTimeout(() => toast.remove(), 300); - }, 10000); + }, 25000); } // MARK: - MutationObserver @@ -991,15 +1143,22 @@ startObserver(); chrome.runtime.sendMessage({ type: 'get_state' }, (response) => { if (response) { isDemoMode = response.isDemoMode ?? false; + isUniversalMasking = response.isUniversalMasking ?? false; + isUniversalDetection = response.isUniversalDetection ?? false; chrome.storage.local.set({ demosafeDemoMode: isDemoMode }); if (isDemoMode) { - enablePreHide(); - scanAndMask(); + if (isOnSupportedPlatform() || isUniversalMasking) { + enablePreHide(); + scanAndMask(); + } if (isOnSupportedPlatform()) { startPlatformWatcher(); startClipboardInterceptor(); startInputPolling(); scanForNewKeys(); + } else if (shouldAutoCapture()) { + startInputPolling(); + scanForNewKeys(); } } else { removePreHide(); diff --git a/packages/chrome-extension/src/content-scripts/pre-hide.ts b/packages/chrome-extension/src/content-scripts/pre-hide.ts index 4b84ec1..39c1d0b 100644 --- a/packages/chrome-extension/src/content-scripts/pre-hide.ts +++ b/packages/chrome-extension/src/content-scripts/pre-hide.ts @@ -105,3 +105,23 @@ if (document.body) { } (window as unknown as Record).__demosafe_instant_observer = instantObserver; + +// === Layer 3: Turbo/SPA navigation pre-hide === +// GitHub uses Turbo (Hotwire) for partial page transitions. +// Pre-hide key elements in the incoming body BEFORE it renders. +if (css) { + // Extract selectors from CSS string: "sel1, sel2 { ... }" → "sel1, sel2" + const selectors = css.replace(/\{[^}]+\}/g, '').trim(); + if (selectors) { + document.addEventListener('turbo:before-render', ((event: CustomEvent) => { + const newBody = event.detail?.newBody as HTMLElement | undefined; + if (!newBody) return; + try { + newBody.querySelectorAll(selectors).forEach(el => { + (el as HTMLElement).style.setProperty('visibility', 'hidden', 'important'); + el.setAttribute('data-demosafe-prehidden', 'true'); + }); + } catch { /* invalid selector — skip */ } + }) as EventListener); + } +} diff --git a/packages/chrome-extension/src/popup/popup.html b/packages/chrome-extension/src/popup/popup.html index 317234f..cf4a0e2 100644 --- a/packages/chrome-extension/src/popup/popup.html +++ b/packages/chrome-extension/src/popup/popup.html @@ -100,6 +100,16 @@ opacity: 0.4; cursor: not-allowed; } + .toggle-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-bottom: 1px solid #e5e5e5; } + .toggle-label { display: flex; flex-direction: column; gap: 2px; } + .toggle-label .label-main { font-size: 12px; color: #333; font-weight: 500; } + .toggle-label .label-sub { font-size: 10px; color: #999; } + .toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; } + .toggle-switch input { opacity: 0; width: 0; height: 0; } + .toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background: #ccc; border-radius: 20px; transition: background 0.2s; } + .toggle-slider::before { content: ''; position: absolute; height: 16px; width: 16px; left: 2px; bottom: 2px; background: white; border-radius: 50%; transition: transform 0.2s; } + .toggle-switch input:checked + .toggle-slider { background: #8b5cf6; } + .toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); } .footer { padding: 8px 12px; border-top: 1px solid #e5e5e5; @@ -149,6 +159,15 @@ +
+
Universal MaskingMask stored keys on all pages
+ +
+
+
Universal DetectionDetect new keys on all pages
+ +
+ diff --git a/packages/chrome-extension/src/popup/popup.ts b/packages/chrome-extension/src/popup/popup.ts index 9c11bdc..f0708df 100644 --- a/packages/chrome-extension/src/popup/popup.ts +++ b/packages/chrome-extension/src/popup/popup.ts @@ -11,6 +11,8 @@ interface DemoSafeState { captureTimeoutEnd: number | null; capturedCount: number; connectionPath: 'ws' | 'nmh' | 'offline'; + isUniversalMasking: boolean; + isUniversalDetection: boolean; } const CONNECTION_LABELS: Record = { @@ -92,6 +94,10 @@ function updateUI(state: DemoSafeState) { captureBtn.style.display = 'none'; stopCountdown(); } + + // Universal toggles + (document.getElementById('toggleUniversalMasking') as HTMLInputElement).checked = state.isUniversalMasking ?? false; + (document.getElementById('toggleUniversalDetection') as HTMLInputElement).checked = state.isUniversalDetection ?? false; } function startCountdown(endTimestamp: number | null) { @@ -146,3 +152,11 @@ document.getElementById('toggleDemo')!.addEventListener('click', () => { document.getElementById('toggleCapture')!.addEventListener('click', () => { chrome.runtime.sendMessage({ type: 'toggle_capture_mode' }); }); + +// Toggle universal masking / detection +document.getElementById('toggleUniversalMasking')!.addEventListener('change', () => { + chrome.runtime.sendMessage({ type: 'toggle_universal_masking' }); +}); +document.getElementById('toggleUniversalDetection')!.addEventListener('change', () => { + chrome.runtime.sendMessage({ type: 'toggle_universal_detection' }); +}); diff --git a/shared/ipc-protocol/src/index.ts b/shared/ipc-protocol/src/index.ts index cd40ae2..fa89d81 100644 --- a/shared/ipc-protocol/src/index.ts +++ b/shared/ipc-protocol/src/index.ts @@ -24,6 +24,7 @@ export type RequestAction = | 'request_paste' | 'request_paste_group' | 'submit_detected' + | 'submit_captured_key' | 'resolve_mask'; export interface HandshakePayload {