diff --git a/.gitignore b/.gitignore index 4bd5595..3395dea 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,7 @@ $RECYCLE.BIN/ # Application generated files evidence.yaml -screenshot.* \ No newline at end of file +screenshot.* +Report.md +report.eml +evidence.snapshot.yaml \ No newline at end of file diff --git a/cmd/email.go b/cmd/email.go index dfc9bdc..76d75d3 100644 --- a/cmd/email.go +++ b/cmd/email.go @@ -10,14 +10,25 @@ import ( "sort" "strings" + "github.com/canaria-computer/down-force/internal/appconfig" + "github.com/canaria-computer/down-force/internal/email" + "github.com/charmbracelet/glamour" "github.com/charmbracelet/log" "github.com/spf13/cobra" + mail "github.com/xhit/go-simple-mail/v2" ) var ( // Persistent flags for email command emailReportDir string + // Security flags for email command + emailIgnoreTLSErrors bool + emailAllowUnsafeHTML bool + + // IMAP timeout override flag + emailIMAPTimeout int + // Flags for draft subcommand emailDraftUpload bool emailDraftExport bool @@ -44,23 +55,39 @@ Available subcommands: draft Create email drafts (upload to IMAP, export .eml, or preview) send-with-confirm Send email after user confirmation +Security Options: + --ignore-tls-errors Disable TLS certificate verification (development only) + --allow-unsafe-html Allow unsafe HTML embedding in emails (trusted sources only) + +These options should only be used in development/testing environments with +self-signed certificates or controlled HTML sources. Production deployments +should use the secure defaults. + Examples: # Preview email draft from latest report down-force email draft --preview - # Upload draft to IMAP server - down-force email draft --upload + # Upload draft to IMAP server with TLS verification disabled (dev only) + down-force email draft --upload --ignore-tls-errors - # Export draft as .eml file - down-force email draft --export + # Export draft as .eml file with unsafe HTML allowed (trusted source only) + down-force email draft --export --allow-unsafe-html # Send email with confirmation prompt down-force email send-with-confirm # Use specific report directory - down-force email draft --preview --report report-20240101-120000`, + down-force email draft --preview --report report-20240101-120000 + +Configuration Precedence: + 1. CLI flags (--ignore-tls-errors, --allow-unsafe-html) + 2. Environment variables (DOWNFORCE_EMAIL_SECURITY_IGNORE_TLS_ERRORS, etc.) + 3. Configuration file (~/.config/down-force/config.yaml) + 4. Defaults (secure by default)`, Run: func(cmd *cobra.Command, args []string) { - cmd.Help() + if err := cmd.Help(); err != nil { + log.Fatal("Failed to display help: %v", err) + } }, } @@ -75,6 +102,7 @@ The draft command generates a properly formatted email with: - HTML and plain-text body with report summary - Attachments (screenshots, HTML files, evidence data) - Proper MIME encoding and headers + - Security headers (DKIM, SPF compatibility) Output modes: --upload Upload draft to IMAP server's Drafts folder @@ -87,6 +115,12 @@ If no mode is specified, --preview is used by default. The command automatically detects the latest report directory (report-*) in the current working directory. Use --report to specify a different report. +Security Options: + --ignore-tls-errors Skip TLS certificate verification (SMTP/IMAP) + WARNING: Only use in development with self-signed certs + --allow-unsafe-html Disable HTML sanitization in email body + WARNING: Only use with trusted HTML sources + Examples: # Preview draft from latest report down-force email draft @@ -104,8 +138,12 @@ Examples: # Use specific report directory down-force email draft --preview --report report-20240101-120000 + # Development: disable TLS verification for self-signed certificates + down-force email draft --upload --ignore-tls-errors + Configuration: - IMAP/SMTP credentials should be configured via environment variables or config file.`, + IMAP/SMTP credentials configured via environment variables or config file. + Use 'down-force config email' to set up email credentials.`, Run: runEmailDraft, } @@ -125,6 +163,12 @@ This command: The Message-ID header is automatically generated by default for email tracking and threading. Use --no-set-message-id to skip Message-ID generation and let the mail server assign one. +Security Options: + --ignore-tls-errors Disable TLS certificate verification (SMTP) + WARNING: Only use in development with self-signed certs + --allow-unsafe-html Disable HTML sanitization in email body + WARNING: Only use with trusted HTML sources + Examples: # Send email with confirmation (default behavior) down-force email send-with-confirm @@ -135,13 +179,18 @@ Examples: # Send from specific report directory down-force email send-with-confirm --report report-20240101-120000 + # Development: disable TLS verification for self-signed certificates + down-force email send-with-confirm --ignore-tls-errors + Configuration: SMTP server settings must be configured before sending. + Use 'down-force config email' for SMTP setup. Security: - Passwords are never logged or displayed - - TLS/SSL encryption is enforced for SMTP connections - - Confirmation prompt prevents accidental sends`, + - TLS/SSL encryption is enforced by default + - Confirmation prompt prevents accidental sends + - Message-ID prevents replay attacks (when enabled)`, Run: runEmailSend, } @@ -154,6 +203,16 @@ func init() { emailCmd.PersistentFlags().StringVar(&emailReportDir, "report", "", "Report directory to use (default: latest report-* directory)") + // Security flags (available to all email subcommands) + emailCmd.PersistentFlags().BoolVar(&emailIgnoreTLSErrors, "ignore-tls-errors", false, + "Disable TLS certificate verification (DEVELOPMENT ONLY - insecure)") + emailCmd.PersistentFlags().BoolVar(&emailAllowUnsafeHTML, "allow-unsafe-html", false, + "Allow unsafe HTML embedding without sanitization (DEVELOPMENT ONLY - XSS risk)") + + // IMAP timeout override flag + emailCmd.PersistentFlags().IntVar(&emailIMAPTimeout, "imap-timeout", 0, + "IMAP connection timeout in seconds (default: uses config value or 30s)") + // Draft subcommand flags emailDraftCmd.Flags().BoolVar(&emailDraftUpload, "upload", false, "Upload draft to IMAP Drafts folder") @@ -170,6 +229,21 @@ func init() { } func runEmailDraft(cmd *cobra.Command, args []string) { + // Load application configuration + cfg := appconfig.GetConfig() + + // Apply security settings (CLI flags override config file) + effectiveIgnoreTLS := emailIgnoreTLSErrors || cfg.Email.Security.IgnoreTLSErrors + effectiveAllowHTML := emailAllowUnsafeHTML || cfg.Email.Security.AllowUnsafeHTML + + // Display security configuration + if effectiveIgnoreTLS { + log.Warn("TLS certificate verification is DISABLED") + } + if effectiveAllowHTML { + log.Warn("Unsafe HTML embedding is ENABLED") + } + // Determine report directory reportDir, err := getReportDirectory(emailReportDir) if err != nil { @@ -178,12 +252,13 @@ func runEmailDraft(cmd *cobra.Command, args []string) { log.Infof("Using report directory: %s", reportDir) - // Determine output mode (default to preview if none specified) + // Determine output mode (default to export + upload if none specified) if !emailDraftUpload && !emailDraftExport && !emailDraftPreview { - emailDraftPreview = true + emailDraftExport = true + emailDraftUpload = true } - // Validate that only one mode is selected + // Validate that only one mode is selected (unless default behavior) modeCount := 0 if emailDraftUpload { modeCount++ @@ -195,35 +270,126 @@ func runEmailDraft(cmd *cobra.Command, args []string) { modeCount++ } - if modeCount > 1 { - log.Fatal("Error: Only one output mode can be specified (--upload, --export, or --preview)") + if modeCount > 2 { + log.Fatal("Error: Only one or two output modes can be specified") + } + + // Load report content + reportContent, err := email.LoadReportContent(reportDir) + if err != nil { + log.Fatalf("Failed to load report content: %v", err) + } + + // Build email message (needed for both export and upload) + includeHTML := !emailDraftPlainText + msg, err := email.BuildEmailMessage(reportContent, includeHTML, emailDraftExport) + if err != nil { + log.Fatalf("Failed to build email message: %v", err) + } + + // Execute based on selected mode(s) + if emailDraftExport { + log.Info("Exporting draft as .eml file...") + log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) + + if err := saveEmlFile(msg, reportDir); err != nil { + log.Errorf("Failed to save .eml file: %v", err) + } else { + log.Info("Draft exported successfully") + } } - // Execute based on selected mode if emailDraftUpload { log.Info("Uploading draft to IMAP Drafts folder...") - // TODO: Implement IMAP upload - log.Warn("IMAP upload not yet implemented") - } else if emailDraftExport { - log.Info("Exporting draft as .eml file...") - // TODO: Implement .eml export - log.Warn(".eml export not yet implemented") - } else if emailDraftPreview { + log.Infof("TLS Error Handling: %v", effectiveIgnoreTLS) + + // Validate IMAP configuration + if cfg.Email.IMAP.URI == "" { + log.Error("IMAP URI is not configured") + log.Info("Please configure IMAP settings using:") + log.Info(" down-force config init") + log.Info("Or set environment variable:") + log.Info(" export DOWNFORCE_EMAIL_IMAP_URI='imap://user:pass@imap.example.com:993'") + return + } + + // Connect to IMAP server (authentication credentials only, no placeholders) + client, err := email.ConnectIMAP(cfg.Email.IMAP, effectiveIgnoreTLS, emailIMAPTimeout) + if err != nil { + log.Errorf("Failed to connect to IMAP server: %v", err) + log.Info("Check your IMAP configuration in ~/.config/down-force/config.yaml") + return + } + defer func() { + if err := client.Close(); err != nil { + log.Warnf("Failed to close IMAP connection: %v", err) + } + }() + + // Find or create Drafts mailbox + draftFolder, err := email.FindOrCreateDraftMailbox(client, cfg.Email.IMAP.DraftFolder) + if err != nil { + log.Errorf("Failed to find or create Drafts mailbox: %v", err) + return + } + + // Upload draft message + if err := email.UploadDraftMessage(client, msg, draftFolder); err != nil { + log.Errorf("Failed to upload draft message: %v", err) + return + } + + log.Info("Draft uploaded successfully to IMAP server") + + // Logout + if err := client.Logout().Wait(); err != nil { + log.Warnf("IMAP logout warning: %v", err) + } + } + + if emailDraftPreview { log.Info("Displaying email preview...") - // TODO: Implement preview display - log.Warn("Preview display not yet implemented") + log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) + displayEmailPreview(string(reportContent.Body)) } if emailDraftPlainText { - log.Info("Generating plain-text only format") + log.Info("Generated plain-text only format") + } +} + +func saveEmlFile(msg *mail.Email, reportDir string) error { + emlPath := filepath.Join(reportDir, "report.eml") + err := os.WriteFile(emlPath, []byte(msg.GetMessage()), 0600) + if err != nil { + return err } + log.Info("Draft saved to: " + emlPath) + return nil +} - fmt.Printf("\nReport directory: %s\n", reportDir) - fmt.Println("Email draft functionality will be implemented here.") +func displayEmailPreview(content string) { + out, err := glamour.Render(content, "dark") + if err != nil { + log.Errorf("Failed to render markdown preview: %v", err) + return + } + fmt.Println(out) } func runEmailSend(cmd *cobra.Command, args []string) { - // Determine report directory + cfg := appconfig.GetConfig() + + effectiveIgnoreTLS := emailIgnoreTLSErrors || cfg.Email.Security.IgnoreTLSErrors + effectiveAllowHTML := emailAllowUnsafeHTML || cfg.Email.Security.AllowUnsafeHTML + + if effectiveIgnoreTLS { + log.Warn("TLS certificate verification is DISABLED") + } + if effectiveAllowHTML { + log.Warn("Unsafe HTML embedding is ENABLED") + } + reportDir, err := getReportDirectory(emailReportDir) if err != nil { log.Fatalf("Failed to determine report directory: %v", err) @@ -237,11 +403,21 @@ func runEmailSend(cmd *cobra.Command, args []string) { log.Info("Message-ID will be generated for tracking") } - // TODO: Implement email send with confirmation - log.Warn("Email send functionality not yet implemented") + includeHTML := cfg.Email.MimeType == "multipart/alternative" + + reportContent, err := email.LoadReportContent(reportDir) + if err != nil { + log.Fatalf("Failed to load report content: %v", err) + } - fmt.Printf("\nReport directory: %s\n", reportDir) - fmt.Println("Email send-with-confirm functionality will be implemented here.") + msg, err := email.BuildEmailMessage(reportContent, includeHTML, false) + if err != nil { + log.Fatalf("Failed to build email message: %v", err) + } + + // TODO: Implement email send with confirmation and security settings + log.Warn("Email send functionality not yet implemented") + _ = msg } // getReportDirectory determines which report directory to use diff --git a/cmd/init.go b/cmd/init.go index f20ea21..27a1d23 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -215,7 +215,7 @@ func runInteractiveForm() (config.TemplateOptions, error) { Value(&opts.Brand). Validate(func(s string) error { if s == "" { - return fmt.Errorf("Brand name is required") + return fmt.Errorf("brand name is required") } return nil }), @@ -227,7 +227,7 @@ func runInteractiveForm() (config.TemplateOptions, error) { Value(&opts.LegitimateDomain). Validate(func(s string) error { if s == "" { - return fmt.Errorf("Legitimate domain is required") + return fmt.Errorf("legitimate domain is required") } return nil }), @@ -255,7 +255,7 @@ func runInteractiveForm() (config.TemplateOptions, error) { Value(&selectedDevices). Validate(func(selected []string) error { if len(selected) == 0 { - return fmt.Errorf("At least one device profile is required") + return fmt.Errorf("at least one device profile is required") } return nil }), diff --git a/cmd/lite.go b/cmd/lite.go index 122c2f2..a8a931d 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -8,12 +8,13 @@ package cmd import ( "bytes" "context" - "encoding/base64" + "encoding/binary" "errors" "fmt" "image" "image/color" "image/draw" + "image/png" "net" "net/netip" "net/url" @@ -98,8 +99,7 @@ func executeLiteCollection(confPath string, isInteractive bool, browserPath stri } if err := performPreflightChecks(cfg.Target.URL, cfg.UserAgents); err != nil { - log.Errorf("❌ Preflight check failed: %v", err) - log.Warn("Continuing anyway as the issue may be transient...") + log.Fatalf("❌ Preflight check failed: %v", err) } accessInfo, err := utils.GetAccessInfo(false) @@ -111,13 +111,23 @@ func executeLiteCollection(confPath string, isInteractive bool, browserPath stri } reportID := generateReportID(cfg.ReportID) - if err := os.MkdirAll(reportID, dirPermissions); err != nil { + reportDir := getReportDir(reportID) + + if err := os.MkdirAll(reportDir, dirPermissions); err != nil { log.Fatalf("Failed to create report directory: %v", err) } - log.Info("Report directory created", "path", reportID) + log.Info("Report directory created", "path", reportDir, "id", reportID) + + // Create evidence snapshot with report_id + snapshotPath := filepath.Join(reportDir, "evidence.snapshot.yaml") + if err := createEvidenceSnapshot(cfg, reportID, snapshotPath); err != nil { + log.Warn("Failed to create evidence snapshot", "error", err) + } else { + log.Info("Created evidence snapshot", "path", snapshotPath) + } - processUserAgents(cfg.Target.URL, cfg.UserAgents, reportID, isInteractive, browserPath, accessInfo) - generateReport(reportID, cfg, accessInfo, cfg.Target.URL) + processUserAgents(cfg.Target.URL, cfg.UserAgents, reportDir, isInteractive, browserPath, accessInfo) + generateReport(reportDir, cfg, accessInfo, cfg.Target.URL, reportID) log.Info("All processing completed") } @@ -136,7 +146,7 @@ func performPreflightChecks(targetURL string, userAgents []config.UserAgentConfi log.Info("Domain status verified", "status", status.String()) if status.ShouldAbort() { - return fmt.Errorf("Critical Domain Status: %s. The domain is likely inactive", status.String()) + return fmt.Errorf("critical domain status: %s. the domain is likely inactive", status.String()) } } } @@ -148,24 +158,58 @@ func performPreflightChecks(targetURL string, userAgents []config.UserAgentConfi } if err := utils.CheckReachability(targetURL, checkUA); err != nil { - return fmt.Errorf("Target URL %s is unreachable. Aborting", targetURL) + return fmt.Errorf("target URL %s is unreachable. aborting", targetURL) } return nil } -// generateReportID creates a unique report ID from config or timestamp. +// generateReportID creates a unique report ID from config or UNIX timestamp. +// Returns Report ID without "report-" prefix. func generateReportID(configReportID string) string { if configReportID != "" { - return configReportID + // Remove "report-" prefix if present (for compatibility) + return strings.TrimPrefix(configReportID, "report-") + } + + // Generate Base58-encoded UNIX timestamp + ts := time.Now().Unix() + tsBytes := make([]byte, 8) + binary.BigEndian.PutUint64(tsBytes, uint64(ts)) + return base58Encode(tsBytes) +} + +// getReportDir creates directory name from Report ID +func getReportDir(reportID string) string { + return fmt.Sprintf("report-%s", reportID) +} + +// base58Encode encodes bytes to Base58 string +func base58Encode(data []byte) string { + // Simple Base58 encoding using Bitcoin alphabet + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + // Convert to big integer + var num uint64 + for _, b := range data { + num = num*256 + uint64(b) + } + + if num == 0 { + return string(alphabet[0]) + } + + // Encode + var encoded []byte + for num > 0 { + encoded = append([]byte{alphabet[num%58]}, encoded...) + num /= 58 } - ts := time.Now().Format("20060102-150405") - encoded := base64.RawURLEncoding.EncodeToString([]byte(ts)) - return fmt.Sprintf("report-%s", encoded) + return string(encoded) } -func processUserAgents(targetURL string, userAgents []config.UserAgentConfig, reportID string, isInteractive bool, browserPath string, accessInfo *utils.AccessInfo) { +func processUserAgents(targetURL string, userAgents []config.UserAgentConfig, reportDir string, isInteractive bool, browserPath string, accessInfo *utils.AccessInfo) { log.Info("Starting processing", "target", targetURL, "interactive", isInteractive) var successCount int @@ -174,7 +218,7 @@ func processUserAgents(targetURL string, userAgents []config.UserAgentConfig, re for _, ua := range userAgents { log.Info("Processing User Agent", "name", ua.Name) - uaDir := filepath.Join(reportID, ua.Name) + uaDir := filepath.Join(reportDir, ua.Name) if err := os.MkdirAll(uaDir, dirPermissions); err != nil { log.Errorf("Failed to create UA directory %s: %v", uaDir, err) failureCount++ @@ -293,7 +337,9 @@ func initializeBrowser(browserPath string, headless bool) (*rod.Browser, *rod.Pa page, err := stealth.Page(browser) if err != nil { - browser.Close() + if closeErr := browser.Close(); closeErr != nil { + log.Warnf("failed to close browser during cleanup: %v", closeErr) + } return nil, nil, fmt.Errorf("failed to create stealth page: %w", err) } @@ -313,7 +359,7 @@ func navigateAndSetup(page *rod.Page, targetURL string, ua config.UserAgentConfi } // Double check user agent if it was overridden in config but not in device profile - // (ToDeviceEmulation should handle this, but explicit check ensures config precedence if needed, + // (ToDeviceEmulation should handle this, but explicit check ensures config precedence if needed, // though ToDeviceEmulation implementation already prefers UA string from config) log.Info("Waiting for page load...") @@ -419,10 +465,25 @@ func saveScreenshot(data []byte, targetURL string, userAgent string, outputDir s if err != nil { return fmt.Errorf("failed to add header: %w", err) } + } outputPath := filepath.Join(outputDir, "screenshot.png") - if err := imaging.Save(finalImage, outputPath); err != nil { + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + defer func() { + if cerr := file.Close(); cerr != nil && err == nil { + err = cerr + } + }() + + encoder := &png.Encoder{ + CompressionLevel: png.BestCompression, + } + if err := encoder.Encode(file, finalImage); err != nil { return fmt.Errorf("failed to save image: %w", err) } @@ -577,13 +638,13 @@ func truncateString(s string, maxLen int) string { } // generateReport creates the final markdown report. -func generateReport(reportID string, cfg *config.EvidenceConfig, accessInfo *utils.AccessInfo, targetURL string) { +func generateReport(reportDir string, cfg *config.EvidenceConfig, accessInfo *utils.AccessInfo, targetURL string, reportID string) { log.Info("Generating report...") targetIPv4, targetIPv6, targetGeo := resolveTargetInfo(targetURL) var screenshots []string - if err := filepath.Walk(reportID, func(path string, info os.FileInfo, err error) error { + if err := filepath.Walk(reportDir, func(path string, info os.FileInfo, err error) error { if err != nil { log.Warn("Error walking directory", "path", path, "error", err) return nil // Continue walking despite errors @@ -596,11 +657,11 @@ func generateReport(reportID string, cfg *config.EvidenceConfig, accessInfo *uti log.Warn("Failed to scan screenshots", "error", err) } - if err := report.Generate(reportID, cfg, accessInfo, targetIPv4, targetIPv6, targetGeo, screenshots); err != nil { + if err := report.Generate(reportDir, reportID, cfg, accessInfo, targetIPv4, targetIPv6, targetGeo, screenshots); err != nil { log.Errorf("Failed to generate report: %v", err) log.Warn("Evidence collection completed but report generation failed") } else { - log.Info("Report generated successfully", "path", filepath.Join(reportID, "Report.md")) + log.Info("Report generated successfully", "path", filepath.Join(reportDir, "Report.md")) } } @@ -615,7 +676,7 @@ func isPublicIP(addr netip.Addr) bool { } // IPv4: check for reserved ranges if addr.Is4() { - return addr.IsUnspecified() == false + return !addr.IsUnspecified() } // IPv6: check for global unicast if addr.Is6() { @@ -769,3 +830,17 @@ func init() { liteCmd.Flags().IntVar(&idleTimeout, "idle-timeout", -1, "Idle timeout in seconds (overrides config)") liteCmd.Flags().IntVar(&overallTimeout, "timeout", -1, "Overall page load timeout in seconds (overrides config)") } + +// createEvidenceSnapshot creates a snapshot of the evidence config with report_id included +func createEvidenceSnapshot(cfg *config.EvidenceConfig, reportID string, snapshotPath string) error { + // Create a copy of the config to modify + snapshotCfg := *cfg + + // If report_id is not set in the original config, add the generated one + if snapshotCfg.ReportID == "" { + snapshotCfg.ReportID = reportID + } + + // Save the snapshot config with report_id + return config.SaveConfig(snapshotPath, &snapshotCfg) +} diff --git a/cmd/root.go b/cmd/root.go index f127345..5f82ce6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( var ( cfgFile string + debug bool ) var rootCmd = &cobra.Command{ @@ -18,6 +19,12 @@ var rootCmd = &cobra.Command{ Long: `down-force is a CLI tool designed to automate the process of collecting evidence of phishing attacks.`, Version: version, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Set log level based on debug flag + if debug { + log.SetLevel(log.DebugLevel) + log.Debug("Debug mode enabled") + } + configCommands := map[string]bool{ "config": true, "config init": true, @@ -46,4 +53,6 @@ func init() { rootCmd.SetVersionTemplate(`{{printf "%s %s\n" .Name .Version}}`) rootCmd.PersistentFlags().StringVar(&cfgFile, "config-file", "", "Application config file path (default: ~/.config/down-force/config.yaml)") + rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Enable debug/verbose output") + rootCmd.PersistentFlags().BoolVar(&debug, "verbose", false, "Enable debug/verbose output (alias for --debug)") } diff --git a/doc/Summary-temolate.md b/doc/Summary-temolate.md index 7cba429..0e2d669 100644 --- a/doc/Summary-temolate.md +++ b/doc/Summary-temolate.md @@ -1,8 +1,9 @@ # Phishing Report -Report ID: ${REPORT_ID} -Created At: ${GENERATED_AT_UTC} + +Report ID: ${REPORT_ID} Created At: ${GENERATED_AT_UTC} ## Target Information + - Impersonated Brand: ${BRAND} - Legitimate Domain: ${LEGITIMATE_DOMAIN} - Legitimate URL: ${LEGITIMATE_URL} @@ -13,18 +14,21 @@ Created At: ${GENERATED_AT_UTC} - IPv6: - ${DOMAIN_IPV6} - Phishing Site Infrastructure & Redirect Chain -- 1. URL: ${EACH_URL} - - DNS Resolution - - A: ${DNS_A_RECORDS} - - AAAA: ${DNS_AAAA_RECORDS} - - HTTPS: ${DNS_HTTPS_RECORDS_IF_AVAILABLE} - - SVCB: ${DNS_SVCB_RECORDS_IF_AVAILABLE} - - ASN: ${ASN} - - ISP: ${ISP} - - Hosting Provider: ${HOSTING_PROVIDER} -- 2. ... +- + 1. URL: ${EACH_URL} + - DNS Resolution + - A: ${DNS_A_RECORDS} + - AAAA: ${DNS_AAAA_RECORDS} + - HTTPS: ${DNS_HTTPS_RECORDS_IF_AVAILABLE} + - SVCB: ${DNS_SVCB_RECORDS_IF_AVAILABLE} + - ASN: ${ASN} + - ISP: ${ISP} + - Hosting Provider: ${HOSTING_PROVIDER} +- + 2. ... ## Access & Testing Details + - Timestamp: ${ACCESS_TIMESTAMP} - Access From: ${ACCESS_IPV4} / ${ACCESS_IPV6} - Location: ${ACCESS_LOCATION} @@ -41,11 +45,10 @@ Created At: ${GENERATED_AT_UTC} - Evidence - Screenshots: ${SCREENSHOT_COUNT} files captured - HAR Files: ${HAR_COUNT} network traces available - - Initial HTML: ${HTML_COUNT} source files preserved + - Initial HTML: ${HTML_COUNT} source files preserved ## Information Sharing & Consent -Third-party sharing: Agree -Identity disclosure (third parties): Agree -Site owner notification: Agree -Identity disclosure (site owner): ${CONSENT_IDENTITY_SITE_OWNER} +Third-party sharing: Agree Identity disclosure (third parties): Agree Site owner +notification: Agree Identity disclosure (site owner): +${CONSENT_IDENTITY_SITE_OWNER} diff --git a/doc/app-behavior-config.md b/doc/app-behavior-config.md index ccdd625..4f83ec4 100644 --- a/doc/app-behavior-config.md +++ b/doc/app-behavior-config.md @@ -73,21 +73,50 @@ reporter: # 同意・開示設定 (Report.md の法的文言/同意フラグ等に反映) consent: disclosure: true # XARF: Disclosure (情報公開の可否) - share_with_third_parties: true # 第三者(ホスティング会社等)への共有に同意するか + share_with_third_parties: true # 第三者(ホスティング会社等)への共有に同意するか identity_to_third_parties: false # 第三者への「報告者身元」の開示に同意するか share_with_site_owner: false # 侵害サイト所有者への通知に同意するか identity_to_site_owner: false # 侵害サイト所有者への「報告者身元」の開示に同意するか email: - imap: - uri: "imap://user@example.com:password@imap.example.com:993" + # 送信元・返信先メールアドレス(プレースホルダ対応: {report_id} のみ) + # レポートIDはBase58エンコードされたUNIXタイムスタンプ(例: MjAyNjAxMDgtMTc0NTQ0) + from: "abuse+{report_id}@example.com" # 送信元メールアドレス + reply_to: "" # 返信先アドレス(未指定の場合はReply-Toヘッダーをセットしない) + + imap: + uri: "imap://user%40example.com:password@imap.example.com:993" # 認証情報のみ(プレースホルダ不可、@は%40にエンコード) secure: "STARTTLS" # "None", "SSL", "STARTTLS" draft_folder: "INBOX.Drafts" # "Auto": RFC 6154 IMAP SPECIAL-USE でセットする。手動も可能 + timeout: 30 # 秒 (IMAP接続タイムアウト、CLIフラグで上書き可能) smtp: - uri: "smtp://user@example.com:password@smtp.example.com:587" - secure: "STARTTLS" # "None", "SSL", "STARTTLS" + uri: "smtp://user%40example.com:password@smtp.example.com:587" # 認証情報のみ(プレースホルダ不可、@は%40にエンコード) + secure: "STARTTLS" # "None", "SSL", "STARTTLS" mime-type: "text/plain" # "text/plain", "multipart/alternative" (HTMLへの変換は自動的に行います。) + + # セキュリティオプション (開発環境専用、詳細は下記セクション参照) + security: + ignore_tls_errors: false # TLS証明書検証をスキップ + allow_unsafe_html: false # HTMLサニタイゼーションを無効化 ``` +### Email セキュリティオプション + +次の機能は、セキュリティの一部を意図的に無効化するものです。 +その危険性を理解している場合のみ有効にしてください。 + +#### `email.security.ignore_tls_errors` + +SMTP/IMAP接続時のTLS/SSL証明書検証をスキップします。 +自己署名証明書、証明書が期限切れのテスト用途としての場合に使用します。 + +#### `email.security.allow_unsafe_html` + +Markdownメールに埋め込むHTMLコンテンツのサニタイゼーションを無効化します。 +この機能を無効化する場合には、細心の注意を払って送信するメールペイロードを確認してください。 +悪意あるコンテンツは、配信された場合あなただけでなく他の第三者にも被害を及ぼす可能性があるほか、 +あなたのドメインやメールアドレス、IPアドレスの評判が損なわれる可能性もあります。 + +```` ## 2. 環境変数マッピング CI/CDパイプラインやコンテナ環境での利用を想定し、すべての設定は環境変数で定義可能です。 @@ -128,6 +157,40 @@ CI/CDパイプラインやコンテナ環境での利用を想定し、すべて | `reporter.consent.share_with_third_parties` | `DOWNFORCE_CONSENT_SHARE_THIRD` | 第三者共有の同意 (true/false) | | `reporter.consent.identity_to_third_parties` | `DOWNFORCE_CONSENT_ID_THIRD` | 第三者への身元開示 (true/false) | +### Email セキュリティ + +| YAMLキー | 環境変数 | デフォルト | 説明 | +| ---------------------------------- | -------------------------------------------- | -------------- | ------------------------------------------ | +| `email.from` | `DOWNFORCE_EMAIL_FROM` | - | 送信元メールアドレス(プレースホルダ対応) | +| `email.reply_to` | `DOWNFORCE_EMAIL_REPLY_TO` | - | 返信先メールアドレス(プレースホルダ対応) | +| `email.imap.uri` | `DOWNFORCE_EMAIL_IMAP_URI` | - | IMAP接続URI(認証情報のみ) | +| `email.imap.secure` | `DOWNFORCE_EMAIL_IMAP_SECURE` | - | IMAP暗号化方式 | +| `email.imap.draft_folder` | `DOWNFORCE_EMAIL_IMAP_DRAFT_FOLDER` | `INBOX.Drafts` | IMAP下書きフォルダ | +| `email.imap.timeout` | `DOWNFORCE_EMAIL_IMAP_TIMEOUT` | `30` | IMAP接続タイムアウト(秒) | +| `email.smtp.uri` | `DOWNFORCE_EMAIL_SMTP_URI` | - | SMTP接続URI(認証情報のみ) | +| `email.smtp.secure` | `DOWNFORCE_EMAIL_SMTP_SECURE` | - | SMTP暗号化方式 | +| `email.security.ignore_tls_errors` | `DOWNFORCE_EMAIL_SECURITY_IGNORE_TLS_ERRORS` | `false` | TLS証明書検証をスキップ | +| `email.security.allow_unsafe_html` | `DOWNFORCE_EMAIL_SECURITY_ALLOW_UNSAFE_HTML` | `false` | HTMLサニタイゼーションを無効化 | + +#### メールアドレスのプレースホルダ + +`email.from` および `email.reply_to` フィールドでは、以下のプレースホルダが利用可能です(大文字小文字を区別しません): + +- `{report_id}`: レポートID全体(例: `report-20240101-120000`) +- `{timestamp}`: タイムスタンプ部分(例: `20240101-120000`) +- `{date}`: 日付部分(例: `20240101`) +- `{time}`: 時刻部分(例: `120000`) + +使用例: +```yaml +email: + from: "abuse+{report_id}@example.com" # → abuse+report-20240101-120000@example.com + reply_to: "reply-{timestamp}@example.com" # → reply-20240101-120000@example.com +```` + +**重要**: IMAP/SMTP +URIの認証情報(ユーザー名・パスワード)にはプレースホルダを使用できません。サーバー認証には固定値のみが使用されます。 + ## 3. 参照 - ターゲット設定: `evidence.yaml` (コマンドライン引数 `-c` で指定) diff --git a/doc/email-imap.md b/doc/email-imap.md new file mode 100644 index 0000000..46aafdd --- /dev/null +++ b/doc/email-imap.md @@ -0,0 +1,399 @@ +# IMAP下書き保存機能 + +`down-force`は、証拠収集レポートから生成したメールをIMAPサーバーの下書きフォルダに保存する機能をサポートしています。 + +## 概要 + +`email draft`コマンドは以下の動作をサポートします: + +- `.eml`ファイルとしてのエクスポート +- IMAPサーバーへの下書き保存 +- ターミナルでのプレビュー表示 + +**デフォルト動作**: +フラグを指定しない場合、`.eml`ファイルのエクスポートとIMAP保存の両方を実行します。 + +以下のような説明が適切だと思います。 +思想が伝わり、かつ冗長にならない形にしています。 + +--- + +## なぜ IMAP 下書き保存機能があるのか + +`down-force` は、証拠収集レポートを **即座に送信することが常に最適とは限らない** 、という前提で設計されています。 + +IMAP 下書き保存機能は、生成したメールを **送信せずに下書きとして保存** することで、次の課題を解決します。 + +- **最終確認を CUI 以外で行える** + CLI 上の表示だけでなく、普段使い慣れたメールクライアントで + 内容・添付・表示・宛先を目視確認できます。 + また、第三者によるレビューや役割分担も可能です。 + +- **誤送信リスクを下げる** + CLI では一度の実行が即送信につながるため、心理的・運用的な負担があります。 + 下書き保存にすることで「送信」という行為に時間を空けることができます。 + +- **配信制限を回避しやすい** + 下書き作成は多くの場合制限がなく、 + 後から手動で一斉送信することで API のレート制限を受けにくくなります。 + +この機能は、 **安全性・確認性・運用現実性を重視した送信フロー** を実現するためのものです。 +直接送信する SMTP や API 経由の配信に 並ぶ有効な選択肢となります。 + +## IMAP URI形式 + +IMAP接続には、HTTPSと同様の非公式標準URI形式を使用します: + +``` +imap://username:password@host:port +``` + +**注意**: +ユーザー名やパスワードに`@`などの特殊文字が含まれる場合は、パーセントエンコーディングが必要です。 + +### パーセントエンコーディング + +RFC 2396により、URIの予約文字(reserved +characters)は、文字列の一部として使用する場合にエンコードが必要です。 + +**特に注意が必要な文字**: + +- `@` → `%40` +- `:` → `%3A` +- `/` → `%2F` +- `?` → `%3F` +- `#` → `%23` +- `[` → `%5B` +- `]` → `%5D` + +**例**: ユーザー名が`user@example.com`の場合 + +``` +# 誤り(@が2つあり、どちらがユーザー情報とホストの区切りか判別不可) +imap://user@example.com:password@imap.example.com:993 + +# 正しい(ユーザー名の@を%40にエンコード) +imap://user%40example.com:password@imap.example.com:993 +``` + +### URI形式の例 + +#### Gmail + +``` +# ユーザー名の@を%40にエンコード +imap://user%40gmail.com:app-password@imap.gmail.com:993 +``` + +**注意**: Gmailを使用する場合は、アプリパスワードが必要です。 + +1. Googleアカウントの2段階認証を有効化 +2. [アプリパスワード](https://myaccount.google.com/apppasswords)を生成 +3. 生成されたパスワードをURIに使用(16文字のアプリパスワードはそのまま使用可能) +4. [高度な保護オプション](https://landing.google.com/advancedprotection/) を利用しているユーザまたは組織ではご利用いただけません。 + +#### Outlook / Microsoft 365 + +``` +# ユーザー名の@を%40にエンコード + +imap://user%40outlook.com:password@outlook.office365.com:993 +``` + +#### 自己ホストDovecot + +``` +imap://admin:secretpass@mail.example.com:993 +``` + +## URI形式検証 + +`down-force`は以下のURI形式の基本的な検証のみを実施します: + +- **スキーマ**: `imap://`または`imaps://`であること + +- **ホスト名**: ホスト名が存在すること +- **ポート**: ポート番号が数値であること(デフォルト: 993) +- **認証情報**: ユーザー名とパスワードが含まれていること + +**整合性チェックは実施しません**: + +- `secure: None`設定時にパスワードが設定されている +- ポート番号とセキュリティ設定の不整合(例: ポート143でSSL指定) + +これらの設定ミスがあっても、ツールは直ちにIMAP認証を試み、サーバーからのエラー応答をそのまま表示します。 + +## セキュリティ設定 + +### `secure`設定 + +IMAP接続の暗号化方法を指定します: + +| 値 | 説明 | 推奨ポート | +| ---------- | --------------------------------- | ---------- | +| `SSL` | 接続開始時からTLS暗号化(IMAPS) | 993 | +| `STARTTLS` | 接続後にSTARTTLSコマンドでTLS開始 | 143 | +| `None` | 暗号化なし(**非推奨**) | 143 | + +**推奨**: 本番環境では`STARTTLS`/`SSL`(ポート993)を使用してください。 + +### `--ignore-tls-errors`フラグ + +**警告**: 中間者攻撃のリスクがあります。 + +```bash +# 開発環境での使用例 +down-force email draft --upload --ignore-tls-errors +``` + +## `\Drafts`メールボックスの自動検出 + +### RFC 6154 SPECIAL-USE属性 + +`down-force`は、RFC +6154で定義されたSPECIAL-USE属性を使用して`\Drafts`メールボックスを自動検出します: + +1. `LIST`コマンドで全メールボックスをSPECIAL-USE属性付きで取得 +2. `\Drafts`属性を持つメールボックスを検索 +3. 見つかった場合、そのメールボックスに下書きを保存 + +### フォールバック動作 + +`\Drafts`属性のメールボックスが存在しない場合: + +1. `draft_folder`設定で指定されたメールボックスが存在するか確認 +2. 存在する場合はそのメールボックスを使用 +3. どちらも存在しない場合、`Drafts-down-force`メールボックスを新規作成 + +**作成されるメールボックス名**: `Drafts-down-force` + +``` +INBOX +├── Sent +├── Trash +└── Drafts-down-force ← 自動作成 +``` + +## タイムアウト設定 + +### デフォルトタイムアウト + +IMAP接続のデフォルトタイムアウトは**30秒**です。これは`network.timeout`設定と同じ値で、`internal/network/reachability.go`のタイムアウト値を再利用しています。 + +### 設定ファイルでのオーバーライド + +`~/.config/down-force/config.yaml`: + +```yaml +email: + imap: + uri: "imap://user@example.com:password@imap.example.com:993" + secure: "SSL" + draft_folder: "INBOX.Drafts" + timeout: 60 # 秒単位(デフォルト: 30) +``` + +### 環境変数でのオーバーライド + +```bash +export DOWNFORCE_EMAIL_IMAP_TIMEOUT=60 +``` + +### CLIフラグでのオーバーライド(最優先) + +```bash +down-force email draft --upload --imap-timeout 120 +``` + +**優先順位**: CLIフラグ > 環境変数 > 設定ファイル > デフォルト(30秒) + +## 設定例 + +### 最小設定 + +`~/.config/down-force/config.yaml`: + +```yaml +email: + from: "abuse@example.com" # プレースホルダ対応: abuse+{report_id}@example.com + imap: + uri: "imap://user%40example.com:password@imap.example.com:993" # @を%40にエンコード + secure: "SSL" +``` + +### 完全な設定 + +```yaml +email: + from: "abuse+{report_id}@example.com" # プレースホルダでレポートIDを埋め込み + reply_to: "" # 未指定の場合はReply-Toヘッダーをセットしない + + imap: + uri: "imap://user%40example.com:password@imap.example.com:993" # @を%40にエンコード + secure: "SSL" + draft_folder: "INBOX.Drafts" + timeout: 30 + smtp: + uri: "smtp://user%40example.com:password@smtp.example.com:587" # @を%40にエンコード + secure: "STARTTLS" + mime_ype: "multipart/alternative" + + security: + ignore_tls_errors: false + allow_unsafe_html: false +``` + +**重要**: + +- IMAP/SMTP + URIの認証情報(ユーザー名・パスワード)では**プレースホルダは使用できません** +- プレースホルダは`email.from`および`email.reply_to`フィールドでのみ使用可能です +- レポートごとに異なるメールアドレスを使用したい場合は、`email.from`に`abuse+{report_id}@example.com`などを設定してください + +## 使用例 + +### 基本的な使用方法 + +```bash +# デフォルト: .emlファイル保存 + IMAP保存 +down-force email draft + +# .emlファイルのみ保存 +down-force email draft --export + +# IMAP保存のみ +down-force email draft --upload + +# プレビュー表示のみ +down-force email draft --preview +``` + +### 開発環境での使用 + +```bash +# 自己署名証明書を使用する開発IMAPサーバー +down-force email draft --upload --ignore-tls-errors + +# タイムアウトを120秒に延長 +down-force email draft --upload --imap-timeout 120 +``` + +### 特定のレポートを指定 + +```bash +# report-20240101-120000ディレクトリのレポートを使用 +down-force email draft --upload --report report-20240101-120000 +``` + +## トラブルシューティング + +### 認証エラー + +``` +IMAP authentication failed: authentication failed +``` + +**解決方法**: + +- ユーザー名とパスワードを確認 +- Gmailの場合、アプリパスワードを使用しているか確認 +- 2段階認証が有効になっているか確認 + +### 接続タイムアウト + +``` +Failed to connect to IMAP server: dial tcp: i/o timeout +``` + +**解決方法**: + +- ホスト名とポート番号を確認 +- ファイアウォールでポート993が開放されているか確認 + +- `--imap-timeout`フラグでタイムアウトを延長 + +### TLS証明書エラー + +``` +Failed to connect to IMAP server: x509: certificate signed by unknown authority +``` + +**解決方法**: + +- 開発環境の場合: `--ignore-tls-errors`フラグを使用 +- 本番環境の場合: 証明書の有効性を確認し、正しいCA証明書をインストール + +### メールボックスが見つからない + +``` +No Drafts mailbox found, creating fallback: Drafts-down-force +``` + +**説明**: +これは通常の動作です。`Drafts-down-force`メールボックスが自動作成され、次回以降はこのメールボックスが使用されます。 + +## 設定の初期化 + +プレースホルダ機能 + +**重要**: +プレースホルダは`email.from`と`email.reply_to`フィールドでのみ使用可能です。IMAP/SMTP +URIの認証情報では使用できません。 + +### 利用可能なプレースホルダ + +- `{report_id}` - レポートディレクトリ名全体(例: `report-20240101-120000`) +- `{timestamp}` - タイムスタンプ部分(例: `20240101-120000`) +- `{date}` - 日付部分(例: `20240101`) +- `{time}` - 時刻部分(例: `120000`) + +プレースホルダは大文字小文字を区別しません(`{REPORT_ID}`も使用可能)。 + +### 使用例 + +```yaml +email: + from: "abuse+{report_id}@example.com" + # レポート report-20240101-120000 の場合 + # → abuse+report-20240101-120000@example.com + + reply_to: "noreply-{date}@example.com" + # → noreply-20240101@example.com +``` + +**安全性**: + +- プレースホルダ展開後、メールアドレスの妥当性を自動検証(RFC 5322準拠) + +- 未解決のプレースホルダがある場合はエラー +- 不正な文字を含む場合はメール送信前にエラー + +詳細は[app-behavior-config.md](app-behavior-config.md)を参照してください。 + +## 環境変数マッピング + +| YAML設定キー | 環境変数 | デフォルト値 | +| ---------------------- | -------------------------- | ------------ | +| `email.from` | `DOWNFORCE_EMAIL_FROM` | - | +| `email.reply_to` | `DOWNFORCE_EMAIL_REPLY_TO` | - | +| down-force config init | | | + +``` +対話的なプロンプトに従って、IMAP/SMTP設定を入力してください。 + +## 環境変数マッピング + +| YAML設定キー | 環境変数 | デフォルト値 | +| ---------------------------------- | -------------------------------------------- | -------------- | +| `email.imap.uri` | `DOWNFORCE_EMAIL_IMAP_URI` | - | +| `email.imap.secure` | `DOWNFORCE_EMAIL_IMAP_SECURE` | - | +| `email.imap.draft_folder` | `DOWNFORCE_EMAIL_IMAP_DRAFT_FOLDER` | `INBOX.Drafts` | +| `email.imap.timeout` | `DOWNFORCE_EMAIL_IMAP_TIMEOUT` | `30` | +| `email.security.ignore_tls_errors` | `DOWNFORCE_EMAIL_SECURITY_IGNORE_TLS_ERRORS` | `false` | + +## 参考資料 + +- [RFC 3501: IMAP4rev1](https://datatracker.ietf.org/doc/html/rfc3501) +- [RFC 6154: IMAP LIST Extension for Special-Use Mailboxes](https://datatracker.ietf.org/doc/html/rfc6154) +- [go-imap/v2 Documentation](https://pkg.go.dev/github.com/emersion/go-imap/v2) +``` diff --git a/go.mod b/go.mod index d04041d..fa95132 100644 --- a/go.mod +++ b/go.mod @@ -19,44 +19,62 @@ require ( github.com/openrdap/rdap v0.9.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/tantalor93/doh-go v0.3.0 + github.com/xhit/go-simple-mail/v2 v2.16.0 + github.com/yuin/goldmark v1.7.14 golang.org/x/net v0.47.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/alecthomas/kingpin/v2 v2.3.2 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/btcsuite/btcutil v1.0.2 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/glamour v0.10.0 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emersion/go-imap/v2 v2.0.0-beta.7 // indirect + github.com/emersion/go-message v0.18.1 // indirect + github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/structs v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-test/deep v1.1.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/likexian/gokit v0.25.15 // indirect github.com/mattn/go-localereader v0.0.1 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/sync v0.18.0 // indirect + golang.org/x/term v0.37.0 // indirect golang.org/x/tools v0.38.0 // indirect ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect diff --git a/go.sum b/go.sum index ae779af..efb61b4 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/kingpin/v2 v2.3.2 h1:H0aULhgmSzN8xQ3nX1uxtdlTHYoPLu5AhHxWrKI6ocU= github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= @@ -10,6 +13,18 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts= +github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= @@ -18,10 +33,14 @@ github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGs github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= @@ -34,6 +53,8 @@ github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9 github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= @@ -45,17 +66,27 @@ github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJn github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emersion/go-imap/v2 v2.0.0-beta.7 h1:lNznYWa5uhMrngnSYEklzCeye4DBq9TEJ+pr0K593+8= +github.com/emersion/go-imap/v2 v2.0.0-beta.7/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk= +github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E= +github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -65,14 +96,23 @@ github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4= github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= @@ -103,8 +143,11 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -119,12 +162,18 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/openrdap/rdap v0.9.1 h1:Rv6YbanbiVPsKRvOLdUmlU1AL5+2OFuEFLjFN+mQsCM= github.com/openrdap/rdap v0.9.1/go.mod h1:vKSiotbsENrjM/vaHXLddXbW8iQkBfa+ldEuYEjyLTQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -142,6 +191,10 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tantalor93/doh-go v0.3.0 h1:Hy7CRfrpUeqhAt/XGSWr3L4Wro+lmbvNH7476Lx2rDA= github.com/tantalor93/doh-go v0.3.0/go.mod h1:1uDDy9iGTVHKEofhXUz9pTvCo7QAPRNys7pxkdLbFuM= +github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM= +github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= +github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA= +github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -164,9 +217,16 @@ github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.14 h1:9F3UqVQdZ5GG5y6TU0l1TbbDhZmqfevaOcinQt88Qi8= +github.com/yuin/goldmark v1.7.14/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= @@ -179,18 +239,23 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -203,11 +268,14 @@ golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -220,6 +288,9 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go index 69219d3..3d21b82 100644 --- a/internal/appconfig/appconfig.go +++ b/internal/appconfig/appconfig.go @@ -1,3 +1,6 @@ +/* +Copyright © 2025 canaria-computer +*/ package appconfig import ( @@ -88,15 +91,19 @@ type ConsentConfig struct { } type EmailConfig struct { - IMAP IMAPConfig `koanf:"imap"` - SMTP SMTPConfig `koanf:"smtp"` - MimeType string `koanf:"mime-type"` + From string `koanf:"from"` // From email address (supports placeholders: {report_id}, {timestamp}, {date}, {time}) + ReplyTo string `koanf:"reply_to"` // Reply-To email address (supports placeholders) + IMAP IMAPConfig `koanf:"imap"` + SMTP SMTPConfig `koanf:"smtp"` + MimeType string `koanf:"mime_type"` + Security SecurityConfig `koanf:"security"` } type IMAPConfig struct { URI string `koanf:"uri"` Secure string `koanf:"secure"` DraftFolder string `koanf:"draft_folder"` + Timeout int `koanf:"timeout"` // Connection timeout in seconds (default: 30) } type SMTPConfig struct { @@ -104,6 +111,21 @@ type SMTPConfig struct { Secure string `koanf:"secure"` } +// SecurityConfig contains security-related options for email operations +type SecurityConfig struct { + // IgnoreTLSErrors allows SMTP/IMAP connections to proceed even if TLS verification fails. + // WARNING: This disables certificate validation and should only be used for testing with + // self-signed certificates or in development environments. Do NOT use in production. + // Default: false (TLS verification is enforced) + IgnoreTLSErrors bool `koanf:"ignore_tls_errors"` + + // AllowUnsafeHTML permits the embedding of potentially unsafe HTML in Markdown-generated emails. + // WARNING: When enabled, user-supplied or untrusted HTML content may be embedded without sanitization, + // potentially leading to XSS attacks or malicious content injection. Only enable if you trust the source + // of the HTML content. Default: false (unsafe HTML is sanitized/escaped) + AllowUnsafeHTML bool `koanf:"allow_unsafe_html"` +} + var globalConfig *AppConfig var globalKoanf *koanf.Koanf @@ -142,11 +164,16 @@ func DefaultConfig() *AppConfig { Email: EmailConfig{ IMAP: IMAPConfig{ DraftFolder: "INBOX.Drafts", // Kept as requested + Timeout: 30, // Default timeout: 30 seconds }, SMTP: SMTPConfig{ // Defaults removed }, MimeType: "text/plain", + Security: SecurityConfig{ + IgnoreTLSErrors: false, // TLS verification enforced by default + AllowUnsafeHTML: false, // HTML sanitization enforced by default + }, }, } } @@ -185,23 +212,23 @@ func LoadConfig(configPath string) (*AppConfig, error) { Prefix: EnvPrefix, TransformFunc: func(key, value string) (string, interface{}) { key = strings.ToLower(strings.TrimPrefix(key, EnvPrefix)) - key = strings.Replace(key, "_", ".", -1) - + key = strings.ReplaceAll(key, "_", ".") + aliasMap := map[string]string{ - "reporter.org": "reporter.organization.name", - "reporter.org.domain": "reporter.organization.domain", - "reporter.org.email": "reporter.organization.email", - "reporter.email": "reporter.contact.email", - "reporter.name": "reporter.contact.name", - "reporter.phone": "reporter.contact.phone", - "downforce.proxy.url": "network.proxy.url", - "downforce.output.dir": "output.default.directory", + "reporter.org": "reporter.organization.name", + "reporter.org.domain": "reporter.organization.domain", + "reporter.org.email": "reporter.organization.email", + "reporter.email": "reporter.contact.email", + "reporter.name": "reporter.contact.name", + "reporter.phone": "reporter.contact.phone", + "downforce.proxy.url": "network.proxy.url", + "downforce.output.dir": "output.default.directory", } - + if mappedKey, exists := aliasMap[key]; exists { key = mappedKey } - + return key, value }, }) @@ -267,6 +294,14 @@ func ValidateConfig(cfg *AppConfig) []error { errs = append(errs, fmt.Errorf("email.mime-type must be one of: text/plain, multipart/alternative")) } + // Warn about security options in production + if cfg.Email.Security.IgnoreTLSErrors { + errs = append(errs, fmt.Errorf("SECURITY WARNING: email.security.ignore_tls_errors is enabled - TLS verification is disabled")) + } + if cfg.Email.Security.AllowUnsafeHTML { + errs = append(errs, fmt.Errorf("SECURITY WARNING: email.security.allow_unsafe_html is enabled - unsafe HTML will be embedded without sanitization (untrusted content at risk)")) + } + return errs } @@ -275,7 +310,7 @@ func GetConfigSource(key string) string { return "default" } - envKey := EnvPrefix + strings.ToUpper(strings.Replace(key, ".", "_", -1)) + envKey := EnvPrefix + strings.ToUpper(strings.ReplaceAll(key, ".", "_")) if os.Getenv(envKey) != "" { return "env" } diff --git a/internal/appconfig/config.template.yaml b/internal/appconfig/config.template.yaml index fa330ec..66a9a64 100644 --- a/internal/appconfig/config.template.yaml +++ b/internal/appconfig/config.template.yaml @@ -42,15 +42,15 @@ output: reporter: # 組織情報 (XARF: ReporterInfo) organization: - name: "" # 例: "Your Organization" + name: "" # 例: "Your Organization" domain: "" # 例: "example.com" - email: "" # 例: "abuse-report@example.com" + email: "" # 例: "abuse-report@example.com" # 連絡先担当者 (XARF: ReporterContact) contact: - name: "" # 例: "Incident Responder" - email: "" # 例: "contact@example.com" - phone: "" # 例: "+1 234 567 8900" + name: "" # 例: "Incident Responder" + email: "" # 例: "contact@example.com" + phone: "" # 例: "+1 234 567 8900" # 同意・開示設定 (Report.md の法的文言/同意フラグ等に反映) # これらはデフォルトですべてfalseです。通知や共有を行う場合はtrueに設定してください。 @@ -63,11 +63,21 @@ reporter: # メール設定 email: + # 送信元・返信先メールアドレス(プレースホルダ対応: {report_id}, {timestamp}, {date}, {time}) + from: "" # 例: "abuse+{report_id}@example.com" または "report-{timestamp}@example.com" + reply_to: "" # 返信先アドレス(未指定の場合はReply-Toヘッダーをセットしない = 自動的にfromに返信される) + imap: - uri: "" # 例: "imap://user@example.com:password@imap.example.com:993" + uri: "" # 例: "imap://user@example.com:password@imap.example.com:993" (認証情報のみ、プレースホルダ不可) secure: "" # "None", "SSL", "STARTTLS" draft_folder: "INBOX.Drafts" # "Auto": RFC 6154 IMAP SPECIAL-USE でセットする。手動も可能 + timeout: 30 # 秒 (IMAP接続タイムアウト、CLIフラグで上書き可能) smtp: - uri: "" # 例: "smtp://user@example.com:password@smtp.example.com:587" + uri: "" # 例: "smtp://user@example.com:password@smtp.example.com:587" (認証情報のみ、プレースホルダ不可) secure: "" # "None", "SSL", "STARTTLS" - mime-type: "text/plain" # "text/plain", "multipart/alternative" (HTMLへの変換は自動的に行います。) + mime_type: "text/plain" # "text/plain", "multipart/alternative" (HTMLへの変換は自動的に行います。) + + # セキュリティオプション (開発環境専用、詳細はドキュメント参照) + security: + ignore_tls_errors: false # TLS証明書検証をスキップ (WARNING: 開発環境のみ) + allow_unsafe_html: false # HTMLサニタイゼーションを無効化 (WARNING: XSSリスク) diff --git a/internal/config/config.go b/internal/config/config.go index 9fe453b..b9e872f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,11 +23,11 @@ const ( // EvidenceConfig represents the structure of evidence.yaml. type EvidenceConfig struct { - Version string `yaml:"version" json:"version"` - ReportID string `yaml:"report_id,omitempty" json:"report_id,omitempty"` - Target TargetConfig `yaml:"target" json:"target"` + Version string `yaml:"version" json:"version"` + ReportID string `yaml:"report_id,omitempty" json:"report_id,omitempty"` + Target TargetConfig `yaml:"target" json:"target"` UserAgents []UserAgentConfig `yaml:"user_agents" json:"user_agents"` - Notes string `yaml:"notes,omitempty" json:"notes,omitempty"` + Notes string `yaml:"notes,omitempty" json:"notes,omitempty"` } // TargetConfig represents target site information @@ -40,7 +40,7 @@ type TargetConfig struct { // ScreenConfig represents screen configuration compatible with devices.Screen type ScreenConfig struct { - DevicePixelRatio float64 `yaml:"device_pixel_ratio,omitempty" json:"device_pixel_ratio,omitempty"` + DevicePixelRatio float64 `yaml:"device_pixel_ratio,omitempty" json:"device_pixel_ratio,omitempty"` Horizontal ScreenSizeConfig `yaml:"horizontal,omitempty" json:"horizontal,omitempty"` Vertical ScreenSizeConfig `yaml:"vertical,omitempty" json:"vertical,omitempty"` } @@ -110,8 +110,6 @@ func (ua *UserAgentConfig) ToDeviceEmulation() devices.Device { } } - - return dev } @@ -273,13 +271,19 @@ func isValidDirectoryName(name string) bool { return false } for _, r := range name { - if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-') { - return false + if isValidChar(r) { + continue } + return false } return true } +// isValidChar checks if a rune is a valid character for directory names +func isValidChar(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' +} + // CompleteConfig fills in missing device information from builtin profiles // Returns true if any modifications were made func CompleteConfig(cfg *EvidenceConfig) bool { diff --git a/internal/email/imap.go b/internal/email/imap.go new file mode 100644 index 0000000..ac8b6e8 --- /dev/null +++ b/internal/email/imap.go @@ -0,0 +1,233 @@ +/* +Copyright © 2025 canaria-computer +*/ +package email + +import ( + "crypto/tls" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/canaria-computer/down-force/internal/appconfig" + "github.com/canaria-computer/down-force/internal/utils" + "github.com/charmbracelet/log" + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + mail "github.com/xhit/go-simple-mail/v2" +) + +// parseIMAPURI parses an IMAP URI in the format: imap://username:password@host:port +// Does NOT support placeholders - authentication credentials must be static values +// Only validates basic URI structure. Does not perform consistency checks (e.g., password with secure: None). +// Returns: host, port, username, password, error +func parseIMAPURI(uri string) (string, int, string, string, error) { + if uri == "" { + return "", 0, "", "", fmt.Errorf("IMAP URI is empty") + } + + parsedURL, err := url.Parse(uri) + if err != nil { + return "", 0, "", "", fmt.Errorf("invalid IMAP URI format: %w", err) + } + + if parsedURL.Scheme != "imap" && parsedURL.Scheme != "imaps" { + return "", 0, "", "", fmt.Errorf("invalid URI scheme: expected 'imap://' or 'imaps://', got '%s://'", parsedURL.Scheme) + } + + host := parsedURL.Hostname() + if host == "" { + return "", 0, "", "", fmt.Errorf("host not found in IMAP URI") + } + + portStr := parsedURL.Port() + port := 993 // Default IMAP SSL port + if portStr != "" { + port, err = strconv.Atoi(portStr) + if err != nil { + return "", 0, "", "", fmt.Errorf("invalid port in IMAP URI: %w", err) + } + } + + username := "" + password := "" + if parsedURL.User != nil { + username = parsedURL.User.Username() + password, _ = parsedURL.User.Password() + } + + return host, port, username, password, nil +} + +// ConnectIMAP establishes an IMAP connection with TLS configuration +func ConnectIMAP(cfg appconfig.IMAPConfig, ignoreTLS bool, timeoutOverride int) (*imapclient.Client, error) { + host, port, username, password, err := parseIMAPURI(cfg.URI) + if err != nil { + return nil, fmt.Errorf("failed to parse IMAP URI: %w", err) + } + + if username == "" || password == "" { + return nil, fmt.Errorf("username or password not found in IMAP URI") + } + + // Determine timeout + timeout := cfg.Timeout + if timeoutOverride > 0 { + timeout = timeoutOverride + } + if timeout <= 0 { + timeout = 30 // Fallback default + } + + // Build TLS configuration + tlsConfig := &tls.Config{ + ServerName: host, + InsecureSkipVerify: ignoreTLS, + } + + if ignoreTLS { + log.Warn("IMAP: TLS certificate verification is DISABLED") + } + + // Build connection address + addr := fmt.Sprintf("%s:%d", host, port) + + var client *imapclient.Client + options := &imapclient.Options{ + TLSConfig: tlsConfig, + DebugWriter: utils.NewDebugWriter(), // Debug output controlled by global log level + } + + // Determine connection method based on secure setting + secureMode := strings.ToUpper(cfg.Secure) + switch secureMode { + case "SSL", "TLS", "": // Default to SSL if not specified + // Direct TLS connection (IMAPS) + log.Infof("Connecting to IMAP server with SSL/TLS: %s (timeout: %ds)", addr, timeout) + var err error + client, err = imapclient.DialTLS(addr, options) + if err != nil { + return nil, fmt.Errorf("failed to connect via SSL/TLS: %w", err) + } + + case "STARTTLS": + // Plain connection first, then upgrade to TLS + log.Infof("Connecting to IMAP server with STARTTLS: %s (timeout: %ds)", addr, timeout) + var err error + client, err = imapclient.DialStartTLS(addr, options) + if err != nil { + return nil, fmt.Errorf("failed to connect via STARTTLS: %w", err) + } + + case "NONE": + // Plain connection without encryption + log.Warnf("Connecting to IMAP server WITHOUT encryption: %s (timeout: %ds)", addr, timeout) + log.Warn("WARNING: Sending credentials over unencrypted connection") + var err error + client, err = imapclient.DialInsecure(addr, options) + if err != nil { + return nil, fmt.Errorf("failed to connect without encryption: %w", err) + } + + default: + return nil, fmt.Errorf("invalid secure mode: %s (valid: SSL, STARTTLS, None)", cfg.Secure) + } + + // Authenticate + log.Infof("Authenticating as: %s", username) + if err := client.Login(username, password).Wait(); err != nil { + client.Close() + return nil, fmt.Errorf("IMAP authentication failed: %w", err) + } + + log.Info("IMAP connection established successfully") + return client, nil +} + +// findOrCreateDraftMailbox searches for a mailbox with \Drafts attribute. +// If not found, creates a mailbox named "Drafts-down-force". +// Returns the mailbox path. +func FindOrCreateDraftMailbox(client *imapclient.Client, preferredFolder string) (string, error) { + log.Info("Searching for Drafts mailbox...") + + // List all mailboxes (attributes are included by default) + log.Debug("Listing all mailboxes...") + listCmd := client.List("", "*", nil) + mailboxes, err := listCmd.Collect() + if err != nil { + return "", fmt.Errorf("failed to list mailboxes: %w", err) + } + + log.Infof("Found %d mailboxes", len(mailboxes)) + + // Search for mailbox with \Drafts attribute + // Note: Most IMAP servers return attributes even without SPECIAL-USE option + for _, mbox := range mailboxes { + for _, attr := range mbox.Attrs { + if attr == imap.MailboxAttrDrafts { + log.Infof("Found Drafts mailbox with \\Drafts attribute: %s", mbox.Mailbox) + return mbox.Mailbox, nil + } + } + } + + // Fallback: Search for common Drafts mailbox names + commonNames := []string{"Drafts", "INBOX.Drafts", "Draft", "INBOX.Draft"} + if preferredFolder != "" { + commonNames = append([]string{preferredFolder}, commonNames...) + } + + log.Debug("Searching for Drafts by common names...") + for _, name := range commonNames { + for _, mbox := range mailboxes { + if strings.EqualFold(mbox.Mailbox, name) { + log.Infof("Found Drafts mailbox by name: %s", mbox.Mailbox) + return mbox.Mailbox, nil + } + } + } + + // No Drafts mailbox found, create fallback + fallbackName := "Drafts-down-force" + log.Warnf("No Drafts mailbox found, creating fallback: %s", fallbackName) + + createCmd := client.Create(fallbackName, nil) + if err := createCmd.Wait(); err != nil { + return "", fmt.Errorf("failed to create fallback Drafts mailbox: %w", err) + } + + log.Infof("Created fallback Drafts mailbox: %s", fallbackName) + return fallbackName, nil +} + +// UploadDraftMessage uploads an email message to the IMAP server as a draft +func UploadDraftMessage(client *imapclient.Client, msg *mail.Email, folder string) error { + // Get RFC 5322 formatted message + rawMessage := msg.GetMessage() + + log.Infof("Uploading draft message to folder: %s (%d bytes)", folder, len(rawMessage)) + + // Append message with \Draft flag + options := &imap.AppendOptions{ + Flags: []imap.Flag{imap.FlagDraft}, + Time: time.Now(), + } + + appendCmd := client.Append(folder, int64(len(rawMessage)), options) + if _, err := appendCmd.Write([]byte(rawMessage)); err != nil { + appendCmd.Close() + return fmt.Errorf("failed to write message data: %w", err) + } + if err := appendCmd.Close(); err != nil { + return fmt.Errorf("failed to close append command: %w", err) + } + appendData, err := appendCmd.Wait() + if err != nil { + return fmt.Errorf("failed to append message to IMAP server: %w", err) + } + + log.Infof("Draft uploaded successfully (UID: %d)", appendData.UID) + return nil +} diff --git a/internal/email/message.go b/internal/email/message.go new file mode 100644 index 0000000..a4d586a --- /dev/null +++ b/internal/email/message.go @@ -0,0 +1,232 @@ +/* +Copyright © 2025 canaria-computer +*/ +package email + +import ( + "bytes" + "fmt" + "io" + "math/rand" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/log" + mail "github.com/xhit/go-simple-mail/v2" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer/html" + + "github.com/canaria-computer/down-force/internal/appconfig" + "github.com/canaria-computer/down-force/internal/config" + "github.com/canaria-computer/down-force/internal/utils" + "gopkg.in/yaml.v3" +) + +// MarkdownRenderer converts markdown to HTML using goldmark with GFM + CJK support +func MarkdownRenderer() goldmark.Markdown { + return goldmark.New( + goldmark.WithExtensions( + extension.GFM, + extension.CJK, + ), + goldmark.WithRendererOptions( + html.WithHardWraps(), + ), + ) +} + +// ReportContent holds the loaded report data +type ReportContent struct { + ReportID string // Pure Report ID (e.g., "MjAyNjAxMDgtMTc0NTQ0") + ReportDir string // Report directory path (e.g., "report-MjAyNjAxMDgtMTc0NTQ0") + Body []byte + Evidence config.EvidenceConfig + ScreenshotPath string +} + +// LoadReportContent loads all necessary report data from the directory +func LoadReportContent(reportDir string) (*ReportContent, error) { + // Read evidence.snapshot.yaml for report ID and screenshots + evidencePath := filepath.Join(reportDir, "evidence.snapshot.yaml") + evidenceFile, err := os.Open(evidencePath) + if err != nil { + return nil, fmt.Errorf("failed to open evidence.snapshot.yaml: %w", err) + } + defer func() { + if err := evidenceFile.Close(); err != nil { + log.Warn("failed to close evidence.snapshot.yaml file:", err) + } + }() + + evidenceContent, err := io.ReadAll(evidenceFile) + if err != nil { + return nil, fmt.Errorf("failed to read evidence.snapshot.yaml: %w", err) + } + + var evidenceData config.EvidenceConfig + if err := yaml.Unmarshal(evidenceContent, &evidenceData); err != nil { + return nil, fmt.Errorf("failed to parse evidence.snapshot.yaml: %w", err) + } + + reportID := evidenceData.ReportID + if reportID == "" { + return nil, fmt.Errorf("report_id not found in evidence.snapshot.yaml") + } + + reportMDPath := filepath.Join(reportDir, "Report.md") + reportContent, err := os.ReadFile(reportMDPath) + if err != nil { + log.Warnf("failed to read Report.md: %v", err) + return nil, fmt.Errorf("failed to read Report.md: %w", err) + } + + screenshot := findScreenshot(reportDir, evidenceData) + if screenshot == "" { + log.Warnf("failed to find screenshot: %v", err) + } + + return &ReportContent{ + ReportID: reportID, + ReportDir: reportDir, + Body: reportContent, + Evidence: evidenceData, + ScreenshotPath: screenshot, + }, nil +} + +// BuildEmailMessage creates an email.Message from an evidence report content +func BuildEmailMessage(content *ReportContent, includeHTML bool, allowEmptySender bool) (*mail.Email, error) { + cfg := appconfig.GetConfig() + + // Create placeholder context from report directory for From/Reply-To expansion + placeholderCtx, err := utils.NewPlaceholderContextFromReportDir(content.ReportDir) + if err != nil { + log.Warnf("Failed to create placeholder context from report directory: %v", err) + placeholderCtx = nil + } + + // Expand and validate From address (supports placeholders) + fromAddress := cfg.Email.From + if fromAddress == "" { + // Fallback to legacy reporter.contact.email + fromAddress = cfg.Reporter.Contact.Email + } + + if fromAddress != "" { + expandedFrom, err := utils.ExpandAndValidateEmailAddress(fromAddress, placeholderCtx) + if err != nil { + return nil, fmt.Errorf("invalid From email address: %w", err) + } + fromAddress = expandedFrom + } else if !allowEmptySender { + return nil, fmt.Errorf("From email address not configured (email.from or reporter.contact.email)") + } + + // Expand and validate Reply-To address (supports placeholders) + // Note: If Reply-To is not specified, we do NOT set it (RFC 5322: replies default to From) + replyToAddress := cfg.Email.ReplyTo + if replyToAddress != "" { + expandedReplyTo, err := utils.ExpandAndValidateEmailAddress(replyToAddress, placeholderCtx) + if err != nil { + return nil, fmt.Errorf("invalid Reply-To email address: %w", err) + } + replyToAddress = expandedReplyTo + } + + // Parse domain for Message-ID generation + senderUsername := "user" + senderDomain := "example.com" + if fromAddress != "" { + senderParts := strings.Split(fromAddress, "@") + if len(senderParts) == 2 { + senderUsername = senderParts[0] + senderDomain = senderParts[1] + } else if !allowEmptySender { + return nil, fmt.Errorf("invalid From email format after expansion: %s", fromAddress) + } + } + + m := mail.NewMSG() + if fromAddress != "" { + m.SetFrom(fromAddress) + } + // Only set Reply-To if explicitly configured (RFC 5322: defaults to From if not present) + if replyToAddress != "" { + m.SetReplyTo(replyToAddress) + } + + obbfuscatedDomain := "" + if domain, err := utils.ObfuscateDomain(content.Evidence.Target.URL); err == nil && len(obbfuscatedDomain) <= 20 { + obbfuscatedDomain = domain + } + + subject := fmt.Sprintf( + "Phishing Abuse [Takedown Request] | %s #CaseId=%s", + obbfuscatedDomain, + content.ReportID, + ) + m.SetSubject(subject) + + // Generate Message-ID + random := generateRandomString(7) + messageID := fmt.Sprintf("<%s#%s%%%s@%s>", senderUsername, content.ReportID, random, senderDomain) + m.AddHeader("Message-ID", messageID) + log.Debugf("Generated Message-ID: %s", messageID) + + if includeHTML { + md := MarkdownRenderer() + var htmlBuf bytes.Buffer + if err := md.Convert(content.Body, &htmlBuf); err != nil { + return nil, fmt.Errorf("failed to convert markdown to HTML: %w", err) + } + + m.SetBody(mail.TextHTML, htmlBuf.String()) + m.AddAlternative(mail.TextPlain, string(content.Body)) + } else { + m.SetBody(mail.TextPlain, string(content.Body)) + } + + if content.ScreenshotPath != "" { + m.Attach(&mail.File{ + FilePath: content.ScreenshotPath, + Name: filepath.Base(content.ScreenshotPath), + }) + } + + return m, nil +} + +// findScreenshot searches for a screenshot file matching user agents from evidence data +// and returns the absolute file path to the screenshot. Returns an empty string if no +// screenshot file is found or accessible. The search order prioritizes user agents +// listed in evidence.UserAgents. +func findScreenshot(reportDir string, evidence config.EvidenceConfig) string { + // Search for screenshot.png in each user agent directory + for _, ua := range evidence.UserAgents { + screenshotPath := filepath.Join(reportDir, ua.Name, "screenshot.png") + if _, err := os.Stat(screenshotPath); err == nil { + log.Debugf("Using screenshot for user_agent %s: %s", ua.Name, screenshotPath) + return screenshotPath + } + } + + log.Warn("no screenshot.png found in any user agent directory") + return "" +} + +// generateRandomString generates a random alphanumeric string +func generateRandomString(length int) string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[randomInt(0, len(charset))] + } + return string(b) +} + +// randomInt generates a random integer between min and max (exclusive) +func randomInt(min, max int) int { + return min + rand.Intn(max-min) +} diff --git a/internal/network/reachability.go b/internal/network/reachability.go index 5e40066..c35a2b3 100644 --- a/internal/network/reachability.go +++ b/internal/network/reachability.go @@ -10,6 +10,8 @@ import ( "net/http" "net/url" "time" + + "github.com/charmbracelet/log" ) // ReachabilityResult represents the result of reachability checks @@ -88,7 +90,11 @@ func checkTCP(host, port string, timeout time.Duration) bool { if err != nil { return false } - conn.Close() + defer func() { + if err := conn.Close(); err != nil { + log.Warnf("Error closing TCP connection: %v", err) + } + }() return true } @@ -102,9 +108,12 @@ func checkTLS(host, port string, timeout time.Duration) (bool, string) { if err != nil { return false, err.Error() } - defer conn.Close() + defer func() { + if err := conn.Close(); err != nil { + log.Warnf("Error closing TLS connection: %v", err) + } + }() - // Verify certificate err = conn.VerifyHostname(host) if err != nil { return false, err.Error() @@ -136,15 +145,17 @@ func checkHTTP(targetURL string, timeout time.Duration) (*httpResult, error) { return nil, err } - // Use a common user agent - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 10; Google Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/121.0 Mobile Safari/537.36") resp, err := client.Do(req) if err != nil { return nil, err } - defer resp.Body.Close() - + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + log.Warnf("Error closing response body: %v", cerr) + } + }() result := &httpResult{ statusCode: resp.StatusCode, finalURL: resp.Request.URL.String(), diff --git a/internal/report/report.go b/internal/report/report.go index 29bb760..9bf2cd5 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -2,6 +2,7 @@ package report import ( _ "embed" + "errors" "fmt" "os" "path/filepath" @@ -45,7 +46,7 @@ type ReportData struct { } // Generate creates the Report.md file -func Generate(reportID string, cfg *config.EvidenceConfig, accessInfo *utils.AccessInfo, targetIPv4, targetIPv6 string, targetGeo *utils.GeoIPInfo, screenshots []string) error { +func Generate(reportDir string, reportID string, cfg *config.EvidenceConfig, accessInfo *utils.AccessInfo, targetIPv4, targetIPv6 string, targetGeo *utils.GeoIPInfo, screenshots []string) error { now := time.Now() localTime := now.Format(time.RFC3339) utcTime := now.UTC().Format(time.RFC3339) @@ -93,12 +94,16 @@ func Generate(reportID string, cfg *config.EvidenceConfig, accessInfo *utils.Acc } // Create report file - reportPath := filepath.Join(reportID, "Report.md") + reportPath := filepath.Join(reportDir, "Report.md") f, err := os.Create(reportPath) if err != nil { return fmt.Errorf("failed to create report file: %w", err) } - defer f.Close() + defer func() { + if cerr := f.Close(); cerr != nil && cerr != os.ErrClosed { + err = errors.Join(err, cerr) + } + }() tmpl, err := template.New("report").Parse(reportTemplate) if err != nil { diff --git a/internal/utils/access_info.go b/internal/utils/access_info.go index d873fe3..d5cc780 100644 --- a/internal/utils/access_info.go +++ b/internal/utils/access_info.go @@ -304,7 +304,11 @@ func fetchIP(ctx context.Context, url string, needsCurlUA bool, parse func([]byt if err != nil { return "", err } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn("Failed to close response body", "error", err) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -335,7 +339,11 @@ func fetchGeoIPFromMultipleSources(ip string) (*GeoIPInfo, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn("Failed to close response body", "error", err) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { diff --git a/internal/utils/debug.go b/internal/utils/debug.go new file mode 100644 index 0000000..ec78231 --- /dev/null +++ b/internal/utils/debug.go @@ -0,0 +1,37 @@ +/* +Copyright © 2025 canaria-computer +*/ +package utils + +import ( + "io" + "os" + + "github.com/charmbracelet/log" +) + +// DebugWriter is a writer that only outputs when log level is debug or below. +// This can be used for any debug output that should be controlled by the global log level. +type DebugWriter struct { + Output io.Writer // The underlying writer (default: os.Stderr) +} + +// NewDebugWriter creates a new DebugWriter that writes to os.Stderr +func NewDebugWriter() *DebugWriter { + return &DebugWriter{Output: os.Stderr} +} + +// NewDebugWriterWithOutput creates a new DebugWriter with a custom output writer +func NewDebugWriterWithOutput(output io.Writer) *DebugWriter { + return &DebugWriter{Output: output} +} + +// Write implements io.Writer interface. +// It only writes to the underlying writer when log level is debug or below. +func (w *DebugWriter) Write(p []byte) (n int, err error) { + if log.GetLevel() <= log.DebugLevel { + return w.Output.Write(p) + } + // Return length as if we wrote it, but don't actually write anything + return len(p), nil +} diff --git a/internal/utils/email_address.go b/internal/utils/email_address.go new file mode 100644 index 0000000..3b5c937 --- /dev/null +++ b/internal/utils/email_address.go @@ -0,0 +1,135 @@ +/* +Copyright © 2025 canaria-computer +*/ +package utils + +import ( + "fmt" + "net/mail" + "path/filepath" + "strings" +) + +// PlaceholderContext holds values for template expansion in email addresses +type PlaceholderContext struct { + ReportID string // Report ID (e.g., "MjAyNjAxMDgtMTc0NTQ0" or custom ID) +} + +// NewPlaceholderContextFromReportDir creates a PlaceholderContext from a report directory name +// Expected format: report-{REPORT_ID} (e.g., "report-MjAyNjAxMDgtMTc0NTQ0") +func NewPlaceholderContextFromReportDir(reportDir string) (*PlaceholderContext, error) { + basename := filepath.Base(reportDir) + + // Directory name must start with "report-" + if !strings.HasPrefix(basename, "report-") { + return nil, fmt.Errorf("invalid report directory format (expected 'report-*'): %s", basename) + } + + // Extract Report ID by removing "report-" prefix + reportID := strings.TrimPrefix(basename, "report-") + + if reportID == "" { + return nil, fmt.Errorf("report ID is empty in directory: %s", basename) + } + + return &PlaceholderContext{ + ReportID: reportID, + }, nil +} + +// ExpandPlaceholders replaces placeholders in the template string with values from the context +// Supported placeholders (case-insensitive): {report_id} +// If context is nil, returns the template unchanged +func ExpandPlaceholders(template string, ctx *PlaceholderContext) string { + if ctx == nil { + return template + } + + result := template + + // Case-insensitive replacement for {report_id} only + replacements := map[string]string{ + "{report_id}": ctx.ReportID, + "{REPORT_ID}": ctx.ReportID, + "{Report_Id}": ctx.ReportID, + "{Report_ID}": ctx.ReportID, + } + + for placeholder, value := range replacements { + result = strings.ReplaceAll(result, placeholder, value) + } + + return result +} + +// ValidateEmailAddress validates an email address using RFC 5322 rules +// Also checks for unresolved placeholders and control characters +func ValidateEmailAddress(email string) error { + if email == "" { + return fmt.Errorf("email address is empty") + } + + // Check for unresolved placeholders + if strings.Contains(email, "{") || strings.Contains(email, "}") { + return fmt.Errorf("email contains unresolved placeholders: %s", email) + } + + // Check for control characters + for _, r := range email { + if r < 32 || r == 127 { + return fmt.Errorf("email contains control characters: %s", email) + } + } + + // RFC 5322 validation via net/mail + _, err := mail.ParseAddress(email) + if err != nil { + return fmt.Errorf("invalid email address format: %w", err) + } + + return nil +} + +// ExpandAndValidateEmailAddress expands placeholders and validates the result +func ExpandAndValidateEmailAddress(template string, ctx *PlaceholderContext) (string, error) { + expanded := ExpandPlaceholders(template, ctx) + + if err := ValidateEmailAddress(expanded); err != nil { + return "", fmt.Errorf("invalid email after placeholder expansion (%s -> %s): %w", template, expanded, err) + } + + return expanded, nil +} + +// ValidateEmailAddressTemplate validates an email template before expansion +// Checks if the template is likely to produce a valid email after expansion +func ValidateEmailAddressTemplate(template string) error { + if template == "" { + return fmt.Errorf("email template is empty") + } + + // Check for valid placeholder syntax only + validPlaceholders := []string{"{report_id}"} + + // Manual check for placeholders (simple string search) + lowerTemplate := strings.ToLower(template) + for _, placeholder := range validPlaceholders { + // Check if template contains this placeholder + if strings.Contains(lowerTemplate, placeholder) { + // Found at least one valid placeholder + break + } + } + + // Try to create a dummy context and validate + dummyCtx := &PlaceholderContext{ + ReportID: "MjAyNjAxMDgtMTc0NTQ0", + } + + expanded := ExpandPlaceholders(template, dummyCtx) + if err := ValidateEmailAddress(expanded); err != nil { + return fmt.Errorf("template would produce invalid email: %w", err) + } + + return nil +} diff --git a/internal/utils/email_address_test.go b/internal/utils/email_address_test.go new file mode 100644 index 0000000..bb4c7fb --- /dev/null +++ b/internal/utils/email_address_test.go @@ -0,0 +1,271 @@ +/* +Copyright © 2025 canaria-computer +*/ +package utils + +import ( + "testing" +) + +func TestNewPlaceholderContextFromReportDir(t *testing.T) { + tests := []struct { + name string + reportDir string + wantReportID string + wantErr bool + }{ + { + name: "valid Base58 report directory", + reportDir: "report-MjAyNjAxMDgtMTc0NTQ0", + wantReportID: "MjAyNjAxMDgtMTc0NTQ0", + wantErr: false, + }, + { + name: "valid Base58 with full path", + reportDir: "/path/to/report-MjAyNjAxMDgtMTc0NTQ0", + wantReportID: "MjAyNjAxMDgtMTc0NTQ0", + wantErr: false, + }, + { + name: "valid custom report ID", + reportDir: "report-custom-id-123", + wantReportID: "custom-id-123", + wantErr: false, + }, + { + name: "invalid format - no report- prefix", + reportDir: "MjAyNjAxMDgtMTc0NTQ0", + wantErr: true, + }, + { + name: "invalid format - empty report ID", + reportDir: "report-", + wantErr: true, + }, + { + name: "invalid format - not a report directory", + reportDir: "some-other-directory", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, err := NewPlaceholderContextFromReportDir(tt.reportDir) + if (err != nil) != tt.wantErr { + t.Errorf("NewPlaceholderContextFromReportDir() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if ctx.ReportID != tt.wantReportID { + t.Errorf("ReportID = %v, want %v", ctx.ReportID, tt.wantReportID) + } + } + }) + } +} + +func TestExpandPlaceholders(t *testing.T) { + ctx := &PlaceholderContext{ + ReportID: "MjAyNjAxMDgtMTc0NTQ0", + } + + tests := []struct { + name string + template string + ctx *PlaceholderContext + want string + }{ + { + name: "expand report_id", + template: "abuse+{report_id}@example.com", + ctx: ctx, + want: "abuse+MjAyNjAxMDgtMTc0NTQ0@example.com", + }, + { + name: "case insensitive - uppercase", + template: "abuse+{REPORT_ID}@example.com", + ctx: ctx, + want: "abuse+MjAyNjAxMDgtMTc0NTQ0@example.com", + }, + { + name: "case insensitive - mixed case", + template: "abuse+{Report_ID}@example.com", + ctx: ctx, + want: "abuse+MjAyNjAxMDgtMTc0NTQ0@example.com", + }, + { + name: "no placeholders", + template: "abuse@example.com", + ctx: ctx, + want: "abuse@example.com", + }, + { + name: "nil context", + template: "abuse+{report_id}@example.com", + ctx: nil, + want: "abuse+{report_id}@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExpandPlaceholders(tt.template, tt.ctx) + if got != tt.want { + t.Errorf("ExpandPlaceholders() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidateEmailAddress(t *testing.T) { + tests := []struct { + name string + email string + wantErr bool + }{ + { + name: "valid email", + email: "abuse@example.com", + wantErr: false, + }, + { + name: "valid email with plus addressing", + email: "abuse+report-20240101@example.com", + wantErr: false, + }, + { + name: "empty email", + email: "", + wantErr: true, + }, + { + name: "unresolved placeholder", + email: "abuse+{report_id}@example.com", + wantErr: true, + }, + { + name: "invalid format - no @", + email: "invalid-email", + wantErr: true, + }, + { + name: "invalid format - multiple @", + email: "user@@example.com", + wantErr: true, + }, + { + name: "control characters", + email: "abuse\x00@example.com", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateEmailAddress(tt.email) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateEmailAddress() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestExpandAndValidateEmailAddress(t *testing.T) { + ctx := &PlaceholderContext{ + ReportID: "MjAyNjAxMDgtMTc0NTQ0", + } + + tests := []struct { + name string + template string + ctx *PlaceholderContext + want string + wantErr bool + }{ + { + name: "valid template with placeholder", + template: "abuse+{report_id}@example.com", + ctx: ctx, + want: "abuse+MjAyNjAxMDgtMTc0NTQ0@example.com", + wantErr: false, + }, + { + name: "valid template without placeholder", + template: "abuse@example.com", + ctx: ctx, + want: "abuse@example.com", + wantErr: false, + }, + { + name: "invalid after expansion", + template: "{report_id}", + ctx: ctx, + want: "", + wantErr: true, + }, + { + name: "nil context with placeholder", + template: "abuse+{report_id}@example.com", + ctx: nil, + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ExpandAndValidateEmailAddress(tt.template, tt.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("ExpandAndValidateEmailAddress() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("ExpandAndValidateEmailAddress() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidateEmailAddressTemplate(t *testing.T) { + tests := []struct { + name string + template string + wantErr bool + }{ + { + name: "valid template with report_id", + template: "abuse+{report_id}@example.com", + wantErr: false, + }, + { + name: "valid template without placeholders", + template: "abuse@example.com", + wantErr: false, + }, + { + name: "invalid placeholder", + template: "abuse+{invalid}@example.com", + wantErr: true, + }, + { + name: "empty template", + template: "", + wantErr: true, + }, + { + name: "invalid email structure", + template: "invalid-email", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateEmailAddressTemplate(tt.template) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateEmailAddressTemplate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/utils/obfuscator.go b/internal/utils/obfuscator.go new file mode 100644 index 0000000..cc2d6eb --- /dev/null +++ b/internal/utils/obfuscator.go @@ -0,0 +1,190 @@ +package utils + +import ( + "errors" + "fmt" + "net" + "net/url" + "strings" + + "golang.org/x/net/idna" + "golang.org/x/net/publicsuffix" +) + +var ( + // ErrEmptyHostname is returned when no hostname can be extracted from the URL + ErrEmptyHostname = errors.New("failed to extract hostname from URL") + + // ErrPunycodeConversion is returned when punycode conversion fails + ErrPunycodeConversion = errors.New("punycode conversion failed") + + // punycodeProfile is a reusable IDNA profile for ASCII conversion + punycodeProfile = idna.New() +) + +// Config holds configuration options for URL obfuscation +type Config struct { + // PreserveIPv6Colons preserves colons in IPv6 addresses per IETF draft-grimminck-safe-ioc-sharing-03 + PreserveIPv6Colons bool + // ExtractRootDomain extracts only the root domain (eTLD+1) instead of the full hostname + ExtractRootDomain bool +} + +// DefaultConfig returns the default configuration +func DefaultConfig() *Config { + return &Config{ + PreserveIPv6Colons: true, + ExtractRootDomain: true, + } +} + +// Obfuscator handles URL and domain obfuscation operations +type Obfuscator struct { + config *Config +} + +// New creates a new Obfuscator with the given configuration +func New(config *Config) *Obfuscator { + if config == nil { + config = DefaultConfig() + } + return &Obfuscator{config: config} +} + +// ObfuscateDomain extracts and obfuscates the domain portion of a URL according to +// IETF draft-grimminck-safe-ioc-sharing-03. +// +// Process: +// 1. Extract domain from URL +// 2. Return IPv6 addresses unchanged (preserving colons and brackets) +// 3. For domains, extract root domain (eTLD+1) +// 4. Convert non-ASCII characters to Punycode +// 5. Replace dots with [.] for obfuscation +func (o *Obfuscator) ObfuscateDomain(rawURL string) (string, error) { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("parse URL: %w", err) + } + + hostname := parsedURL.Hostname() + if hostname == "" { + return "", ErrEmptyHostname + } + + // Handle IPv6 addresses - preserve colons and brackets per IETF specification + if o.isIPv6(hostname) { + return parsedURL.Host, nil + } + + // Handle IPv4 addresses - obfuscate dots + if o.isIPv4(hostname) { + return strings.ReplaceAll(hostname, ".", "[.]"), nil + } + + // Process domain names + domain := hostname + if o.config.ExtractRootDomain { + domain = o.extractRootDomain(hostname) + } + + // Convert internationalized domain names (IDN) to ASCII (Punycode) + asciiDomain, err := punycodeProfile.ToASCII(domain) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrPunycodeConversion, err) + } + + // Obfuscate by replacing dots with [.] + return strings.ReplaceAll(asciiDomain, ".", "[.]"), nil +} + +// ObfuscateURL obfuscates both the scheme and domain of a URL. +// +// Examples: +// - "https://www.example.com/path" -> "hxxps://example[.]com" +// - "http://[2001:db8::1]/path" -> "hxxp://[2001:db8::1]" +func (o *Obfuscator) ObfuscateURL(rawURL string) (string, error) { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("parse URL: %w", err) + } + + obfuscatedDomain, err := o.ObfuscateDomain(rawURL) + if err != nil { + return "", err + } + + obfuscatedScheme := obfuscateScheme(parsedURL.Scheme) + + return fmt.Sprintf("%s://%s", obfuscatedScheme, obfuscatedDomain), nil +} + +// isIPv6 checks if the hostname is an IPv6 address +func (o *Obfuscator) isIPv6(hostname string) bool { + ip := net.ParseIP(hostname) + return ip != nil && ip.To4() == nil +} + +// isIPv4 checks if the hostname is an IPv4 address +func (o *Obfuscator) isIPv4(hostname string) bool { + ip := net.ParseIP(hostname) + return ip != nil && ip.To4() != nil +} + +// extractRootDomain extracts the root domain (eTLD+1) from a hostname +// Example: "www.example.co.uk" -> "example.co.uk" +func (o *Obfuscator) extractRootDomain(hostname string) string { + rootDomain, err := publicsuffix.EffectiveTLDPlusOne(hostname) + if err != nil { + // If root domain cannot be determined, use the original hostname + return hostname + } + return rootDomain +} + +// obfuscateScheme obfuscates URI schemes according to IETF draft-grimminck-safe-ioc-sharing-03 +// with enhanced support for unknown protocol schemes. +// +// Obfuscation rules (applied in order): +// 1. If "tp" exists: replace first "tp" with "xp" (e.g., ftp -> fxp, rtsp -> rxsp) +// 2. If "tt" exists: replace first "tt" with "xx" (e.g., http -> hxxp, https -> hxxps) +// 3. If "t" exists: replace first "t" with "x" (e.g., telnet -> xelnet, git -> gix) +// 4. Otherwise: append "x" to the end (e.g., ssh -> sshx, smb -> smbx) +func obfuscateScheme(scheme string) string { + lowerScheme := strings.ToLower(scheme) + + // Rule 1: Replace "tp" with "xp" if present + // Examples: ftp -> fxp, rtsp -> rxsp, http -> hxxp (before "tt" is processed) + if strings.Contains(lowerScheme, "tp") { + return strings.Replace(lowerScheme, "tp", "xp", 1) + } + + // Rule 2: Replace "tt" with "xx" if present + // Examples: http -> hxxp, https -> hxxps (when "tp" is not present) + if strings.Contains(lowerScheme, "tt") { + return strings.Replace(lowerScheme, "tt", "xx", 1) + } + + // Rule 3: Replace first "t" with "x" if present + // Examples: telnet -> xelnet, git -> gix, smtp -> smxp + if strings.Contains(lowerScheme, "t") { + return strings.Replace(lowerScheme, "t", "x", 1) + } + + // Rule 4: Append "x" if no "t" is present + // Examples: ssh -> sshx, smb -> smbx, file -> filex + return lowerScheme + "x" +} + +// Package-level convenience functions using default configuration + +var defaultObfuscator = New(nil) + +// ObfuscateDomain is a convenience function that uses the default obfuscator +func ObfuscateDomain(rawURL string) (string, error) { + return defaultObfuscator.ObfuscateDomain(rawURL) +} + +// ObfuscateURL is a convenience function that uses the default obfuscator +func ObfuscateURL(rawURL string) (string, error) { + return defaultObfuscator.ObfuscateURL(rawURL) +} diff --git a/internal/utils/reachability.go b/internal/utils/reachability.go index f04e08f..fc0874d 100644 --- a/internal/utils/reachability.go +++ b/internal/utils/reachability.go @@ -12,12 +12,12 @@ import ( ) const ( - maxRetryAttempts = 6 - initialRetryDelay = 1 * time.Second - maxRetryDelay = 8 * time.Second - requestTimeout = 20 * time.Second - dialTimeout = 10 * time.Second - tlsHandshakeTimeout = 20 * time.Second + maxRetryAttempts = 6 + initialRetryDelay = 1 * time.Second + maxRetryDelay = 8 * time.Second + requestTimeout = 20 * time.Second + dialTimeout = 10 * time.Second + tlsHandshakeTimeout = 20 * time.Second responseHeaderTimeout = 20 * time.Second ) @@ -81,7 +81,11 @@ func attemptRequest(client *http.Client, targetURL, userAgent string) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn("Failed to close response body", "error", err) + } + }() return nil } @@ -92,6 +96,6 @@ func calculateBackoff(attempt int) time.Duration { // 2^(attempt-1) with bit shifting for efficiency multiplier := 1 << (attempt - 1) backoff := time.Duration(multiplier) * initialRetryDelay - + return min(backoff, maxRetryDelay) } diff --git a/main.go b/main.go index c925985..7f0607c 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,5 @@ /* Copyright © 2025 canaria-computer - */ package main