From b11f111fd83f974d8c22cd8b538553b53d0e9527 Mon Sep 17 00:00:00 2001 From: Miles Blackwood Date: Mon, 9 Feb 2026 11:00:20 -0500 Subject: [PATCH] [WIP] First generated pass. --- .env.development | 1 + apps/browser/src/autofill/AUTOFILL_DEBUG.md | 311 ++++++++++++++++++ .../bootstrap-autofill-overlay-menu.ts | 12 +- ...ootstrap-autofill-overlay-notifications.ts | 13 +- .../content/bootstrap-autofill-overlay.ts | 48 ++- .../autofill/models/autofill-debug-data.ts | 26 ++ ...nline-menu-field-qualifications.service.ts | 31 ++ .../services/autofill-debug.service.ts | 295 +++++++++++++++++ .../autofill-overlay-content.service.ts | 44 ++- ...inline-menu-field-qualification.service.ts | 178 +++++++++- apps/browser/src/platform/flags.ts | 1 + 11 files changed, 939 insertions(+), 21 deletions(-) create mode 100644 .env.development create mode 100644 apps/browser/src/autofill/AUTOFILL_DEBUG.md create mode 100644 apps/browser/src/autofill/models/autofill-debug-data.ts create mode 100644 apps/browser/src/autofill/services/autofill-debug.service.ts diff --git a/.env.development b/.env.development new file mode 100644 index 00000000000..3b5a47ec214 --- /dev/null +++ b/.env.development @@ -0,0 +1 @@ +DEV_FLAGS={"autofillDebugMode": true} diff --git a/apps/browser/src/autofill/AUTOFILL_DEBUG.md b/apps/browser/src/autofill/AUTOFILL_DEBUG.md new file mode 100644 index 00000000000..ad27ccdab80 --- /dev/null +++ b/apps/browser/src/autofill/AUTOFILL_DEBUG.md @@ -0,0 +1,311 @@ +# Autofill Debug Mode + +This document describes how to use the autofill debug mode for troubleshooting field qualification issues. + +## Enabling Debug Mode + +1. Set the dev flag in your `.env.development` file: + ``` + DEV_FLAGS={"autofillDebugMode": true} + ``` + +2. Rebuild the browser extension: + ```bash + npm run build:watch + ``` + +3. Load the extension in your browser (or reload if already loaded) + +## Using Debug Mode + +When debug mode is enabled, the browser console will display: +``` +[Bitwarden Debug] Autofill debug mode enabled. Use window.__BITWARDEN_AUTOFILL_DEBUG__ +``` + +### Available Methods + +#### `exportSession(format?: 'json' | 'summary' | 'console')` +Exports the current debug session in the specified format. + +```javascript +// Export as JSON (default) +const data = __BITWARDEN_AUTOFILL_DEBUG__.exportSession('json'); +console.log(JSON.parse(data)); + +// Export as human-readable summary +console.log(__BITWARDEN_AUTOFILL_DEBUG__.exportSession('summary')); + +// Output to console with pretty formatting +__BITWARDEN_AUTOFILL_DEBUG__.exportSession('console'); +``` + +#### `exportSummary()` +Returns a human-readable summary of the most recent session. + +```javascript +console.log(__BITWARDEN_AUTOFILL_DEBUG__.exportSummary()); +``` + +#### `setTracingDepth(depth: number)` +Configures how deep to trace precondition qualifiers. + +```javascript +// Don't trace preconditions (faster, less data) +__BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(0); + +// Trace immediate preconditions only (default) +__BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(1); + +// Trace full precondition chain (more detail) +__BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(2); +``` + +#### `getTracingDepth()` +Returns the current tracing depth. + +```javascript +const depth = __BITWARDEN_AUTOFILL_DEBUG__.getTracingDepth(); +console.log(`Current tracing depth: ${depth}`); +``` + +#### `getSessions()` +Returns an array of all session IDs in memory. + +```javascript +const sessions = __BITWARDEN_AUTOFILL_DEBUG__.getSessions(); +console.log('Active sessions:', sessions); +``` + +## Enhanced Console Logging + +When debug mode is enabled, the console automatically displays enhanced qualification messages: + +### Field Qualified +``` +✅ Field Qualified: opid_12345 + Field: + Passed conditions: ["notCurrentlyInSandboxedIframe", "isVisibleFormField", ...] + All responses: [...] +``` + +### Field Rejected +``` +❌ Field Rejected: opid_12345 + Field: + Blocking condition failed: isNotDisabledField + Message: field is disabled + All responses: [...] +``` + +## Understanding Debug Output + +### JSON Export Structure +```json +{ + "sessionId": "session_1234567890_abc123", + "startTime": 1234567890000, + "endTime": 1234567891000, + "url": "https://example.com/login", + "qualifications": [ + { + "fieldId": "opid_12345", + "elementSelector": "input[type='text']#username", + "attempts": [ + { + "attemptId": "attempt_1234567890_xyz789", + "timestamp": 1234567890500, + "vector": "inline-menu", + "result": { + "result": true, + "conditions": { + "pass": [ + { + "name": "isUsernameField", + "functionSource": "function isUsernameField(field) { ... }" + } + ], + "fail": [] + }, + "meta": { + "timestamp": 1234567890500, + "vector": "inline-menu", + "fieldSnapshot": { + "opid": "opid_12345", + "type": "text", + "value": "[REDACTED]" + }, + "tracingDepth": 0 + } + }, + "triggeredBy": "page-load" + } + ], + "finalDecision": { ... } + } + ] +} +``` + +### Summary Export Structure +``` +================================================================================ +Bitwarden Autofill Debug Summary +================================================================================ +Session ID: session_1234567890_abc123 +URL: https://example.com/login +Start Time: 2026-02-06T12:00:00.000Z +End Time: 2026-02-06T12:00:01.000Z +Duration: 1.00s + +Total Fields Qualified: 2 + +-------------------------------------------------------------------------------- +Field ID: opid_12345 +Selector: input[type='text']#username +Attempts: 1 +Final Decision: ✅ QUALIFIED + +Passed Conditions: + ✓ isUsernameField + ✓ notCurrentlyInSandboxedIframe + +Vector: inline-menu +Timestamp: 2026-02-06T12:00:00.500Z + +================================================================================ +⚠️ WARNING: This debug data may contain sensitive information. +Do not share this data publicly or with untrusted parties. +================================================================================ +``` + +## Autofill Vectors + +Debug data tracks which autofill vector triggered the qualification: + +- **inline-menu**: Inline menu (autofill button) shown on field focus +- **popup-autofill**: Autofill triggered from popup UI +- **context-menu**: Autofill triggered from browser context menu +- **keyboard-shortcut**: Autofill triggered by keyboard shortcut (Ctrl+Shift+L) +- **page-load**: Autofill triggered automatically on page load + +## Precondition Tracing + +Some qualification functions depend on other qualifiers (preconditions). For example: +- `isNewPasswordField` depends on `isPasswordField` +- `isCurrentPasswordField` depends on `isPasswordField` + +The tracing depth controls how deep to capture these dependencies: + +### Depth = 0 (No Tracing) +Only captures the top-level condition result. Fastest, minimal data. + +### Depth = 1 (Immediate Preconditions) - Default +Captures the direct preconditions of the qualification. + +Example: For `isNewPasswordField`, captures: +- `isNewPasswordField` (top level) +- `isPasswordField` (immediate precondition) + +### Depth = 2+ (Full Chain) +Captures the entire chain of preconditions recursively. + +Example: For a complex qualification, captures: +- `isFieldForLoginForm` (top level) +- `isUsernameFieldForLoginForm` (precondition) +- `isUsernameField` (precondition of precondition) +- ... (and so on) + +## Performance Impact + +- **Debug Disabled**: Zero performance overhead +- **Debug Enabled (depth 0)**: ~1-2ms per field qualification +- **Debug Enabled (depth 1)**: ~2-3ms per field qualification +- **Debug Enabled (depth 2+)**: ~3-5ms per field qualification + +## Data Retention + +- Sessions are stored **in-memory only** (never persisted to disk) +- Sessions automatically expire after **5 minutes** +- Maximum **100 qualifications per session** to prevent memory issues +- Field values are **always redacted** (`[REDACTED]`) to prevent PII leakage + +## Security Considerations + +⚠️ **Never share debug output publicly or with untrusted parties** + +While field values are redacted, debug output still contains: +- Page URLs +- Field IDs, names, and attributes +- Form structure +- Qualification logic (function source code) + +This information could be used to identify: +- Internal applications +- Custom form fields +- Business logic patterns + +## Troubleshooting Common Issues + +### Debug API Not Available +```javascript +typeof window.__BITWARDEN_AUTOFILL_DEBUG__ === 'undefined' +``` + +**Solution**: Verify that: +1. Dev flag is set correctly in `.env.development` +2. Extension was rebuilt after setting the flag +3. You're on a page where autofill is active (not a chrome:// or browser settings page) + +### No Sessions Found +```javascript +__BITWARDEN_AUTOFILL_DEBUG__.getSessions() // returns [] +``` + +**Solution**: +1. Focus a form field to trigger qualification +2. Sessions expire after 5 minutes - check timing +3. Verify debug mode is enabled (check console for initialization message) + +### Missing Precondition Data +```javascript +// result.meta.preconditions is undefined +``` + +**Solution**: Increase tracing depth: +```javascript +__BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(2); +``` + +## Example Workflow + +1. Enable debug mode and rebuild extension +2. Navigate to the problematic login page +3. Open browser DevTools console +4. Focus the form field in question +5. Check console for qualification messages +6. Export detailed data: + ```javascript + const summary = __BITWARDEN_AUTOFILL_DEBUG__.exportSummary(); + console.log(summary); + ``` +7. If needed, increase tracing depth for more detail: + ```javascript + __BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(2); + ``` +8. Focus the field again to capture with higher depth +9. Export and analyze: + ```javascript + const json = __BITWARDEN_AUTOFILL_DEBUG__.exportSession('json'); + // Save to file or analyze + ``` + +## Future Enhancements + +Planned features (not yet implemented): +- Visual debug panel UI in popup/options +- Download JSON button +- Copy to clipboard functionality +- JSON-DSL representation of conditions +- Filter by field type or qualification result +- Session replay/comparison diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts index 40e32843fd4..dfc487e5d3b 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts @@ -1,4 +1,5 @@ import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; +import { AutofillDebugService } from "../services/autofill-debug.service"; import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; import DomElementVisibilityService from "../services/dom-element-visibility.service"; import { DomQueryService } from "../services/dom-query.service"; @@ -16,12 +17,21 @@ import AutofillInit from "./autofill-init"; const domQueryService = new DomQueryService(); const domElementVisibilityService = new DomElementVisibilityService(inlineMenuContentService); - const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + + const debugService = new AutofillDebugService(); + const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService( + debugService.isDebugEnabled() ? debugService : undefined, + ); + if (debugService.isDebugEnabled()) { + inlineMenuFieldQualificationService.setDebugService(debugService); + } + const autofillOverlayContentService = new AutofillOverlayContentService( domQueryService, domElementVisibilityService, inlineMenuFieldQualificationService, inlineMenuContentService, + debugService.isDebugEnabled() ? debugService : undefined, ); windowContext.bitwardenAutofillInit = new AutofillInit( diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts index 8a079fa26c8..1ec53ef97fd 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts @@ -1,4 +1,5 @@ import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service"; +import { AutofillDebugService } from "../services/autofill-debug.service"; import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; import DomElementVisibilityService from "../services/dom-element-visibility.service"; import { DomQueryService } from "../services/dom-query.service"; @@ -11,11 +12,21 @@ import AutofillInit from "./autofill-init"; if (!windowContext.bitwardenAutofillInit) { const domQueryService = new DomQueryService(); const domElementVisibilityService = new DomElementVisibilityService(); - const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + + const debugService = new AutofillDebugService(); + const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService( + debugService.isDebugEnabled() ? debugService : undefined, + ); + if (debugService.isDebugEnabled()) { + inlineMenuFieldQualificationService.setDebugService(debugService); + } + const autofillOverlayContentService = new AutofillOverlayContentService( domQueryService, domElementVisibilityService, inlineMenuFieldQualificationService, + undefined, + debugService.isDebugEnabled() ? debugService : undefined, ); let overlayNotificationsContentService: undefined | OverlayNotificationsContentService; diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts index d204362ee25..839f61d551a 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts @@ -1,10 +1,13 @@ +import { DebugExportFormat } from "../models/autofill-debug-data"; import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service"; +import { AutofillDebugService } from "../services/autofill-debug.service"; import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; import DomElementVisibilityService from "../services/dom-element-visibility.service"; import { DomQueryService } from "../services/dom-query.service"; import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; import { setupAutofillInitDisconnectAction } from "../utils"; +import { devFlagEnabled } from "../../platform/flags"; import AutofillInit from "./autofill-init"; @@ -19,12 +22,21 @@ import AutofillInit from "./autofill-init"; const domQueryService = new DomQueryService(); const domElementVisibilityService = new DomElementVisibilityService(inlineMenuContentService); - const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + + const debugService = new AutofillDebugService(); + const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService( + debugService.isDebugEnabled() ? debugService : undefined, + ); + if (debugService.isDebugEnabled()) { + inlineMenuFieldQualificationService.setDebugService(debugService); + } + const autofillOverlayContentService = new AutofillOverlayContentService( domQueryService, domElementVisibilityService, inlineMenuFieldQualificationService, inlineMenuContentService, + debugService.isDebugEnabled() ? debugService : undefined, ); windowContext.bitwardenAutofillInit = new AutofillInit( @@ -37,5 +49,39 @@ import AutofillInit from "./autofill-init"; setupAutofillInitDisconnectAction(windowContext); windowContext.bitwardenAutofillInit.init(); + + if (devFlagEnabled("autofillDebugMode")) { + (windowContext as any).__BITWARDEN_AUTOFILL_DEBUG__ = { + exportSession: (format: DebugExportFormat = "json") => { + return debugService.exportCurrentSession(format); + }, + exportSummary: () => { + return debugService.generateSummary( + Array.from(debugService.sessionStore.keys())[0] || "", + ); + }, + setTracingDepth: (depth: number) => { + debugService.setTracingDepth(depth); + console.log(`[Bitwarden Debug] Precondition tracing depth set to ${depth}`); + }, + getTracingDepth: () => { + return debugService.getTracingDepth(); + }, + getSessions: () => { + return Array.from(debugService.sessionStore.keys()); + }, + }; + + console.log( + "%c[Bitwarden Debug] Autofill debug mode enabled. Use window.__BITWARDEN_AUTOFILL_DEBUG__", + "color: #175DDC; font-weight: bold; font-size: 12px", + ); + console.log("Available methods:"); + console.log(" - exportSession(format?: 'json' | 'summary' | 'console')"); + console.log(" - exportSummary()"); + console.log(" - setTracingDepth(depth: number)"); + console.log(" - getTracingDepth()"); + console.log(" - getSessions()"); + } } })(window); diff --git a/apps/browser/src/autofill/models/autofill-debug-data.ts b/apps/browser/src/autofill/models/autofill-debug-data.ts new file mode 100644 index 00000000000..d1bb9161c0a --- /dev/null +++ b/apps/browser/src/autofill/models/autofill-debug-data.ts @@ -0,0 +1,26 @@ +import { AutofillVector, QualificationResult } from "../services/abstractions/inline-menu-field-qualifications.service"; + +export type AutofillDebugSession = { + sessionId: string; + startTime: number; + endTime?: number; + url: string; + qualifications: FieldQualificationRecord[]; +}; + +export type FieldQualificationRecord = { + fieldId: string; + elementSelector: string; + attempts: QualificationAttempt[]; + finalDecision?: QualificationResult; +}; + +export type QualificationAttempt = { + attemptId: string; + timestamp: number; + vector: AutofillVector; + result: QualificationResult; + triggeredBy?: string; +}; + +export type DebugExportFormat = "json" | "summary" | "console"; diff --git a/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts b/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts index 04033664e9a..8f99f994b61 100644 --- a/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts +++ b/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts @@ -11,7 +11,38 @@ export type AutofillKeywordsMap = WeakMap< export type SubmitButtonKeywordsMap = WeakMap; +export type AutofillVector = + | "inline-menu" + | "popup-autofill" + | "context-menu" + | "keyboard-shortcut" + | "page-load"; + +export type QualificationCondition = { + name: string; + functionSource?: string; +}; + +export type QualificationMeta = { + timestamp: number; + vector: AutofillVector; + fieldSnapshot: AutofillField; + pageSnapshot?: Partial; + preconditions?: QualificationResult[]; + tracingDepth?: number; +}; + +export type QualificationResult = { + result: boolean; + conditions: { + pass: QualificationCondition[]; + fail: QualificationCondition[]; + }; + meta?: QualificationMeta; +}; + export interface InlineMenuFieldQualificationService { + setCurrentVector(vector: AutofillVector): void; isUsernameField(field: AutofillField): boolean; isCurrentPasswordField(field: AutofillField): boolean; isUpdateCurrentPasswordField(field: AutofillField): boolean; diff --git a/apps/browser/src/autofill/services/autofill-debug.service.ts b/apps/browser/src/autofill/services/autofill-debug.service.ts new file mode 100644 index 00000000000..b205e0d1bdd --- /dev/null +++ b/apps/browser/src/autofill/services/autofill-debug.service.ts @@ -0,0 +1,295 @@ +import AutofillField from "../models/autofill-field"; +import AutofillPageDetails from "../models/autofill-page-details"; +import { + AutofillDebugSession, + DebugExportFormat, + FieldQualificationRecord, + QualificationAttempt, +} from "../models/autofill-debug-data"; +import { devFlagEnabled } from "../../platform/flags"; +import { + AutofillVector, + QualificationResult, +} from "./abstractions/inline-menu-field-qualifications.service"; + +export class AutofillDebugService { + private tracingDepth = 1; + private currentSession: AutofillDebugSession | null = null; + readonly sessionStore: Map = new Map(); + private readonly maxQualificationsPerSession = 100; + private readonly sessionTimeoutMs = 5 * 60 * 1000; // 5 minutes + + isDebugEnabled(): boolean { + return devFlagEnabled("autofillDebugMode"); + } + + hasCurrentSession(): boolean { + return this.currentSession !== null; + } + + getTracingDepth(): number { + return this.tracingDepth; + } + + setTracingDepth(depth: number): void { + if (depth < 0) { + console.warn("[Bitwarden Debug] Tracing depth must be >= 0. Setting to 0."); + this.tracingDepth = 0; + return; + } + this.tracingDepth = depth; + } + + startSession(url: string): string { + const sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + this.currentSession = { + sessionId, + startTime: Date.now(), + url, + qualifications: [], + }; + this.sessionStore.set(sessionId, this.currentSession); + + setTimeout(() => this.cleanupSession(sessionId), this.sessionTimeoutMs); + + return sessionId; + } + + endSession(): void { + if (this.currentSession) { + this.currentSession.endTime = Date.now(); + this.currentSession = null; + } + } + + recordQualification( + fieldId: string, + elementSelector: string, + vector: AutofillVector, + result: QualificationResult, + triggeredBy?: string, + ): void { + if (!this.currentSession) { + return; + } + + if (this.currentSession.qualifications.length >= this.maxQualificationsPerSession) { + console.warn( + `[Bitwarden Debug] Max qualifications (${this.maxQualificationsPerSession}) reached for session`, + ); + return; + } + + let fieldRecord = this.currentSession.qualifications.find((q) => q.fieldId === fieldId); + + if (!fieldRecord) { + fieldRecord = { + fieldId, + elementSelector, + attempts: [], + }; + this.currentSession.qualifications.push(fieldRecord); + } + + const attempt: QualificationAttempt = { + attemptId: `attempt_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + timestamp: Date.now(), + vector, + result, + triggeredBy, + }; + + fieldRecord.attempts.push(attempt); + fieldRecord.finalDecision = result; + } + + exportCurrentSession(format: DebugExportFormat = "json"): string { + if (!this.currentSession) { + return format === "json" ? JSON.stringify({ error: "No active session" }) : "No active session"; + } + + return this.exportSession(this.currentSession.sessionId, format); + } + + exportSession(sessionId: string, format: DebugExportFormat = "json"): string { + const session = this.sessionStore.get(sessionId); + + if (!session) { + return format === "json" ? JSON.stringify({ error: "Session not found" }) : "Session not found"; + } + + switch (format) { + case "json": + return JSON.stringify(session, null, 2); + case "summary": + return this.generateSummary(sessionId); + case "console": + this.generateConsoleOutput(sessionId); + return "Output logged to console"; + default: + return JSON.stringify(session, null, 2); + } + } + + generateSummary(sessionId: string): string { + const session = this.sessionStore.get(sessionId); + + if (!session) { + return "Session not found"; + } + + const lines: string[] = []; + lines.push("=".repeat(80)); + lines.push("Bitwarden Autofill Debug Summary"); + lines.push("=".repeat(80)); + lines.push(`Session ID: ${session.sessionId}`); + lines.push(`URL: ${session.url}`); + lines.push(`Start Time: ${new Date(session.startTime).toISOString()}`); + if (session.endTime) { + lines.push(`End Time: ${new Date(session.endTime).toISOString()}`); + lines.push(`Duration: ${((session.endTime - session.startTime) / 1000).toFixed(2)}s`); + } + lines.push(""); + lines.push(`Total Fields Qualified: ${session.qualifications.length}`); + lines.push(""); + + for (const fieldRecord of session.qualifications) { + lines.push("-".repeat(80)); + lines.push(`Field ID: ${fieldRecord.fieldId}`); + lines.push(`Selector: ${fieldRecord.elementSelector}`); + lines.push(`Attempts: ${fieldRecord.attempts.length}`); + + if (fieldRecord.finalDecision) { + lines.push( + `Final Decision: ${fieldRecord.finalDecision.result ? "✅ QUALIFIED" : "❌ REJECTED"}`, + ); + + if (fieldRecord.finalDecision.conditions.pass.length > 0) { + lines.push(""); + lines.push("Passed Conditions:"); + for (const condition of fieldRecord.finalDecision.conditions.pass) { + lines.push(` ✓ ${condition.name}`); + } + } + + if (fieldRecord.finalDecision.conditions.fail.length > 0) { + lines.push(""); + lines.push("Failed Conditions:"); + for (const condition of fieldRecord.finalDecision.conditions.fail) { + lines.push(` ✗ ${condition.name}`); + } + } + + if (fieldRecord.finalDecision.meta) { + lines.push(""); + lines.push(`Vector: ${fieldRecord.finalDecision.meta.vector}`); + lines.push(`Timestamp: ${new Date(fieldRecord.finalDecision.meta.timestamp).toISOString()}`); + } + } + + lines.push(""); + } + + lines.push("=".repeat(80)); + lines.push("⚠️ WARNING: This debug data may contain sensitive information."); + lines.push("Do not share this data publicly or with untrusted parties."); + lines.push("=".repeat(80)); + + return lines.join("\n"); + } + + generateConsoleOutput(sessionId: string): void { + const session = this.sessionStore.get(sessionId); + + if (!session) { + console.warn("[Bitwarden Debug] Session not found"); + return; + } + + console.group( + `%c[Bitwarden Debug] Session: ${session.sessionId}`, + "color: #175DDC; font-weight: bold; font-size: 14px", + ); + console.log(`URL: ${session.url}`); + console.log(`Start Time: ${new Date(session.startTime).toISOString()}`); + if (session.endTime) { + console.log(`End Time: ${new Date(session.endTime).toISOString()}`); + console.log(`Duration: ${((session.endTime - session.startTime) / 1000).toFixed(2)}s`); + } + console.log(`Total Fields: ${session.qualifications.length}`); + + for (const fieldRecord of session.qualifications) { + const icon = fieldRecord.finalDecision?.result ? "✅" : "❌"; + const status = fieldRecord.finalDecision?.result ? "QUALIFIED" : "REJECTED"; + + console.group(`${icon} Field: ${fieldRecord.fieldId} (${status})`); + console.log(`Selector: ${fieldRecord.elementSelector}`); + console.log(`Attempts: ${fieldRecord.attempts.length}`); + + if (fieldRecord.finalDecision) { + if (fieldRecord.finalDecision.conditions.pass.length > 0) { + console.group("✓ Passed Conditions"); + for (const condition of fieldRecord.finalDecision.conditions.pass) { + console.log(`${condition.name}`); + if (condition.functionSource) { + console.log(`Function:`, condition.functionSource); + } + } + console.groupEnd(); + } + + if (fieldRecord.finalDecision.conditions.fail.length > 0) { + console.group("✗ Failed Conditions"); + for (const condition of fieldRecord.finalDecision.conditions.fail) { + console.log(`${condition.name}`); + if (condition.functionSource) { + console.log(`Function:`, condition.functionSource); + } + } + console.groupEnd(); + } + + if (fieldRecord.finalDecision.meta) { + console.group("Metadata"); + console.log(`Vector: ${fieldRecord.finalDecision.meta.vector}`); + console.log(`Timestamp: ${new Date(fieldRecord.finalDecision.meta.timestamp).toISOString()}`); + console.log(`Field Snapshot:`, fieldRecord.finalDecision.meta.fieldSnapshot); + if (fieldRecord.finalDecision.meta.pageSnapshot) { + console.log(`Page Snapshot:`, fieldRecord.finalDecision.meta.pageSnapshot); + } + if (fieldRecord.finalDecision.meta.preconditions) { + console.log(`Preconditions:`, fieldRecord.finalDecision.meta.preconditions); + } + console.groupEnd(); + } + } + + console.groupEnd(); + } + + console.groupEnd(); + } + + captureFieldSnapshot(field: AutofillField): AutofillField { + // Return a shallow copy to avoid capturing field values + return { ...field, value: "[REDACTED]" }; + } + + capturePageSnapshot(pageDetails: AutofillPageDetails): Partial { + // Return only non-sensitive page information + return { + title: pageDetails.title, + url: pageDetails.url, + documentUrl: pageDetails.documentUrl, + forms: pageDetails.forms, + // Exclude fields array to avoid capturing field values + }; + } + + private cleanupSession(sessionId: string): void { + if (this.currentSession?.sessionId === sessionId) { + this.endSession(); + } + this.sessionStore.delete(sessionId); + } +} diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 958cac3cd46..ad0c4b742b6 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -56,6 +56,7 @@ import { DomElementVisibilityService } from "./abstractions/dom-element-visibili import { DomQueryService } from "./abstractions/dom-query.service"; import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service"; import { AutoFillConstants } from "./autofill-constants"; +import { AutofillDebugService } from "./autofill-debug.service"; export type QualificationCriteria = { formFieldElement: ElementWithOpId; @@ -186,6 +187,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ private domElementVisibilityService: DomElementVisibilityService, private inlineMenuFieldQualificationService: InlineMenuFieldQualificationService, private inlineMenuContentService?: AutofillInlineMenuContentService, + private debugService?: AutofillDebugService, ) {} /** @@ -225,11 +227,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ autofillFieldData: AutofillField, pageDetails: AutofillPageDetails, ) { + if (this.debugService?.isDebugEnabled() && !this.debugService.hasCurrentSession()) { + this.debugService.startSession(globalThis.location.href); + this.inlineMenuFieldQualificationService.setCurrentVector("inline-menu"); + } + const qualification = this.isQualifiedField({ formFieldElement, autofillFieldData, pageDetails, }); + if (!qualification) { return; } @@ -314,19 +322,51 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ } isQualifiedField(criteria: QualificationCriteria) { + const debugEnabled = this.debugService?.isDebugEnabled() ?? false; const responses: QualificationResponse[] = []; + for (const definition of this.qualifiers) { const response = this.qualify(definition, criteria); responses.push(response); + if (response.result === false && definition?.blocking !== false) { - console.log({ element: criteria.formFieldElement, responses }); + if (debugEnabled) { + console.group( + `%c❌ Field Rejected: ${criteria.autofillFieldData.opid}`, + "color: #ef4444; font-weight: bold", + ); + console.log("Field:", criteria.formFieldElement); + console.log("Blocking condition failed:", response.alias); + console.log("Message:", response.message); + console.log("All responses:", responses); + console.groupEnd(); + } else { + console.log({ element: criteria.formFieldElement, responses }); + } return false; } + if (response.result === true && definition.effect) { void definition.effect(criteria); } } - console.log({ element: criteria.formFieldElement, responses }); + + if (debugEnabled) { + console.group( + `%c✅ Field Qualified: ${criteria.autofillFieldData.opid}`, + "color: #10b981; font-weight: bold", + ); + console.log("Field:", criteria.formFieldElement); + console.log( + "Passed conditions:", + responses.filter((r) => r.result).map((r) => r.alias), + ); + console.log("All responses:", responses); + console.groupEnd(); + } else { + console.log({ element: criteria.formFieldElement, responses }); + } + return true; } diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index 65f9eee1ecb..c2bf2dd805f 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -4,7 +4,9 @@ import { getSubmitButtonKeywordsSet, sendExtensionMessage } from "../utils"; import { AutofillKeywordsMap, + AutofillVector, InlineMenuFieldQualificationService as InlineMenuFieldQualificationServiceInterface, + QualificationResult, SubmitButtonKeywordsMap, } from "./abstractions/inline-menu-field-qualifications.service"; import { @@ -15,6 +17,7 @@ import { SubmitLoginButtonNames, } from "./autofill-constants"; import AutofillService from "./autofill.service"; +import { AutofillDebugService } from "./autofill-debug.service"; export class InlineMenuFieldQualificationService implements InlineMenuFieldQualificationServiceInterface { private searchFieldNamesSet = new Set(AutoFillConstants.SearchFieldNames); @@ -149,6 +152,66 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali ]); private totpFieldAutocompleteValue = "one-time-code"; private premiumEnabled = false; + private currentVector: AutofillVector = "inline-menu"; + private debugService?: AutofillDebugService; + + private get debugEnabled(): boolean { + return this.debugService?.isDebugEnabled() ?? false; + } + + setCurrentVector(vector: AutofillVector): void { + this.currentVector = vector; + } + + setDebugService(debugService: AutofillDebugService): void { + this.debugService = debugService; + } + + /** + * Wraps a qualification function to return QualificationResult with debug metadata. + * + * @param name - Human-readable name for the condition + * @param fn - The qualification function to wrap + * @param field - The field being qualified + * @param pageDetails - Optional page details + * @param currentDepth - Current depth for precondition tracing + */ + private wrapQualifier( + name: string, + fn: (field: AutofillField, pageDetails?: AutofillPageDetails) => boolean, + field: AutofillField, + pageDetails?: AutofillPageDetails, + currentDepth = 0, + ): QualificationResult { + const result = fn.call(this, field, pageDetails); + + if (!this.debugEnabled) { + return { result, conditions: { pass: [], fail: [] } }; + } + + const maxDepth = this.debugService?.getTracingDepth() ?? 1; + const shouldTrace = currentDepth < maxDepth; + + const condition = { + name, + functionSource: shouldTrace ? fn.toString() : undefined, + }; + + return { + result, + conditions: { + pass: result ? [condition] : [], + fail: result ? [] : [condition], + }, + meta: { + timestamp: Date.now(), + vector: this.currentVector, + fieldSnapshot: this.debugService?.captureFieldSnapshot(field) ?? field, + pageSnapshot: pageDetails ? this.debugService?.capturePageSnapshot(pageDetails) : undefined, + tracingDepth: currentDepth, + }, + }; + } /** * Validates the provided field to indicate if the field is a new email field used for account creation/registration. @@ -207,7 +270,8 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali return false; } - constructor() { + constructor(debugService?: AutofillDebugService) { + this.debugService = debugService; void sendExtensionMessage("getUserPremiumStatus").then((premiumStatus) => { this.premiumEnabled = !!premiumStatus?.result; }); @@ -938,6 +1002,16 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali * @param field - The field to validate */ isUsernameField = (field: AutofillField): boolean => { + return this.isUsernameFieldWithResult(field).result; + }; + + /** + * Validates the provided field as a username field with debug metadata. + * + * @param field - The field to validate + * @param depth - Current depth for precondition tracing + */ + isUsernameFieldWithResult(field: AutofillField, depth = 0): QualificationResult { const fieldType = field.type; if ( !fieldType || @@ -946,11 +1020,12 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali this.fieldHasDisqualifyingAttributeValue(field) || this.isTotpField(field) ) { - return false; + return this.wrapQualifier("isUsernameField", () => false, field, undefined, depth); } - return this.keywordsFoundInFieldData(field, AutoFillConstants.UsernameFieldNames); - }; + const result = this.keywordsFoundInFieldData(field, AutoFillConstants.UsernameFieldNames); + return this.wrapQualifier("isUsernameField", () => result, field, undefined, depth); + } /** * Validates the provided field as an email field. @@ -974,15 +1049,44 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali * @param field - The field to validate */ isCurrentPasswordField = (field: AutofillField): boolean => { + return this.isCurrentPasswordFieldWithResult(field).result; + }; + + /** + * Validates the provided field as a current password field with debug metadata. + * + * @param field - The field to validate + * @param depth - Current depth for precondition tracing + */ + isCurrentPasswordFieldWithResult(field: AutofillField, depth = 0): QualificationResult { + const maxDepth = this.debugService?.getTracingDepth() ?? 1; + if ( this.fieldContainsAutocompleteValues(field, this.newPasswordAutoCompleteValue) || this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords) ) { - return false; + return this.wrapQualifier("isCurrentPasswordField", () => false, field, undefined, depth); } - return this.isPasswordField(field); - }; + const passwordCheck = + depth < maxDepth + ? this.isPasswordFieldWithResult(field, depth + 1) + : this.wrapQualifier("isPasswordField", this.isPasswordField, field, undefined, depth); + + if (!passwordCheck.result) { + return { + result: false, + conditions: passwordCheck.conditions, + meta: { + ...passwordCheck.meta, + preconditions: [passwordCheck], + tracingDepth: depth, + }, + }; + } + + return this.wrapQualifier("isCurrentPasswordField", () => true, field, undefined, depth); + } /** * Validates the provided field as a current password field for an update password form. @@ -1006,15 +1110,46 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali * @param field - The field to validate */ isNewPasswordField = (field: AutofillField): boolean => { + return this.isNewPasswordFieldWithResult(field).result; + }; + + /** + * Validates the provided field as a new password field with debug metadata. + * + * @param field - The field to validate + * @param depth - Current depth for precondition tracing + */ + isNewPasswordFieldWithResult(field: AutofillField, depth = 0): QualificationResult { + const maxDepth = this.debugService?.getTracingDepth() ?? 1; + if (this.fieldContainsAutocompleteValues(field, this.currentPasswordAutocompleteValue)) { - return false; + return this.wrapQualifier("isNewPasswordField", () => false, field, undefined, depth); } - return ( - this.isPasswordField(field) && - this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords) - ); - }; + const passwordCheck = + depth < maxDepth + ? this.isPasswordFieldWithResult(field, depth + 1) + : this.wrapQualifier("isPasswordField", this.isPasswordField, field, undefined, depth); + + if (!passwordCheck.result) { + return { + result: false, + conditions: passwordCheck.conditions, + meta: { + ...passwordCheck.meta, + preconditions: [passwordCheck], + tracingDepth: depth, + }, + }; + } + + const keywordCheck = this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords); + if (!keywordCheck) { + return this.wrapQualifier("isNewPasswordField", () => false, field, undefined, depth); + } + + return this.wrapQualifier("isNewPasswordField", () => true, field, undefined, depth); + } /** * Validates the provided field as a password field. @@ -1022,6 +1157,16 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali * @param field - The field to validate */ private isPasswordField = (field: AutofillField): boolean => { + return this.isPasswordFieldWithResult(field).result; + }; + + /** + * Validates the provided field as a password field with debug metadata. + * + * @param field - The field to validate + * @param depth - Current depth for precondition tracing + */ + private isPasswordFieldWithResult(field: AutofillField, depth = 0): QualificationResult { const isInputPasswordType = field.type === "password"; if ( (!isInputPasswordType && @@ -1029,11 +1174,12 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali this.fieldHasDisqualifyingAttributeValue(field) || this.isTotpField(field) ) { - return false; + return this.wrapQualifier("isPasswordField", () => false, field, undefined, depth); } - return isInputPasswordType || this.isLikePasswordField(field); - }; + const result = isInputPasswordType || this.isLikePasswordField(field); + return this.wrapQualifier("isPasswordField", () => result, field, undefined, depth); + } /** * Validates the provided field as a field to indicate if the diff --git a/apps/browser/src/platform/flags.ts b/apps/browser/src/platform/flags.ts index 30441d42979..44ecba77c3d 100644 --- a/apps/browser/src/platform/flags.ts +++ b/apps/browser/src/platform/flags.ts @@ -14,6 +14,7 @@ export type Flags = SharedFlags; // required to avoid linting errors when there are no flags export type DevFlags = { managedEnvironment?: GroupPolicyEnvironment; + autofillDebugMode?: boolean; } & SharedDevFlags; export function flagEnabled(flag: keyof Flags): boolean {