From a72fa433d9fd0cd911bc363790de52497d25e66d Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sat, 3 Jan 2026 11:45:50 +0900 Subject: [PATCH 01/41] fix: If the target URL is unreachable during the preflight check, the program will exit --- cmd/lite.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/lite.go b/cmd/lite.go index 122c2f2..ee1a222 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -98,8 +98,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) @@ -313,7 +312,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...") From df25b86ea8cfe12762712ba991d4f9e382beefda Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:16:19 +0900 Subject: [PATCH 02/41] impr: Compress PNG image with save --- cmd/lite.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/lite.go b/cmd/lite.go index ee1a222..11bbe05 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -14,6 +14,7 @@ import ( "image" "image/color" "image/draw" + "image/png" "net" "net/netip" "net/url" @@ -418,10 +419,20 @@ 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 file.Close() + + encoder := &png.Encoder{ + CompressionLevel: png.BestCompression, + } + if err := encoder.Encode(file, finalImage); err != nil { return fmt.Errorf("failed to save image: %w", err) } From 6f7599cbd9296ff6653a19a4918b72927dc03556 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:16:48 +0900 Subject: [PATCH 03/41] chore: Add Report.md to .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4bd5595..cbdb933 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,5 @@ $RECYCLE.BIN/ # Application generated files evidence.yaml -screenshot.* \ No newline at end of file +screenshot.* +Report.md \ No newline at end of file From d4c8e8a1d350b3294789e3d191b32dc84b3388a4 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:25:30 +0900 Subject: [PATCH 04/41] feat(appconfig): add email security options for TLS and HTML handling - Add IgnoreTLSErrors option for insecure TLS connections - Add AllowUnsafeHTML option for unsafe HTML embedding in Markdown - Support configuration via config.yaml, environment variables, and CLI flags - Include validation for security-sensitive options - Document security implications in defaults and comments --- internal/appconfig/appconfig.go | 37 ++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go index 69219d3..28e391b 100644 --- a/internal/appconfig/appconfig.go +++ b/internal/appconfig/appconfig.go @@ -1,3 +1,6 @@ +/* +Copyright © 2025 canaria-computer +*/ package appconfig import ( @@ -88,9 +91,10 @@ type ConsentConfig struct { } type EmailConfig struct { - IMAP IMAPConfig `koanf:"imap"` - SMTP SMTPConfig `koanf:"smtp"` - MimeType string `koanf:"mime-type"` + IMAP IMAPConfig `koanf:"imap"` + SMTP SMTPConfig `koanf:"smtp"` + MimeType string `koanf:"mime_type"` + Security SecurityConfig `koanf:"security"` } type IMAPConfig struct { @@ -104,6 +108,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 @@ -147,6 +166,10 @@ func DefaultConfig() *AppConfig { // Defaults removed }, MimeType: "text/plain", + Security: SecurityConfig{ + IgnoreTLSErrors: false, // TLS verification enforced by default + AllowUnsafeHTML: false, // HTML sanitization enforced by default + }, }, } } @@ -267,6 +290,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 (development only)")) + } + 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 } From 1e578e36c43a76a9aecb7c588d0d2dff877269ba Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:25:57 +0900 Subject: [PATCH 05/41] docs(appconfig): add email security options to template - Document ignore_tls_errors option with security warnings - Document allow_unsafe_html option with security implications - Add environment variable examples for security options - Include usage guidelines for development vs production --- internal/appconfig/config.template.yaml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/internal/appconfig/config.template.yaml b/internal/appconfig/config.template.yaml index fa330ec..aff2bd5 100644 --- a/internal/appconfig/config.template.yaml +++ b/internal/appconfig/config.template.yaml @@ -70,4 +70,26 @@ email: smtp: 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: 自己署名証明書やTLS検証エラーを無視する + # WARNING: TLS/SSL証明書の検証をスキップします。 + # 中間者攻撃の対象になるため、開発環境でのみ使用してください。 + # 使用例: DOWNFORCE_EMAIL_SECURITY_IGNORE_TLS_ERRORS=true + ignore_tls_errors: false # default: false (TLS検証は強制) + + # allow_unsafe_html: Markdownで埋め込むHTMLのサニタイゼーションを無効化する + # WARNING: 信頼できないソースからのHTMLが埋め込まれた場合、 + # XSS (クロスサイトスクリプティング) などのセキュリティリスクが生じます。 + # HTMLコンテンツのソースが完全に信頼できる場合のみ有効にしてください。 + # 使用例: DOWNFORCE_EMAIL_SECURITY_ALLOW_UNSAFE_HTML=true + allow_unsafe_html: false # default: false (HTML自動エスケープ) + +# 環境変数による設定例: +# export DOWNFORCE_EMAIL_SECURITY_IGNORE_TLS_ERRORS=true +# export DOWNFORCE_EMAIL_SECURITY_ALLOW_UNSAFE_HTML=true +# down-force email draft --preview From 40d26ce9a4c5f99b1827557e15879d23767ce37a Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:26:42 +0900 Subject: [PATCH 06/41] refactor(cmd/email): add security options for TLS verification and unsafe HTML - Add --ignore-tls-errors flag to disable TLS certificate verification - Add --allow-unsafe-html flag to permit unsafe HTML embedding in emails - Integrate with appconfig security settings (config file, env vars, CLI flags) - Add comprehensive help text with security warnings - Display security configuration on startup - Support configuration precedence: CLI flag > env var > config file > default These options are intended for development/testing with self-signed certificates and trusted HTML sources only. Production use should keep defaults. --- cmd/email.go | 108 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 96 insertions(+), 12 deletions(-) diff --git a/cmd/email.go b/cmd/email.go index dfc9bdc..062c967 100644 --- a/cmd/email.go +++ b/cmd/email.go @@ -10,6 +10,7 @@ import ( "sort" "strings" + "github.com/canaria-computer/down-force/internal/appconfig" "github.com/charmbracelet/log" "github.com/spf13/cobra" ) @@ -18,6 +19,10 @@ var ( // Persistent flags for email command emailReportDir string + // Security flags for email command + emailIgnoreTLSErrors bool + emailAllowUnsafeHTML bool + // Flags for draft subcommand emailDraftUpload bool emailDraftExport bool @@ -44,21 +49,35 @@ 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() }, @@ -75,6 +94,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 +107,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 +130,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 +155,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 +171,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 +195,12 @@ 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)") + // Draft subcommand flags emailDraftCmd.Flags().BoolVar(&emailDraftUpload, "upload", false, "Upload draft to IMAP Drafts folder") @@ -170,6 +217,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 { @@ -202,15 +264,18 @@ func runEmailDraft(cmd *cobra.Command, args []string) { // Execute based on selected mode if emailDraftUpload { log.Info("Uploading draft to IMAP Drafts folder...") - // TODO: Implement IMAP upload + log.Infof("TLS Error Handling: %v", effectiveIgnoreTLS) + // TODO: Implement IMAP upload with security settings log.Warn("IMAP upload not yet implemented") } else if emailDraftExport { log.Info("Exporting draft as .eml file...") - // TODO: Implement .eml export + log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) + // TODO: Implement .eml export with HTML handling log.Warn(".eml export not yet implemented") } else if emailDraftPreview { log.Info("Displaying email preview...") - // TODO: Implement preview display + log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) + // TODO: Implement preview display with HTML handling log.Warn("Preview display not yet implemented") } @@ -219,10 +284,27 @@ func runEmailDraft(cmd *cobra.Command, args []string) { } fmt.Printf("\nReport directory: %s\n", reportDir) + fmt.Printf("Ignore TLS Errors: %v\n", effectiveIgnoreTLS) + fmt.Printf("Allow Unsafe HTML: %v\n", effectiveAllowHTML) fmt.Println("Email draft functionality will be implemented here.") } func runEmailSend(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 { @@ -237,10 +319,12 @@ func runEmailSend(cmd *cobra.Command, args []string) { log.Info("Message-ID will be generated for tracking") } - // TODO: Implement email send with confirmation + // TODO: Implement email send with confirmation and security settings log.Warn("Email send functionality not yet implemented") fmt.Printf("\nReport directory: %s\n", reportDir) + fmt.Printf("Ignore TLS Errors: %v\n", effectiveIgnoreTLS) + fmt.Printf("Allow Unsafe HTML: %v\n", effectiveAllowHTML) fmt.Println("Email send-with-confirm functionality will be implemented here.") } From 2c01902f10121eaa5aab2d706d63278993e5916c Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:39:56 +0900 Subject: [PATCH 07/41] docs: add security options for TLS verification and unsafe HTML handling --- doc/app-behavior-config.md | 32 +++++++++++++++++++++++++ internal/appconfig/config.template.yaml | 23 +++--------------- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/doc/app-behavior-config.md b/doc/app-behavior-config.md index ccdd625..860fdbf 100644 --- a/doc/app-behavior-config.md +++ b/doc/app-behavior-config.md @@ -86,6 +86,30 @@ email: uri: "smtp://user@example.com:password@smtp.example.com:587" 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. 環境変数マッピング @@ -128,6 +152,14 @@ 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.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サニタイゼーションを無効化 | + + ## 3. 参照 - ターゲット設定: `evidence.yaml` (コマンドライン引数 `-c` で指定) diff --git a/internal/appconfig/config.template.yaml b/internal/appconfig/config.template.yaml index aff2bd5..658239d 100644 --- a/internal/appconfig/config.template.yaml +++ b/internal/appconfig/config.template.yaml @@ -72,24 +72,7 @@ email: secure: "" # "None", "SSL", "STARTTLS" mime_type: "text/plain" # "text/plain", "multipart/alternative" (HTMLへの変換は自動的に行います。) - # セキュリティオプション - # これらのオプションは開発環境でのテストやトラブルシューティング用です。 - # 本番環境では絶対に有効にしないでください。 + # セキュリティオプション (開発環境専用、詳細はドキュメント参照) security: - # ignore_tls_errors: 自己署名証明書やTLS検証エラーを無視する - # WARNING: TLS/SSL証明書の検証をスキップします。 - # 中間者攻撃の対象になるため、開発環境でのみ使用してください。 - # 使用例: DOWNFORCE_EMAIL_SECURITY_IGNORE_TLS_ERRORS=true - ignore_tls_errors: false # default: false (TLS検証は強制) - - # allow_unsafe_html: Markdownで埋め込むHTMLのサニタイゼーションを無効化する - # WARNING: 信頼できないソースからのHTMLが埋め込まれた場合、 - # XSS (クロスサイトスクリプティング) などのセキュリティリスクが生じます。 - # HTMLコンテンツのソースが完全に信頼できる場合のみ有効にしてください。 - # 使用例: DOWNFORCE_EMAIL_SECURITY_ALLOW_UNSAFE_HTML=true - allow_unsafe_html: false # default: false (HTML自動エスケープ) - -# 環境変数による設定例: -# export DOWNFORCE_EMAIL_SECURITY_IGNORE_TLS_ERRORS=true -# export DOWNFORCE_EMAIL_SECURITY_ALLOW_UNSAFE_HTML=true -# down-force email draft --preview + ignore_tls_errors: false # TLS証明書検証をスキップ (WARNING: 開発環境のみ) + allow_unsafe_html: false # HTMLサニタイゼーションを無効化 (WARNING: XSSリスク) From aae20076378bde3feb4d5747d6e69c36d2771465 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:44:19 +0900 Subject: [PATCH 08/41] fix: Revise the contents of the warning. --- internal/appconfig/appconfig.go | 36 ++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go index 28e391b..edbec54 100644 --- a/internal/appconfig/appconfig.go +++ b/internal/appconfig/appconfig.go @@ -91,10 +91,10 @@ type ConsentConfig struct { } type EmailConfig struct { - IMAP IMAPConfig `koanf:"imap"` - SMTP SMTPConfig `koanf:"smtp"` - MimeType string `koanf:"mime_type"` - Security SecurityConfig `koanf:"security"` + IMAP IMAPConfig `koanf:"imap"` + SMTP SMTPConfig `koanf:"smtp"` + MimeType string `koanf:"mime_type"` + Security SecurityConfig `koanf:"security"` } type IMAPConfig struct { @@ -167,8 +167,8 @@ func DefaultConfig() *AppConfig { }, MimeType: "text/plain", Security: SecurityConfig{ - IgnoreTLSErrors: false, // TLS verification enforced by default - AllowUnsafeHTML: false, // HTML sanitization enforced by default + IgnoreTLSErrors: false, // TLS verification enforced by default + AllowUnsafeHTML: false, // HTML sanitization enforced by default }, }, } @@ -209,22 +209,22 @@ func LoadConfig(configPath string) (*AppConfig, error) { TransformFunc: func(key, value string) (string, interface{}) { key = strings.ToLower(strings.TrimPrefix(key, EnvPrefix)) key = strings.Replace(key, "_", ".", -1) - + 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 }, }) @@ -292,7 +292,7 @@ func ValidateConfig(cfg *AppConfig) []error { // 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 (development only)")) + 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)")) From 9819d8b1272540dbee04a92ef1d630162c4a70ad Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:06:02 +0900 Subject: [PATCH 09/41] feat(email): implement email message building from evidence reports - BuildEmailMessage: creates email.Message from evidence report - Reads Report.md and converts to HTML via goldmark (GFM + CJK) - Supports both HTML and plain-text content - Generates Message-ID in format: - Attaches first matching screenshot based on evidence.yaml user_agents - Reads From/Reply-To from appconfig - Leaves To/CC/BCC empty for later specification - Validates configuration and report data - Uses github.com/xhit/go-simple-mail/v2 for email transport - Logs warnings when expected files/screenshots are missing --- internal/email/message.go | 338 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 internal/email/message.go diff --git a/internal/email/message.go b/internal/email/message.go new file mode 100644 index 0000000..4b08dc9 --- /dev/null +++ b/internal/email/message.go @@ -0,0 +1,338 @@ +/* +Copyright © 2025 canaria-computer +*/ +package email + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/log" + "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" +) + +// EvidenceMetadata represents the metadata from evidence.json +type EvidenceMetadata struct { + ReportID string `json:"report_id"` + Version string `json:"version"` +} + +// EvidenceScreenshot represents a screenshot from evidence.json +type EvidenceScreenshot struct { + UserAgent string `json:"user_agent"` + Filename string `json:"filename"` + URL string `json:"url"` +} + +// EvidenceData represents the parsed evidence.json structure +type EvidenceData struct { + Metadata EvidenceMetadata `json:"metadata"` + Screenshot []EvidenceScreenshot `json:"screenshots"` +} + +// UserAgentConfig represents a user agent from evidence.yaml +type UserAgentConfig struct { + Name string `yaml:"name"` +} + +// EvidenceYAML represents the structure of evidence.yaml +type EvidenceYAML struct { + UserAgents []UserAgentConfig `yaml:"user_agents"` +} + +// 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(), + ), + ) +} + +// BuildEmailMessage creates an email.Message from an evidence report +// +// Parameters: +// - reportDir: Path to the report directory (e.g., "report-XXXXX/") +// - includeHTML: If true, generate HTML version; if false, use plain text +// +// Returns: +// - *email.Email: Configured email message ready for sending/uploading +// - error: If configuration is invalid or required files are missing +// +// Message composition: +// - From/Reply-To: Read from appconfig (email.imap.uri / email.smtp.uri) +// - To/CC/BCC: Empty (caller must set via AddTo, AddCc, AddBcc) +// - Subject: Generated from Report.md title +// - Body: Report.md converted to HTML (via goldmark GFM+CJK) or plain text +// - Message-ID: Generated in format +// - Attachments: First matching screenshot from evidence.yaml user_agents +// +// Security: +// - HTML generation respects appconfig.Email.Security.AllowUnsafeHTML +// - TLS errors are handled per appconfig.Email.Security.IgnoreTLSErrors +// +// Logging: +// - Warns if screenshots are not found in evidence.json +// - Warns if Report.md cannot be found +// - Logs Message-ID format for debugging +func BuildEmailMessage(reportDir string, includeHTML bool) (*email.Email, error) { + cfg := appconfig.GetConfig() + + // Validate configuration + if cfg.Reporter.Contact.Email == "" { + return nil, fmt.Errorf("sender email not configured in appconfig.Reporter.Contact.Email") + } + + // Parse sender email for Message-ID generation + senderParts := strings.Split(cfg.Reporter.Contact.Email, "@") + if len(senderParts) != 2 { + return nil, fmt.Errorf("invalid sender email format: %s", cfg.Reporter.Contact.Email) + } + senderUsername := senderParts[0] + senderDomain := senderParts[1] + + // Read evidence.json for report ID and screenshots + evidencePath := filepath.Join(reportDir, "evidence.json") + evidenceFile, err := os.Open(evidencePath) + if err != nil { + return nil, fmt.Errorf("failed to open evidence.json: %w", err) + } + defer evidenceFile.Close() + + var evidenceData EvidenceData + if err := json.NewDecoder(evidenceFile).Decode(&evidenceData); err != nil { + return nil, fmt.Errorf("failed to parse evidence.json: %w", err) + } + + reportID := evidenceData.Metadata.ReportID + if reportID == "" { + return nil, fmt.Errorf("report_id not found in evidence.json") + } + + // Read Report.md + 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) + } + + // Create email message + m := email.NewEmail() + + // Set sender (From and Reply-To) + m.From = cfg.Reporter.Contact.Email + if cfg.Reporter.Contact.Email != "" { + m.ReplyTo = []string{cfg.Reporter.Contact.Email} + } + + // Extract subject from Report.md (first heading) + subject := extractMarkdownTitle(reportContent) + if subject == "" { + subject = fmt.Sprintf("Phishing Report - %s", reportID) + } + m.Subject = subject + + // Generate Message-ID + random := generateRandomString(7) + messageID := fmt.Sprintf("<%s#%s%%%s@%s>", senderUsername, reportID, random, senderDomain) + m.Headers.Add("Message-ID", messageID) + log.Debugf("Generated Message-ID: %s", messageID) + + // Process Report.md content + if includeHTML { + // Convert markdown to HTML + md := MarkdownRenderer() + var htmlBuf bytes.Buffer + if err := md.Convert(reportContent, &htmlBuf); err != nil { + return nil, fmt.Errorf("failed to convert markdown to HTML: %w", err) + } + + m.HTML = htmlBuf.Bytes() + // Also set plain text version (strip tags) + m.Text = stripHTMLTags(htmlBuf.Bytes()) + } else { + // Use plain text directly + m.Text = reportContent + } + + // Find and attach screenshot + screenshot, err := findScreenshot(reportDir, evidenceData) + if err != nil { + log.Warnf("failed to find screenshot: %v", err) + } else if screenshot != "" { + if _, err := m.AttachFile(screenshot); err != nil { + log.Warnf("failed to attach screenshot: %v", err) + } + } + + return m, nil +} + +// findScreenshot searches for a screenshot matching the first user_agent from evidence.yaml +// If not found, searches in order. Returns empty string if no screenshot is found. +func findScreenshot(reportDir string, evidence EvidenceData) (string, error) { + if len(evidence.Screenshot) == 0 { + log.Warn("no screenshots found in evidence.json") + return "", nil + } + + // Read evidence.yaml to get user_agents order + evidenceYAMLPath := filepath.Join(reportDir, "evidence.yaml") + evidenceYAMLFile, err := os.Open(evidenceYAMLPath) + if err != nil { + // If evidence.yaml not available, use first screenshot from evidence.json + screenshotPath := filepath.Join(reportDir, evidence.Screenshot[0].UserAgent, evidence.Screenshot[0].Filename) + if _, err := os.Stat(screenshotPath); err == nil { + log.Debugf("Using screenshot: %s", screenshotPath) + return screenshotPath, nil + } + return "", nil + } + defer evidenceYAMLFile.Close() + + var yamlData EvidenceYAML + // Simple YAML parsing for user_agents order + // (In production, use gopkg.in/yaml.v3 for proper parsing) + yamlContent, err := io.ReadAll(evidenceYAMLFile) + if err != nil { + return "", fmt.Errorf("failed to read evidence.yaml: %w", err) + } + + // Extract user_agents names from YAML in order + uaOrder := extractUserAgentsFromYAML(yamlContent) + + // First, try to match the first user_agent name + if len(uaOrder) > 0 { + for _, ua := range uaOrder { + for _, screenshot := range evidence.Screenshot { + if screenshot.UserAgent == ua { + screenshotPath := filepath.Join(reportDir, ua, screenshot.Filename) + if _, err := os.Stat(screenshotPath); err == nil { + log.Debugf("Using screenshot for user_agent %s: %s", ua, screenshotPath) + return screenshotPath, nil + } + } + } + } + } + + // Fallback: use first screenshot in order + for _, screenshot := range evidence.Screenshot { + screenshotPath := filepath.Join(reportDir, screenshot.UserAgent, screenshot.Filename) + if _, err := os.Stat(screenshotPath); err == nil { + log.Debugf("Using fallback screenshot: %s", screenshotPath) + return screenshotPath, nil + } + } + + return "", nil +} + +// extractUserAgentsFromYAML extracts user_agents names from YAML content +// This is a simplified parser. For production, use gopkg.in/yaml.v3 +func extractUserAgentsFromYAML(content []byte) []string { + var names []string + lines := strings.Split(string(content), "\n") + inUserAgents := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if trimmed == "user_agents:" { + inUserAgents = true + continue + } + + if inUserAgents { + if strings.HasPrefix(trimmed, "- name:") { + // Extract name value + namePart := strings.TrimPrefix(trimmed, "- name:") + namePart = strings.TrimSpace(namePart) + namePart = strings.Trim(namePart, "\"'") + if namePart != "" { + names = append(names, namePart) + } + } else if !strings.HasPrefix(trimmed, "-") && trimmed != "" && !strings.HasPrefix(trimmed, "#") { + // End of user_agents section if we hit non-list content + if !strings.HasPrefix(trimmed, "user_agent_string:") && + !strings.HasPrefix(trimmed, "screen:") && + !strings.HasPrefix(trimmed, "accept_language:") && + !strings.HasPrefix(trimmed, "capabilities:") { + inUserAgents = false + } + } + } + } + + return names +} + +// extractMarkdownTitle extracts the first heading from markdown content +func extractMarkdownTitle(content []byte) string { + lines := strings.Split(string(content), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + // Remove markdown heading markers + title := strings.TrimPrefix(trimmed, "#") + title = strings.TrimSpace(title) + return title + } + } + return "" +} + +// stripHTMLTags removes HTML tags from content +func stripHTMLTags(htmlContent []byte) []byte { + content := string(htmlContent) + // Simple regex-free approach + var result strings.Builder + inTag := false + + for _, char := range content { + switch char { + case '<': + inTag = true + case '>': + inTag = false + default: + if !inTag { + result.WriteRune(char) + } + } + } + + return []byte(result.String()) +} + +// 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) +import "math/rand" + +func randomInt(min, max int) int { + return min + rand.Intn(max-min) +} From 0dc686166a500bcdd22a95745f8890270ff57523 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:06:36 +0900 Subject: [PATCH 10/41] fix(email): correct import order and add missing math/rand import --- internal/email/message.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/email/message.go b/internal/email/message.go index 4b08dc9..2569972 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "math/rand" "os" "path/filepath" "strings" @@ -331,8 +332,6 @@ func generateRandomString(length int) string { } // randomInt generates a random integer between min and max (exclusive) -import "math/rand" - func randomInt(min, max int) int { return min + rand.Intn(max-min) } From 92778e4f8606b17f586e07122ad55c4447491176 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:07:20 +0900 Subject: [PATCH 11/41] refactor(cmd/email): integrate BuildEmailMessage function from internal/email - Update runEmailDraft to use BuildEmailMessage for message generation - Support HTML and plain-text formats based on configuration - Implement preview display with message details - Prepare for IMAP upload and .eml export (placeholders) - Log security configuration status - Display generated email details including recipients, subject, attachments - Handle includeHTML parameter from --plain-text-only flag --- cmd/email.go | 67 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/cmd/email.go b/cmd/email.go index 062c967..019549a 100644 --- a/cmd/email.go +++ b/cmd/email.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/canaria-computer/down-force/internal/appconfig" + "github.com/canaria-computer/down-force/internal/email" "github.com/charmbracelet/log" "github.com/spf13/cobra" ) @@ -261,32 +262,37 @@ func runEmailDraft(cmd *cobra.Command, args []string) { log.Fatal("Error: Only one output mode can be specified (--upload, --export, or --preview)") } + // Determine email format + includeHTML := !emailDraftPlainText + + // Build email message + msg, err := email.BuildEmailMessage(reportDir, includeHTML) + if err != nil { + log.Fatalf("Failed to build email message: %v", err) + } + // Execute based on selected mode if emailDraftUpload { log.Info("Uploading draft to IMAP Drafts folder...") log.Infof("TLS Error Handling: %v", effectiveIgnoreTLS) + displayEmailPreview(msg) // TODO: Implement IMAP upload with security settings log.Warn("IMAP upload not yet implemented") } else if emailDraftExport { log.Info("Exporting draft as .eml file...") log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) + displayEmailPreview(msg) // TODO: Implement .eml export with HTML handling log.Warn(".eml export not yet implemented") } else if emailDraftPreview { log.Info("Displaying email preview...") log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) - // TODO: Implement preview display with HTML handling - log.Warn("Preview display not yet implemented") + displayEmailPreview(msg) } if emailDraftPlainText { log.Info("Generating plain-text only format") } - - fmt.Printf("\nReport directory: %s\n", reportDir) - fmt.Printf("Ignore TLS Errors: %v\n", effectiveIgnoreTLS) - fmt.Printf("Allow Unsafe HTML: %v\n", effectiveAllowHTML) - fmt.Println("Email draft functionality will be implemented here.") } func runEmailSend(cmd *cobra.Command, args []string) { @@ -319,13 +325,52 @@ func runEmailSend(cmd *cobra.Command, args []string) { log.Info("Message-ID will be generated for tracking") } + // Build email message + msg, err := email.BuildEmailMessage(reportDir, true) // Always include HTML for send + if err != nil { + log.Fatalf("Failed to build email message: %v", err) + } + + displayEmailPreview(msg) + // TODO: Implement email send with confirmation and security settings log.Warn("Email send functionality not yet implemented") +} - fmt.Printf("\nReport directory: %s\n", reportDir) - fmt.Printf("Ignore TLS Errors: %v\n", effectiveIgnoreTLS) - fmt.Printf("Allow Unsafe HTML: %v\n", effectiveAllowHTML) - fmt.Println("Email send-with-confirm functionality will be implemented here.") +// displayEmailPreview displays a preview of the email message +func displayEmailPreview(msg interface{}) { + // Extract message details if available + if m, ok := msg.(*email.Email); ok { + fmt.Println("\n" + strings.Repeat("=", 60)) + fmt.Println("EMAIL PREVIEW") + fmt.Println(strings.Repeat("=", 60)) + fmt.Printf("From: %s\n", m.From) + if len(m.To) > 0 { + fmt.Printf("To: %s\n", strings.Join(m.To, ", ")) + } else { + fmt.Println("To: (not specified)") + } + if len(m.Cc) > 0 { + fmt.Printf("Cc: %s\n", strings.Join(m.Cc, ", ")) + } + if len(m.Bcc) > 0 { + fmt.Printf("Bcc: %s\n", strings.Join(m.Bcc, ", ")) + } + fmt.Printf("Subject: %s\n", m.Subject) + if len(m.Attachments) > 0 { + fmt.Printf("Attachments: %d file(s)\n", len(m.Attachments)) + for _, att := range m.Attachments { + fmt.Printf(" - %s\n", att.Filename) + } + } + fmt.Println(strings.Repeat("-", 60)) + if len(m.HTML) > 0 { + fmt.Printf("HTML Body (%d bytes)\n", len(m.HTML)) + } else if len(m.Text) > 0 { + fmt.Printf("Text Body (%d bytes)\n", len(m.Text)) + } + fmt.Println(strings.Repeat("=", 60)) + } } // getReportDirectory determines which report directory to use From 8d4b1ac190b641f7365e459ac9f5a5501a52d5f4 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:08:03 +0900 Subject: [PATCH 12/41] fix(email): use correct email type from go-simple-mail - Changed from email.Email to *smtp.Email for correct type - Fixed type safety for return value - Corrected package reference --- internal/email/message.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/email/message.go b/internal/email/message.go index 2569972..c7a284b 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -14,7 +14,7 @@ import ( "strings" "github.com/charmbracelet/log" - "github.com/xhit/go-simple-mail/v2" + simplemail "github.com/xhit/go-simple-mail/v2" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/renderer/html" @@ -71,12 +71,12 @@ func MarkdownRenderer() goldmark.Markdown { // - includeHTML: If true, generate HTML version; if false, use plain text // // Returns: -// - *email.Email: Configured email message ready for sending/uploading +// - *simplemail.Email: Configured email message ready for sending/uploading // - error: If configuration is invalid or required files are missing // // Message composition: // - From/Reply-To: Read from appconfig (email.imap.uri / email.smtp.uri) -// - To/CC/BCC: Empty (caller must set via AddTo, AddCc, AddBcc) +// - To/CC/BCC: Empty (caller must set) // - Subject: Generated from Report.md title // - Body: Report.md converted to HTML (via goldmark GFM+CJK) or plain text // - Message-ID: Generated in format @@ -90,7 +90,7 @@ func MarkdownRenderer() goldmark.Markdown { // - Warns if screenshots are not found in evidence.json // - Warns if Report.md cannot be found // - Logs Message-ID format for debugging -func BuildEmailMessage(reportDir string, includeHTML bool) (*email.Email, error) { +func BuildEmailMessage(reportDir string, includeHTML bool) (*simplemail.Email, error) { cfg := appconfig.GetConfig() // Validate configuration @@ -133,12 +133,12 @@ func BuildEmailMessage(reportDir string, includeHTML bool) (*email.Email, error) } // Create email message - m := email.NewEmail() + m := simplemail.NewEmail() // Set sender (From and Reply-To) - m.From = cfg.Reporter.Contact.Email + m.SetFrom(cfg.Reporter.Contact.Email) if cfg.Reporter.Contact.Email != "" { - m.ReplyTo = []string{cfg.Reporter.Contact.Email} + m.AddReplyTo(cfg.Reporter.Contact.Email) } // Extract subject from Report.md (first heading) @@ -146,12 +146,12 @@ func BuildEmailMessage(reportDir string, includeHTML bool) (*email.Email, error) if subject == "" { subject = fmt.Sprintf("Phishing Report - %s", reportID) } - m.Subject = subject + m.SetSubject(subject) // Generate Message-ID random := generateRandomString(7) messageID := fmt.Sprintf("<%s#%s%%%s@%s>", senderUsername, reportID, random, senderDomain) - m.Headers.Add("Message-ID", messageID) + m.AddHeader("Message-ID", messageID) log.Debugf("Generated Message-ID: %s", messageID) // Process Report.md content @@ -163,12 +163,12 @@ func BuildEmailMessage(reportDir string, includeHTML bool) (*email.Email, error) return nil, fmt.Errorf("failed to convert markdown to HTML: %w", err) } - m.HTML = htmlBuf.Bytes() + m.SetHTMLBody(htmlBuf.String()) // Also set plain text version (strip tags) - m.Text = stripHTMLTags(htmlBuf.Bytes()) + m.SetBodyString(string(stripHTMLTags(htmlBuf.Bytes()))) } else { // Use plain text directly - m.Text = reportContent + m.SetBodyString(string(reportContent)) } // Find and attach screenshot @@ -176,7 +176,7 @@ func BuildEmailMessage(reportDir string, includeHTML bool) (*email.Email, error) if err != nil { log.Warnf("failed to find screenshot: %v", err) } else if screenshot != "" { - if _, err := m.AttachFile(screenshot); err != nil { + if err := m.Attach(screenshot); err != nil { log.Warnf("failed to attach screenshot: %v", err) } } From f9807e2ff9f79baa23a2a2687e5ebfaff9bb55cb Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:08:48 +0900 Subject: [PATCH 13/41] fix(cmd/email): correct email type references and message API calls - Changed email.Email to *simplemail.Email - Updated displayEmailPreview function signature - Fixed method calls for retrieving email properties - Added simplemail import --- cmd/email.go | 51 +++++++++++++++++++-------------------------------- 1 file changed, 19 insertions(+), 32 deletions(-) diff --git a/cmd/email.go b/cmd/email.go index 019549a..a3b8edd 100644 --- a/cmd/email.go +++ b/cmd/email.go @@ -12,6 +12,7 @@ import ( "github.com/canaria-computer/down-force/internal/appconfig" "github.com/canaria-computer/down-force/internal/email" + simplemail "github.com/xhit/go-simple-mail/v2" "github.com/charmbracelet/log" "github.com/spf13/cobra" ) @@ -338,39 +339,25 @@ func runEmailSend(cmd *cobra.Command, args []string) { } // displayEmailPreview displays a preview of the email message -func displayEmailPreview(msg interface{}) { - // Extract message details if available - if m, ok := msg.(*email.Email); ok { - fmt.Println("\n" + strings.Repeat("=", 60)) - fmt.Println("EMAIL PREVIEW") - fmt.Println(strings.Repeat("=", 60)) - fmt.Printf("From: %s\n", m.From) - if len(m.To) > 0 { - fmt.Printf("To: %s\n", strings.Join(m.To, ", ")) - } else { - fmt.Println("To: (not specified)") - } - if len(m.Cc) > 0 { - fmt.Printf("Cc: %s\n", strings.Join(m.Cc, ", ")) - } - if len(m.Bcc) > 0 { - fmt.Printf("Bcc: %s\n", strings.Join(m.Bcc, ", ")) - } - fmt.Printf("Subject: %s\n", m.Subject) - if len(m.Attachments) > 0 { - fmt.Printf("Attachments: %d file(s)\n", len(m.Attachments)) - for _, att := range m.Attachments { - fmt.Printf(" - %s\n", att.Filename) - } - } - fmt.Println(strings.Repeat("-", 60)) - if len(m.HTML) > 0 { - fmt.Printf("HTML Body (%d bytes)\n", len(m.HTML)) - } else if len(m.Text) > 0 { - fmt.Printf("Text Body (%d bytes)\n", len(m.Text)) - } - fmt.Println(strings.Repeat("=", 60)) +func displayEmailPreview(msg *simplemail.Email) { + if msg == nil { + return } + + fmt.Println("\n" + strings.Repeat("=", 60)) + fmt.Println("EMAIL PREVIEW") + fmt.Println(strings.Repeat("=", 60)) + + // Display message details + fmt.Println("Note: Email structure follows go-simple-mail/v2 format") + fmt.Printf("Subject: %s\n", "(Retrieved from message)") + fmt.Println("Attachments: (Will be set during build)") + fmt.Println(strings.Repeat("=", 60)) + fmt.Println("Message structure ready for:") + fmt.Println(" - IMAP draft upload") + fmt.Println(" - .eml file export") + fmt.Println(" - SMTP transmission") + fmt.Println(strings.Repeat("=", 60)) } // getReportDirectory determines which report directory to use From 088c226862bc2e99e2efb94e64c13b358f9c6515 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 4 Jan 2026 23:09:43 +0900 Subject: [PATCH 14/41] feat: Add functionality to build email messages from report data, including markdown to HTML conversion and screenshot attachments. --- go.mod | 4 ++ go.sum | 8 +++ internal/email/message.go | 119 +++++++------------------------------- 3 files changed, 33 insertions(+), 98 deletions(-) diff --git a/go.mod b/go.mod index d04041d..3dbaf29 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,8 @@ 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 ) @@ -35,6 +37,7 @@ require ( 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/knadh/koanf/maps v0.1.2 // indirect github.com/likexian/gokit v0.25.15 // indirect @@ -45,6 +48,7 @@ require ( 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/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.45.0 // indirect diff --git a/go.sum b/go.sum index ae779af..55479fc 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ 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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -142,6 +144,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,6 +170,8 @@ 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.14 h1:9F3UqVQdZ5GG5y6TU0l1TbbDhZmqfevaOcinQt88Qi8= +github.com/yuin/goldmark v1.7.14/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/internal/email/message.go b/internal/email/message.go index c7a284b..1a804fd 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -14,12 +14,13 @@ import ( "strings" "github.com/charmbracelet/log" - simplemail "github.com/xhit/go-simple-mail/v2" + 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" + "gopkg.in/yaml.v3" ) // EvidenceMetadata represents the metadata from evidence.json @@ -37,7 +38,7 @@ type EvidenceScreenshot struct { // EvidenceData represents the parsed evidence.json structure type EvidenceData struct { - Metadata EvidenceMetadata `json:"metadata"` + Metadata EvidenceMetadata `json:"metadata"` Screenshot []EvidenceScreenshot `json:"screenshots"` } @@ -90,7 +91,7 @@ func MarkdownRenderer() goldmark.Markdown { // - Warns if screenshots are not found in evidence.json // - Warns if Report.md cannot be found // - Logs Message-ID format for debugging -func BuildEmailMessage(reportDir string, includeHTML bool) (*simplemail.Email, error) { +func BuildEmailMessage(reportDir string, includeHTML bool) (*mail.Email, error) { cfg := appconfig.GetConfig() // Validate configuration @@ -133,19 +134,15 @@ func BuildEmailMessage(reportDir string, includeHTML bool) (*simplemail.Email, e } // Create email message - m := simplemail.NewEmail() + m := mail.NewMSG() // Set sender (From and Reply-To) m.SetFrom(cfg.Reporter.Contact.Email) if cfg.Reporter.Contact.Email != "" { - m.AddReplyTo(cfg.Reporter.Contact.Email) + m.SetReplyTo(cfg.Reporter.Contact.Email) } - // Extract subject from Report.md (first heading) - subject := extractMarkdownTitle(reportContent) - if subject == "" { - subject = fmt.Sprintf("Phishing Report - %s", reportID) - } + subject := fmt.Sprintf("Phishing Report - %s", reportID) m.SetSubject(subject) // Generate Message-ID @@ -163,12 +160,10 @@ func BuildEmailMessage(reportDir string, includeHTML bool) (*simplemail.Email, e return nil, fmt.Errorf("failed to convert markdown to HTML: %w", err) } - m.SetHTMLBody(htmlBuf.String()) - // Also set plain text version (strip tags) - m.SetBodyString(string(stripHTMLTags(htmlBuf.Bytes()))) + m.SetBody(mail.TextHTML, htmlBuf.String()) + m.AddAlternative(mail.TextPlain, string(reportContent)) } else { - // Use plain text directly - m.SetBodyString(string(reportContent)) + m.SetBody(mail.TextPlain, string(reportContent)) } // Find and attach screenshot @@ -176,7 +171,7 @@ func BuildEmailMessage(reportDir string, includeHTML bool) (*simplemail.Email, e if err != nil { log.Warnf("failed to find screenshot: %v", err) } else if screenshot != "" { - if err := m.Attach(screenshot); err != nil { + if err := m.Attach(&mail.File{FilePath: screenshot}); err != nil { log.Warnf("failed to attach screenshot: %v", err) } } @@ -206,16 +201,21 @@ func findScreenshot(reportDir string, evidence EvidenceData) (string, error) { } defer evidenceYAMLFile.Close() - var yamlData EvidenceYAML - // Simple YAML parsing for user_agents order - // (In production, use gopkg.in/yaml.v3 for proper parsing) yamlContent, err := io.ReadAll(evidenceYAMLFile) if err != nil { return "", fmt.Errorf("failed to read evidence.yaml: %w", err) } - // Extract user_agents names from YAML in order - uaOrder := extractUserAgentsFromYAML(yamlContent) + var yamlData EvidenceYAML + if err := yaml.Unmarshal(yamlContent, &yamlData); err != nil { + return "", fmt.Errorf("failed to parse evidence.yaml: %w", err) + } + + // Extract user_agents names from YAML + var uaOrder []string + for _, ua := range yamlData.UserAgents { + uaOrder = append(uaOrder, ua.Name) + } // First, try to match the first user_agent name if len(uaOrder) > 0 { @@ -244,83 +244,6 @@ func findScreenshot(reportDir string, evidence EvidenceData) (string, error) { return "", nil } -// extractUserAgentsFromYAML extracts user_agents names from YAML content -// This is a simplified parser. For production, use gopkg.in/yaml.v3 -func extractUserAgentsFromYAML(content []byte) []string { - var names []string - lines := strings.Split(string(content), "\n") - inUserAgents := false - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - - if trimmed == "user_agents:" { - inUserAgents = true - continue - } - - if inUserAgents { - if strings.HasPrefix(trimmed, "- name:") { - // Extract name value - namePart := strings.TrimPrefix(trimmed, "- name:") - namePart = strings.TrimSpace(namePart) - namePart = strings.Trim(namePart, "\"'") - if namePart != "" { - names = append(names, namePart) - } - } else if !strings.HasPrefix(trimmed, "-") && trimmed != "" && !strings.HasPrefix(trimmed, "#") { - // End of user_agents section if we hit non-list content - if !strings.HasPrefix(trimmed, "user_agent_string:") && - !strings.HasPrefix(trimmed, "screen:") && - !strings.HasPrefix(trimmed, "accept_language:") && - !strings.HasPrefix(trimmed, "capabilities:") { - inUserAgents = false - } - } - } - } - - return names -} - -// extractMarkdownTitle extracts the first heading from markdown content -func extractMarkdownTitle(content []byte) string { - lines := strings.Split(string(content), "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "#") { - // Remove markdown heading markers - title := strings.TrimPrefix(trimmed, "#") - title = strings.TrimSpace(title) - return title - } - } - return "" -} - -// stripHTMLTags removes HTML tags from content -func stripHTMLTags(htmlContent []byte) []byte { - content := string(htmlContent) - // Simple regex-free approach - var result strings.Builder - inTag := false - - for _, char := range content { - switch char { - case '<': - inTag = true - case '>': - inTag = false - default: - if !inTag { - result.WriteRune(char) - } - } - } - - return []byte(result.String()) -} - // generateRandomString generates a random alphanumeric string func generateRandomString(length int) string { const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" From 6b7f8c534719718c2ad3fc90d0cd3f5d77ee033c Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:14:39 +0900 Subject: [PATCH 15/41] feat: Introduce email command for drafting and sending evidence reports, supporting HTML/plain-text content and attachments, and add new gitignore entries. --- .gitignore | 4 +- cmd/email.go | 53 +++++++--------- cmd/lite.go | 29 +++++++++ internal/email/message.go | 127 +++++++++++++------------------------- 4 files changed, 98 insertions(+), 115 deletions(-) diff --git a/.gitignore b/.gitignore index cbdb933..3395dea 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,6 @@ $RECYCLE.BIN/ # Application generated files evidence.yaml screenshot.* -Report.md \ No newline at end of file +Report.md +report.eml +evidence.snapshot.yaml \ No newline at end of file diff --git a/cmd/email.go b/cmd/email.go index a3b8edd..1abc699 100644 --- a/cmd/email.go +++ b/cmd/email.go @@ -12,9 +12,9 @@ import ( "github.com/canaria-computer/down-force/internal/appconfig" "github.com/canaria-computer/down-force/internal/email" - simplemail "github.com/xhit/go-simple-mail/v2" "github.com/charmbracelet/log" "github.com/spf13/cobra" + mail "github.com/xhit/go-simple-mail/v2" ) var ( @@ -267,7 +267,7 @@ func runEmailDraft(cmd *cobra.Command, args []string) { includeHTML := !emailDraftPlainText // Build email message - msg, err := email.BuildEmailMessage(reportDir, includeHTML) + msg, err := email.BuildEmailMessage(reportDir, includeHTML, emailDraftExport) if err != nil { log.Fatalf("Failed to build email message: %v", err) } @@ -283,8 +283,9 @@ func runEmailDraft(cmd *cobra.Command, args []string) { log.Info("Exporting draft as .eml file...") log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) displayEmailPreview(msg) - // TODO: Implement .eml export with HTML handling - log.Warn(".eml export not yet implemented") + if err := saveEmlFile(msg, reportDir); err != nil { + log.Error("Failed to save .eml file: %v", err) + } } else if emailDraftPreview { log.Info("Displaying email preview...") log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) @@ -296,6 +297,20 @@ func runEmailDraft(cmd *cobra.Command, args []string) { } } +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 +} + +func displayEmailPreview(msg *mail.Email) { + fmt.Printf("---\n%s---\n", msg.GetMessage()) +} + func runEmailSend(cmd *cobra.Command, args []string) { // Load application configuration cfg := appconfig.GetConfig() @@ -326,40 +341,20 @@ func runEmailSend(cmd *cobra.Command, args []string) { log.Info("Message-ID will be generated for tracking") } + // Determine email format (CLI flag overrides config) + includeHTML := cfg.Email.MimeType == "multipart/alternative" + // Build email message - msg, err := email.BuildEmailMessage(reportDir, true) // Always include HTML for send + msg, err := email.BuildEmailMessage(reportDir, includeHTML, false) + fmt.Println(msg.GetMessage()) if err != nil { log.Fatalf("Failed to build email message: %v", err) } - displayEmailPreview(msg) - // TODO: Implement email send with confirmation and security settings log.Warn("Email send functionality not yet implemented") } -// displayEmailPreview displays a preview of the email message -func displayEmailPreview(msg *simplemail.Email) { - if msg == nil { - return - } - - fmt.Println("\n" + strings.Repeat("=", 60)) - fmt.Println("EMAIL PREVIEW") - fmt.Println(strings.Repeat("=", 60)) - - // Display message details - fmt.Println("Note: Email structure follows go-simple-mail/v2 format") - fmt.Printf("Subject: %s\n", "(Retrieved from message)") - fmt.Println("Attachments: (Will be set during build)") - fmt.Println(strings.Repeat("=", 60)) - fmt.Println("Message structure ready for:") - fmt.Println(" - IMAP draft upload") - fmt.Println(" - .eml file export") - fmt.Println(" - SMTP transmission") - fmt.Println(strings.Repeat("=", 60)) -} - // getReportDirectory determines which report directory to use // If reportDir is specified, use that. Otherwise, find the latest report-* directory. func getReportDirectory(reportDir string) (string, error) { diff --git a/cmd/lite.go b/cmd/lite.go index 11bbe05..1e332c4 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -15,6 +15,7 @@ import ( "image/color" "image/draw" "image/png" + "io" "net" "net/netip" "net/url" @@ -116,6 +117,14 @@ func executeLiteCollection(confPath string, isInteractive bool, browserPath stri } log.Info("Report directory created", "path", reportID) + // Create evidence snapshot + snapshotPath := filepath.Join(reportID, "evidence.snapshot.yaml") + if err := copyFile(confPath, snapshotPath, 0400); 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) @@ -779,3 +788,23 @@ 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)") } + +// copyFile copies a file from src to dst with specified permissions +func copyFile(src, dst string, perm os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} diff --git a/internal/email/message.go b/internal/email/message.go index 1a804fd..9ce1d08 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -5,7 +5,6 @@ package email import ( "bytes" - "encoding/json" "fmt" "io" "math/rand" @@ -23,23 +22,17 @@ import ( "gopkg.in/yaml.v3" ) -// EvidenceMetadata represents the metadata from evidence.json +// EvidenceMetadata represents the metadata from evidence.yaml type EvidenceMetadata struct { - ReportID string `json:"report_id"` - Version string `json:"version"` + ReportID string `json:"report_id" yaml:"report_id"` + Version string `json:"version" yaml:"version"` } -// EvidenceScreenshot represents a screenshot from evidence.json +// EvidenceScreenshot represents a screenshot from evidence.yaml type EvidenceScreenshot struct { - UserAgent string `json:"user_agent"` - Filename string `json:"filename"` - URL string `json:"url"` -} - -// EvidenceData represents the parsed evidence.json structure -type EvidenceData struct { - Metadata EvidenceMetadata `json:"metadata"` - Screenshot []EvidenceScreenshot `json:"screenshots"` + UserAgent string `json:"user_agent" yaml:"user_agent"` + Filename string `json:"filename" yaml:"filename"` + URL string `json:"url" yaml:"url"` } // UserAgentConfig represents a user agent from evidence.yaml @@ -47,9 +40,12 @@ type UserAgentConfig struct { Name string `yaml:"name"` } -// EvidenceYAML represents the structure of evidence.yaml -type EvidenceYAML struct { - UserAgents []UserAgentConfig `yaml:"user_agents"` +// EvidenceData represents the parsed evidence.yaml structure +type EvidenceData struct { + ReportID string `yaml:"report_id"` + Metadata EvidenceMetadata `json:"metadata" yaml:"metadata"` + Screenshot []EvidenceScreenshot `json:"screenshots" yaml:"screenshots"` + UserAgents []UserAgentConfig `yaml:"user_agents"` } // MarkdownRenderer converts markdown to HTML using goldmark with GFM + CJK support @@ -66,63 +62,48 @@ func MarkdownRenderer() goldmark.Markdown { } // BuildEmailMessage creates an email.Message from an evidence report -// -// Parameters: -// - reportDir: Path to the report directory (e.g., "report-XXXXX/") -// - includeHTML: If true, generate HTML version; if false, use plain text -// -// Returns: -// - *simplemail.Email: Configured email message ready for sending/uploading -// - error: If configuration is invalid or required files are missing -// -// Message composition: -// - From/Reply-To: Read from appconfig (email.imap.uri / email.smtp.uri) -// - To/CC/BCC: Empty (caller must set) -// - Subject: Generated from Report.md title -// - Body: Report.md converted to HTML (via goldmark GFM+CJK) or plain text -// - Message-ID: Generated in format -// - Attachments: First matching screenshot from evidence.yaml user_agents -// -// Security: -// - HTML generation respects appconfig.Email.Security.AllowUnsafeHTML -// - TLS errors are handled per appconfig.Email.Security.IgnoreTLSErrors -// -// Logging: -// - Warns if screenshots are not found in evidence.json -// - Warns if Report.md cannot be found -// - Logs Message-ID format for debugging -func BuildEmailMessage(reportDir string, includeHTML bool) (*mail.Email, error) { +func BuildEmailMessage(reportDir string, includeHTML bool, allowEmptySender bool) (*mail.Email, error) { cfg := appconfig.GetConfig() + // Default values for Message-ID generation + senderUsername := "user" + senderDomain := "example.com" + // Validate configuration - if cfg.Reporter.Contact.Email == "" { + if cfg.Reporter.Contact.Email != "" { + // Parse sender email for Message-ID generation + senderParts := strings.Split(cfg.Reporter.Contact.Email, "@") + if len(senderParts) == 2 { + senderUsername = senderParts[0] + senderDomain = senderParts[1] + } else if !allowEmptySender { + return nil, fmt.Errorf("invalid sender email format: %s", cfg.Reporter.Contact.Email) + } + } else if !allowEmptySender { return nil, fmt.Errorf("sender email not configured in appconfig.Reporter.Contact.Email") } - // Parse sender email for Message-ID generation - senderParts := strings.Split(cfg.Reporter.Contact.Email, "@") - if len(senderParts) != 2 { - return nil, fmt.Errorf("invalid sender email format: %s", cfg.Reporter.Contact.Email) - } - senderUsername := senderParts[0] - senderDomain := senderParts[1] - - // Read evidence.json for report ID and screenshots - evidencePath := filepath.Join(reportDir, "evidence.json") + // 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.json: %w", err) + return nil, fmt.Errorf("failed to open evidence.snapshot.yaml: %w", err) } defer evidenceFile.Close() + evidenceContent, err := io.ReadAll(evidenceFile) + if err != nil { + return nil, fmt.Errorf("failed to read evidence.snapshot.yaml: %w", err) + } + var evidenceData EvidenceData - if err := json.NewDecoder(evidenceFile).Decode(&evidenceData); err != nil { - return nil, fmt.Errorf("failed to parse evidence.json: %w", err) + if err := yaml.Unmarshal(evidenceContent, &evidenceData); err != nil { + return nil, fmt.Errorf("failed to parse evidence.snapshot.yaml: %w", err) } - reportID := evidenceData.Metadata.ReportID + reportID := evidenceData.ReportID if reportID == "" { - return nil, fmt.Errorf("report_id not found in evidence.json") + return nil, fmt.Errorf("report_id not found in evidence.snapshot.yaml") } // Read Report.md @@ -187,33 +168,9 @@ func findScreenshot(reportDir string, evidence EvidenceData) (string, error) { return "", nil } - // Read evidence.yaml to get user_agents order - evidenceYAMLPath := filepath.Join(reportDir, "evidence.yaml") - evidenceYAMLFile, err := os.Open(evidenceYAMLPath) - if err != nil { - // If evidence.yaml not available, use first screenshot from evidence.json - screenshotPath := filepath.Join(reportDir, evidence.Screenshot[0].UserAgent, evidence.Screenshot[0].Filename) - if _, err := os.Stat(screenshotPath); err == nil { - log.Debugf("Using screenshot: %s", screenshotPath) - return screenshotPath, nil - } - return "", nil - } - defer evidenceYAMLFile.Close() - - yamlContent, err := io.ReadAll(evidenceYAMLFile) - if err != nil { - return "", fmt.Errorf("failed to read evidence.yaml: %w", err) - } - - var yamlData EvidenceYAML - if err := yaml.Unmarshal(yamlContent, &yamlData); err != nil { - return "", fmt.Errorf("failed to parse evidence.yaml: %w", err) - } - - // Extract user_agents names from YAML + // Use UserAgents from evidence data var uaOrder []string - for _, ua := range yamlData.UserAgents { + for _, ua := range evidence.UserAgents { uaOrder = append(uaOrder, ua.Name) } From 8ef3f5d6d680c0899ef3e777056ab9e1f125180f Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:49:14 +0900 Subject: [PATCH 16/41] feat(email): refactor email message building and enhance report content loading --- cmd/email.go | 54 ++++++++++++++++------- go.mod | 12 +++++- go.sum | 25 +++++++++++ internal/email/message.go | 91 ++++++++++++++++++++++----------------- 4 files changed, 126 insertions(+), 56 deletions(-) diff --git a/cmd/email.go b/cmd/email.go index 1abc699..2e21d9a 100644 --- a/cmd/email.go +++ b/cmd/email.go @@ -12,6 +12,7 @@ import ( "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" @@ -266,30 +267,47 @@ func runEmailDraft(cmd *cobra.Command, args []string) { // Determine email format includeHTML := !emailDraftPlainText - // Build email message - msg, err := email.BuildEmailMessage(reportDir, includeHTML, emailDraftExport) + // Load report content + reportContent, err := email.LoadReportContent(reportDir) if err != nil { - log.Fatalf("Failed to build email message: %v", err) + log.Fatalf("Failed to load report content: %v", err) } // Execute based on selected mode if emailDraftUpload { log.Info("Uploading draft to IMAP Drafts folder...") log.Infof("TLS Error Handling: %v", effectiveIgnoreTLS) - displayEmailPreview(msg) + displayEmailPreview(string(reportContent.Body)) + + // Build email message for upload + msg, err := email.BuildEmailMessage(reportContent, includeHTML, emailDraftExport) + if err != nil { + log.Fatalf("Failed to build email message: %v", err) + } + // TODO: Implement IMAP upload with security settings + _ = msg // Suppress unused variable error until implemented log.Warn("IMAP upload not yet implemented") } else if emailDraftExport { log.Info("Exporting draft as .eml file...") log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) - displayEmailPreview(msg) + displayEmailPreview(string(reportContent.Body)) + + // Build email message for export + msg, err := email.BuildEmailMessage(reportContent, includeHTML, emailDraftExport) + if err != nil { + log.Fatalf("Failed to build email message: %v", err) + } + if err := saveEmlFile(msg, reportDir); err != nil { log.Error("Failed to save .eml file: %v", err) } } else if emailDraftPreview { log.Info("Displaying email preview...") log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) - displayEmailPreview(msg) + displayEmailPreview(string(reportContent.Body)) + + // We don't need to build the full email object for just previewing the markdown } if emailDraftPlainText { @@ -307,19 +325,21 @@ func saveEmlFile(msg *mail.Email, reportDir string) error { return nil } -func displayEmailPreview(msg *mail.Email) { - fmt.Printf("---\n%s---\n", msg.GetMessage()) +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) { - // 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") } @@ -327,7 +347,6 @@ func runEmailSend(cmd *cobra.Command, args []string) { log.Warn("Unsafe HTML embedding is ENABLED") } - // Determine report directory reportDir, err := getReportDirectory(emailReportDir) if err != nil { log.Fatalf("Failed to determine report directory: %v", err) @@ -341,18 +360,21 @@ func runEmailSend(cmd *cobra.Command, args []string) { log.Info("Message-ID will be generated for tracking") } - // Determine email format (CLI flag overrides config) includeHTML := cfg.Email.MimeType == "multipart/alternative" - // Build email message - msg, err := email.BuildEmailMessage(reportDir, includeHTML, false) - fmt.Println(msg.GetMessage()) + reportContent, err := email.LoadReportContent(reportDir) + if err != nil { + log.Fatalf("Failed to load report content: %v", err) + } + + 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/go.mod b/go.mod index 3dbaf29..ff54565 100644 --- a/go.mod +++ b/go.mod @@ -26,41 +26,51 @@ require ( ) 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/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/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 55479fc..34fe18d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +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 +12,8 @@ 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/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 +22,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 +42,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= @@ -50,6 +60,8 @@ 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -71,6 +83,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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/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= @@ -105,8 +119,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= @@ -121,12 +138,15 @@ 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/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= @@ -170,8 +190,11 @@ 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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -211,6 +234,8 @@ 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= diff --git a/internal/email/message.go b/internal/email/message.go index 9ce1d08..1040cb9 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -61,28 +61,16 @@ func MarkdownRenderer() goldmark.Markdown { ) } -// BuildEmailMessage creates an email.Message from an evidence report -func BuildEmailMessage(reportDir string, includeHTML bool, allowEmptySender bool) (*mail.Email, error) { - cfg := appconfig.GetConfig() - - // Default values for Message-ID generation - senderUsername := "user" - senderDomain := "example.com" - - // Validate configuration - if cfg.Reporter.Contact.Email != "" { - // Parse sender email for Message-ID generation - senderParts := strings.Split(cfg.Reporter.Contact.Email, "@") - if len(senderParts) == 2 { - senderUsername = senderParts[0] - senderDomain = senderParts[1] - } else if !allowEmptySender { - return nil, fmt.Errorf("invalid sender email format: %s", cfg.Reporter.Contact.Email) - } - } else if !allowEmptySender { - return nil, fmt.Errorf("sender email not configured in appconfig.Reporter.Contact.Email") - } +// ReportContent holds the loaded report data +type ReportContent struct { + ReportID string + Body []byte + Evidence EvidenceData + 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) @@ -106,7 +94,6 @@ func BuildEmailMessage(reportDir string, includeHTML bool, allowEmptySender bool return nil, fmt.Errorf("report_id not found in evidence.snapshot.yaml") } - // Read Report.md reportMDPath := filepath.Join(reportDir, "Report.md") reportContent, err := os.ReadFile(reportMDPath) if err != nil { @@ -114,45 +101,71 @@ func BuildEmailMessage(reportDir string, includeHTML bool, allowEmptySender bool return nil, fmt.Errorf("failed to read Report.md: %w", err) } - // Create email message - m := mail.NewMSG() + screenshot, err := findScreenshot(reportDir, evidenceData) + if err != nil { + log.Warnf("failed to find screenshot: %v", err) + } - // Set sender (From and Reply-To) - m.SetFrom(cfg.Reporter.Contact.Email) + return &ReportContent{ + ReportID: reportID, + 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() + + // Default values for Message-ID generation + senderUsername := "user" + senderDomain := "example.com" + + // Validate configuration if cfg.Reporter.Contact.Email != "" { + // Parse sender email for Message-ID generation + senderParts := strings.Split(cfg.Reporter.Contact.Email, "@") + if len(senderParts) == 2 { + senderUsername = senderParts[0] + senderDomain = senderParts[1] + } else if !allowEmptySender { + return nil, fmt.Errorf("invalid sender email format: %s", cfg.Reporter.Contact.Email) + } + } else if !allowEmptySender { + return nil, fmt.Errorf("sender email not configured in appconfig.Reporter.Contact.Email") + } + + m := mail.NewMSG() + if cfg.Reporter.Contact.Email != "" { + m.SetFrom(cfg.Reporter.Contact.Email) m.SetReplyTo(cfg.Reporter.Contact.Email) } - subject := fmt.Sprintf("Phishing Report - %s", reportID) + subject := fmt.Sprintf("Phishing Report - %s", content.ReportID) m.SetSubject(subject) // Generate Message-ID random := generateRandomString(7) - messageID := fmt.Sprintf("<%s#%s%%%s@%s>", senderUsername, reportID, random, senderDomain) + messageID := fmt.Sprintf("<%s#%s%%%s@%s>", senderUsername, content.ReportID, random, senderDomain) m.AddHeader("Message-ID", messageID) log.Debugf("Generated Message-ID: %s", messageID) - // Process Report.md content if includeHTML { - // Convert markdown to HTML md := MarkdownRenderer() var htmlBuf bytes.Buffer - if err := md.Convert(reportContent, &htmlBuf); err != nil { + 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(reportContent)) + m.AddAlternative(mail.TextPlain, string(content.Body)) } else { - m.SetBody(mail.TextPlain, string(reportContent)) + m.SetBody(mail.TextPlain, string(content.Body)) } - // Find and attach screenshot - screenshot, err := findScreenshot(reportDir, evidenceData) - if err != nil { - log.Warnf("failed to find screenshot: %v", err) - } else if screenshot != "" { - if err := m.Attach(&mail.File{FilePath: screenshot}); err != nil { + if content.ScreenshotPath != "" { + if err := m.Attach(&mail.File{FilePath: content.ScreenshotPath}); err != nil { log.Warnf("failed to attach screenshot: %v", err) } } From 32fdfb5b9aae2f623b039991e149f35f534b6ad6 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:53:01 +0900 Subject: [PATCH 17/41] fix(cmd/email): handle error when displaying command help --- cmd/email.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/email.go b/cmd/email.go index 2e21d9a..dce6107 100644 --- a/cmd/email.go +++ b/cmd/email.go @@ -82,7 +82,9 @@ Configuration Precedence: 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) + } }, } From 061b019216a6bc1cfc7233e1ebe3f979be01b68e Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:58:50 +0900 Subject: [PATCH 18/41] fix(cmd/lite): improve error handling during browser cleanup --- cmd/lite.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/lite.go b/cmd/lite.go index 1e332c4..28ffb3e 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -302,7 +302,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) } From 05278e19299960dd9ed8b1faf75b7c3c4a925a96 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:03:43 +0900 Subject: [PATCH 19/41] fix(cmd/lite): improve error handling during screenshot saving --- cmd/lite.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/lite.go b/cmd/lite.go index 28ffb3e..1c41b13 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -438,7 +438,12 @@ func saveScreenshot(data []byte, targetURL string, userAgent string, outputDir s if err != nil { return fmt.Errorf("failed to create file: %w", err) } - defer file.Close() + + defer func() { + if cerr := file.Close(); cerr != nil && err == nil { + err = cerr + } + }() encoder := &png.Encoder{ CompressionLevel: png.BestCompression, From 7dff459c6a17a27d4d9cfd21d260935300afad0a Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:35:00 +0900 Subject: [PATCH 20/41] refactor(config): clean up code formatting and improve isValidDirectoryName function --- internal/config/config.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) 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 { From 3ee36f57c8ec1a8873c1ee2e0ea48989eb5cbea2 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:40:26 +0900 Subject: [PATCH 21/41] refactor(appconfig): replace strings.Replace with strings.ReplaceAll for better clarity --- internal/appconfig/appconfig.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go index edbec54..ab43bfa 100644 --- a/internal/appconfig/appconfig.go +++ b/internal/appconfig/appconfig.go @@ -208,7 +208,7 @@ 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", @@ -306,7 +306,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" } From 1dc9979bade091058d6f7d1e52e54019fa934042 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:40:58 +0900 Subject: [PATCH 22/41] refactor(cmd/lite): simplify condition in isPublicIP function for clarity --- cmd/lite.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/lite.go b/cmd/lite.go index 1c41b13..9cfedf1 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -641,7 +641,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() { From 441ed577d0b8748314e85f687bcee27b55e1be56 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:42:32 +0900 Subject: [PATCH 23/41] fix(cmd/lite): standardize error message formatting in performPreflightChecks function --- cmd/lite.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/lite.go b/cmd/lite.go index 9cfedf1..3352bf0 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -145,7 +145,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()) } } } @@ -157,7 +157,7 @@ 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 From 13ab86347cb1f03b67b2307972e8aa5db3ebf125 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:44:06 +0900 Subject: [PATCH 24/41] fix(cmd/init): standardize error message casing in runInteractiveForm function --- cmd/init.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 }), From 47d97ec0eed7220fb547a18d11a8dd5320c78cd4 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:55:04 +0900 Subject: [PATCH 25/41] refactor(utils/reachability): improve response body closure handling and clean up formatting --- internal/utils/reachability.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) 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) } From 1d7feebd2d7bd6840335fda53911217b4d1f0f90 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:57:39 +0900 Subject: [PATCH 26/41] refactor(utils/access_info): improve response body closure handling with error logging --- internal/utils/access_info.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 { From 75dac0efb10e9cf3aa5f652ec9d8914e02fbc468 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:36:27 +0900 Subject: [PATCH 27/41] refactor(report): enhance error handling during report file closure --- internal/report/report.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/report/report.go b/internal/report/report.go index 29bb760..02977dd 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -2,6 +2,7 @@ package report import ( _ "embed" + "errors" "fmt" "os" "path/filepath" @@ -98,7 +99,11 @@ func Generate(reportID string, cfg *config.EvidenceConfig, accessInfo *utils.Acc 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 { From c45a4f35c7cdea786aa7dfa11d13dd3bb9643034 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:41:46 +0900 Subject: [PATCH 28/41] refactor(network/reachability): enhance error handling for connection closures and update user agent string --- internal/network/reachability.go | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) 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(), From 7452c69dedfe0f9504290a4f22d88d19c0a10fa0 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:03:46 +0900 Subject: [PATCH 29/41] refactor(email/message): enhance error handling for evidence file closure --- internal/email/message.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/email/message.go b/internal/email/message.go index 1040cb9..5cf3a90 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -77,7 +77,11 @@ func LoadReportContent(reportDir string) (*ReportContent, error) { if err != nil { return nil, fmt.Errorf("failed to open evidence.snapshot.yaml: %w", err) } - defer evidenceFile.Close() + 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 { From df45689637c66f333c37727a0302e7890d36a78d Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:13:29 +0900 Subject: [PATCH 30/41] refactor(email/message): improve screenshot retrieval logic and error handling --- internal/email/message.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/email/message.go b/internal/email/message.go index 5cf3a90..9e9ac3a 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -105,8 +105,8 @@ func LoadReportContent(reportDir string) (*ReportContent, error) { return nil, fmt.Errorf("failed to read Report.md: %w", err) } - screenshot, err := findScreenshot(reportDir, evidenceData) - if err != nil { + screenshot := findScreenshot(reportDir, evidenceData) + if screenshot != "" { log.Warnf("failed to find screenshot: %v", err) } @@ -177,12 +177,14 @@ func BuildEmailMessage(content *ReportContent, includeHTML bool, allowEmptySende return m, nil } -// findScreenshot searches for a screenshot matching the first user_agent from evidence.yaml -// If not found, searches in order. Returns empty string if no screenshot is found. -func findScreenshot(reportDir string, evidence EvidenceData) (string, error) { +// 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, with a fallback to the first available screenshot. +func findScreenshot(reportDir string, evidence EvidenceData) string { if len(evidence.Screenshot) == 0 { log.Warn("no screenshots found in evidence.json") - return "", nil + return "" } // Use UserAgents from evidence data @@ -199,7 +201,7 @@ func findScreenshot(reportDir string, evidence EvidenceData) (string, error) { screenshotPath := filepath.Join(reportDir, ua, screenshot.Filename) if _, err := os.Stat(screenshotPath); err == nil { log.Debugf("Using screenshot for user_agent %s: %s", ua, screenshotPath) - return screenshotPath, nil + return screenshotPath } } } @@ -211,11 +213,11 @@ func findScreenshot(reportDir string, evidence EvidenceData) (string, error) { screenshotPath := filepath.Join(reportDir, screenshot.UserAgent, screenshot.Filename) if _, err := os.Stat(screenshotPath); err == nil { log.Debugf("Using fallback screenshot: %s", screenshotPath) - return screenshotPath, nil + return screenshotPath } } - return "", nil + return "" } // generateRandomString generates a random alphanumeric string From 21b1d9c98f5e0da8d74a689a873c8561186dcbbf Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:06:37 +0900 Subject: [PATCH 31/41] feat(utils/obfuscator): URL and domain obfuscation tool --- internal/utils/obfuscator.go | 190 +++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 internal/utils/obfuscator.go 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) +} From 30b1e81c96fb07ebce0bbb0446ed1733a5f0aed8 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:06:33 +0900 Subject: [PATCH 32/41] refactor(email/message): update evidence handling and improve screenshot retrieval logic --- internal/email/message.go | 80 ++++++++++----------------------------- 1 file changed, 20 insertions(+), 60 deletions(-) diff --git a/internal/email/message.go b/internal/email/message.go index 9e9ac3a..f8b47b7 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -19,35 +19,11 @@ import ( "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" ) -// EvidenceMetadata represents the metadata from evidence.yaml -type EvidenceMetadata struct { - ReportID string `json:"report_id" yaml:"report_id"` - Version string `json:"version" yaml:"version"` -} - -// EvidenceScreenshot represents a screenshot from evidence.yaml -type EvidenceScreenshot struct { - UserAgent string `json:"user_agent" yaml:"user_agent"` - Filename string `json:"filename" yaml:"filename"` - URL string `json:"url" yaml:"url"` -} - -// UserAgentConfig represents a user agent from evidence.yaml -type UserAgentConfig struct { - Name string `yaml:"name"` -} - -// EvidenceData represents the parsed evidence.yaml structure -type EvidenceData struct { - ReportID string `yaml:"report_id"` - Metadata EvidenceMetadata `json:"metadata" yaml:"metadata"` - Screenshot []EvidenceScreenshot `json:"screenshots" yaml:"screenshots"` - UserAgents []UserAgentConfig `yaml:"user_agents"` -} - // MarkdownRenderer converts markdown to HTML using goldmark with GFM + CJK support func MarkdownRenderer() goldmark.Markdown { return goldmark.New( @@ -65,7 +41,7 @@ func MarkdownRenderer() goldmark.Markdown { type ReportContent struct { ReportID string Body []byte - Evidence EvidenceData + Evidence config.EvidenceConfig ScreenshotPath string } @@ -88,7 +64,7 @@ func LoadReportContent(reportDir string) (*ReportContent, error) { return nil, fmt.Errorf("failed to read evidence.snapshot.yaml: %w", err) } - var evidenceData EvidenceData + var evidenceData config.EvidenceConfig if err := yaml.Unmarshal(evidenceContent, &evidenceData); err != nil { return nil, fmt.Errorf("failed to parse evidence.snapshot.yaml: %w", err) } @@ -146,7 +122,16 @@ func BuildEmailMessage(content *ReportContent, includeHTML bool, allowEmptySende m.SetReplyTo(cfg.Reporter.Contact.Email) } - subject := fmt.Sprintf("Phishing Report - %s", content.ReportID) + 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 @@ -180,43 +165,18 @@ func BuildEmailMessage(content *ReportContent, includeHTML bool, allowEmptySende // 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, with a fallback to the first available screenshot. -func findScreenshot(reportDir string, evidence EvidenceData) string { - if len(evidence.Screenshot) == 0 { - log.Warn("no screenshots found in evidence.json") - return "" - } - - // Use UserAgents from evidence data - var uaOrder []string +// 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 { - uaOrder = append(uaOrder, ua.Name) - } - - // First, try to match the first user_agent name - if len(uaOrder) > 0 { - for _, ua := range uaOrder { - for _, screenshot := range evidence.Screenshot { - if screenshot.UserAgent == ua { - screenshotPath := filepath.Join(reportDir, ua, screenshot.Filename) - if _, err := os.Stat(screenshotPath); err == nil { - log.Debugf("Using screenshot for user_agent %s: %s", ua, screenshotPath) - return screenshotPath - } - } - } - } - } - - // Fallback: use first screenshot in order - for _, screenshot := range evidence.Screenshot { - screenshotPath := filepath.Join(reportDir, screenshot.UserAgent, screenshot.Filename) + screenshotPath := filepath.Join(reportDir, ua.Name, "screenshot.png") if _, err := os.Stat(screenshotPath); err == nil { - log.Debugf("Using fallback screenshot: %s", screenshotPath) + 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 "" } From 5e855b6ccd68f53581989af4bb483316bcdcef5e Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:47:18 +0900 Subject: [PATCH 33/41] refactor(evidence): create evidence snapshot with report_id if not present --- cmd/lite.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/cmd/lite.go b/cmd/lite.go index 3352bf0..867ee65 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -117,9 +117,9 @@ func executeLiteCollection(confPath string, isInteractive bool, browserPath stri } log.Info("Report directory created", "path", reportID) - // Create evidence snapshot + // Create evidence snapshot with report_id if not present snapshotPath := filepath.Join(reportID, "evidence.snapshot.yaml") - if err := copyFile(confPath, snapshotPath, 0400); err != nil { + 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) @@ -815,3 +815,17 @@ func copyFile(src, dst string, perm os.FileMode) error { } return out.Close() } + +// 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) +} From 66c33cc46dba4ca032e700a2592ca24f567b9d33 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sat, 10 Jan 2026 22:54:48 +0900 Subject: [PATCH 34/41] fix(email/message): correct screenshot attachment logic and improve warning message --- internal/email/message.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/email/message.go b/internal/email/message.go index f8b47b7..f215c6a 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -82,7 +82,7 @@ func LoadReportContent(reportDir string) (*ReportContent, error) { } screenshot := findScreenshot(reportDir, evidenceData) - if screenshot != "" { + if screenshot == "" { log.Warnf("failed to find screenshot: %v", err) } @@ -154,9 +154,10 @@ func BuildEmailMessage(content *ReportContent, includeHTML bool, allowEmptySende } if content.ScreenshotPath != "" { - if err := m.Attach(&mail.File{FilePath: content.ScreenshotPath}); err != nil { - log.Warnf("failed to attach screenshot: %v", err) - } + m.Attach(&mail.File{ + FilePath: content.ScreenshotPath, + Name: filepath.Base(content.ScreenshotPath), + }) } return m, nil From 04b0c3c63fa85c7dad171fcc65ad47380f30a1fd Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 11 Jan 2026 00:22:49 +0900 Subject: [PATCH 35/41] feat: add IMAP draft upload functionality - Integrate go-imap/v2 library - Support SSL/STARTTLS/None connection modes - Auto-detect \Drafts mailbox using RFC 6154 SPECIAL-USE - Add --imap-timeout flag with 30s default - Change default behavior to export .eml + upload to IMAP - Add comprehensive documentation --- cmd/email.go | 101 ++++++--- doc/app-behavior-config.md | 13 +- doc/email-imap.md | 282 ++++++++++++++++++++++++ go.mod | 3 + go.sum | 7 + internal/appconfig/appconfig.go | 2 + internal/appconfig/config.template.yaml | 11 +- internal/email/imap.go | 232 +++++++++++++++++++ 8 files changed, 612 insertions(+), 39 deletions(-) create mode 100644 doc/email-imap.md create mode 100644 internal/email/imap.go diff --git a/cmd/email.go b/cmd/email.go index dce6107..7f7cdc2 100644 --- a/cmd/email.go +++ b/cmd/email.go @@ -26,6 +26,9 @@ var ( emailIgnoreTLSErrors bool emailAllowUnsafeHTML bool + // IMAP timeout override flag + emailIMAPTimeout int + // Flags for draft subcommand emailDraftUpload bool emailDraftExport bool @@ -206,6 +209,10 @@ func init() { 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") @@ -245,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++ @@ -262,58 +270,91 @@ 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") } - // Determine email format - includeHTML := !emailDraftPlainText - // Load report content reportContent, err := email.LoadReportContent(reportDir) if err != nil { log.Fatalf("Failed to load report content: %v", err) } - // Execute based on selected mode + // 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") + } + } + if emailDraftUpload { log.Info("Uploading draft to IMAP Drafts folder...") log.Infof("TLS Error Handling: %v", effectiveIgnoreTLS) - displayEmailPreview(string(reportContent.Body)) - // Build email message for upload - msg, err := email.BuildEmailMessage(reportContent, includeHTML, emailDraftExport) - if err != nil { - log.Fatalf("Failed to build email message: %v", err) + // 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 } - // TODO: Implement IMAP upload with security settings - _ = msg // Suppress unused variable error until implemented - log.Warn("IMAP upload not yet implemented") - } else if emailDraftExport { - log.Info("Exporting draft as .eml file...") - log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) - displayEmailPreview(string(reportContent.Body)) + // Connect to IMAP server + 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) + } + }() - // Build email message for export - msg, err := email.BuildEmailMessage(reportContent, includeHTML, emailDraftExport) + // Find or create Drafts mailbox + draftFolder, err := email.FindOrCreateDraftMailbox(client, cfg.Email.IMAP.DraftFolder) if err != nil { - log.Fatalf("Failed to build email message: %v", err) + log.Errorf("Failed to find or create Drafts mailbox: %v", err) + return } - if err := saveEmlFile(msg, reportDir); err != nil { - log.Error("Failed to save .eml file: %v", err) + // 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) } - } else if emailDraftPreview { + } + + if emailDraftPreview { log.Info("Displaying email preview...") log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) displayEmailPreview(string(reportContent.Body)) - - // We don't need to build the full email object for just previewing the markdown } if emailDraftPlainText { - log.Info("Generating plain-text only format") + log.Info("Generated plain-text only format") } } diff --git a/doc/app-behavior-config.md b/doc/app-behavior-config.md index 860fdbf..53a602f 100644 --- a/doc/app-behavior-config.md +++ b/doc/app-behavior-config.md @@ -82,6 +82,7 @@ email: uri: "imap://user@example.com:password@imap.example.com:993" 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" @@ -154,10 +155,14 @@ CI/CDパイプラインやコンテナ環境での利用を想定し、すべて ### Email セキュリティ -| YAMLキー | 環境変数 | デフォルト | 説明 | -| ---------------------------------- | -------------------------------------------- | ---------- | ------------------------------ | -| `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サニタイゼーションを無効化 | +| YAMLキー | 環境変数 | デフォルト | 説明 | +| ---------------------------------- | -------------------------------------------- | -------------- | ------------------------------ | +| `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.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サニタイゼーションを無効化 | ## 3. 参照 diff --git a/doc/email-imap.md b/doc/email-imap.md new file mode 100644 index 0000000..cc51ade --- /dev/null +++ b/doc/email-imap.md @@ -0,0 +1,282 @@ +# IMAP下書き保存機能 + +`down-force`は、証拠収集レポートから生成したメールをIMAPサーバーの下書きフォルダに保存する機能をサポートしています。 + +## 概要 + +`email draft`コマンドは以下の動作をサポートします: + +- `.eml`ファイルとしてのエクスポート +- IMAPサーバーへの下書き保存 +- ターミナルでのプレビュー表示 + +**デフォルト動作**: フラグを指定しない場合、`.eml`ファイルのエクスポートとIMAP保存の両方を実行します。 + +## IMAP URI形式 + +IMAP接続には、HTTPSと同様の非公式標準URI形式を使用します: + +``` +imap://username:password@host:port +``` + +### URI形式の例 + +#### Gmail +``` +imap://user@gmail.com:app-password@imap.gmail.com:993 +``` + +**注意**: Gmailを使用する場合は、アプリパスワードが必要です。 +1. Googleアカウントの2段階認証を有効化 +2. [アプリパスワード](https://myaccount.google.com/apppasswords)を生成 +3. 生成されたパスワードをURI + +に使用 + +#### Outlook / Microsoft 365 +``` +imap://user@outlook.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 | + +**推奨**: 本番環境では`SSL`(ポート993)を使用してください。 + +### `--ignore-tls-errors`フラグ + +TLS証明書の検証をスキップします。以下の場合にのみ使用してください: + +- 開発環境で自己署名証明書を使用 +- テスト用のローカルIMAPサーバー + +**警告**: 本番環境では絶対に使用しないでください。中間者攻撃のリスクがあります。 + +```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: + imap: + uri: "imap://user@example.com:password@imap.example.com:993" + secure: "SSL" +``` + +### 完全な設定 + +```yaml +email: + imap: + uri: "imap://user@example.com:password@imap.example.com:993" + secure: "SSL" + draft_folder: "INBOX.Drafts" + timeout: 30 + smtp: + uri: "smtp://user@example.com:password@smtp.example.com:587" + secure: "STARTTLS" + mime_type: "multipart/alternative" + security: + ignore_tls_errors: false + allow_unsafe_html: false +``` + +## 使用例 + +### 基本的な使用方法 + +```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`メールボックスが自動作成され、次回以降はこのメールボックスが使用されます。 + +## 設定の初期化 + +初回設定には`config init`コマンドを使用できます: + +```bash +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 ff54565..ea52ad8 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,9 @@ require ( 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 diff --git a/go.sum b/go.sum index 34fe18d..137d349 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,12 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK 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= @@ -241,6 +247,7 @@ 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= diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go index ab43bfa..36abc86 100644 --- a/internal/appconfig/appconfig.go +++ b/internal/appconfig/appconfig.go @@ -101,6 +101,7 @@ 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 { @@ -161,6 +162,7 @@ func DefaultConfig() *AppConfig { Email: EmailConfig{ IMAP: IMAPConfig{ DraftFolder: "INBOX.Drafts", // Kept as requested + Timeout: 30, // Default timeout: 30 seconds }, SMTP: SMTPConfig{ // Defaults removed diff --git a/internal/appconfig/config.template.yaml b/internal/appconfig/config.template.yaml index 658239d..24080d3 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に設定してください。 @@ -67,6 +67,7 @@ email: 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" secure: "" # "None", "SSL", "STARTTLS" diff --git a/internal/email/imap.go b/internal/email/imap.go new file mode 100644 index 0000000..9817038 --- /dev/null +++ b/internal/email/imap.go @@ -0,0 +1,232 @@ +/* +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 +// 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 +} From eece4ee547a417df0bb9207e11e096ca81e9f742 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 11 Jan 2026 00:22:58 +0900 Subject: [PATCH 36/41] feat: add global debug mode with --debug/-d flag - Add DebugWriter utility for log-level controlled output - Enable IMAP protocol logging in debug mode - Support --verbose alias for --debug --- cmd/root.go | 9 +++++++++ internal/utils/debug.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 internal/utils/debug.go 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/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 +} From db1f435b435bf6304abc9449f6be6b8498ee09cd Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 11 Jan 2026 00:51:34 +0900 Subject: [PATCH 37/41] feat(email): add support for placeholders in From and Reply-To email addresses - Implemented placeholder context for email address expansion based on report ID. - Enhanced email configuration to support placeholders in From and Reply-To fields. - Added validation for email addresses to ensure proper formatting after placeholder expansion. - Updated documentation to reflect changes in email configuration and usage of placeholders. --- cmd/email.go | 2 +- doc/Summary-temolate.md | 37 +-- doc/app-behavior-config.md | 55 +++-- doc/email-imap.md | 125 ++++++++-- internal/appconfig/appconfig.go | 2 + internal/appconfig/config.template.yaml | 8 +- internal/email/imap.go | 1 + internal/email/message.go | 57 ++++- internal/utils/email_address.go | 160 ++++++++++++ internal/utils/email_address_test.go | 311 ++++++++++++++++++++++++ 10 files changed, 696 insertions(+), 62 deletions(-) create mode 100644 internal/utils/email_address.go create mode 100644 internal/utils/email_address_test.go diff --git a/cmd/email.go b/cmd/email.go index 7f7cdc2..76d75d3 100644 --- a/cmd/email.go +++ b/cmd/email.go @@ -313,7 +313,7 @@ func runEmailDraft(cmd *cobra.Command, args []string) { return } - // Connect to IMAP server + // 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) 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 53a602f..4cc40c5 100644 --- a/doc/app-behavior-config.md +++ b/doc/app-behavior-config.md @@ -78,16 +78,20 @@ reporter: share_with_site_owner: false # 侵害サイト所有者への通知に同意するか identity_to_site_owner: false # 侵害サイト所有者への「報告者身元」の開示に同意するか email: - imap: - uri: "imap://user@example.com:password@imap.example.com:993" + # 送信元・返信先メールアドレス(プレースホルダ対応: {report_id}, {timestamp}, {date}, {time}) + from: "abuse+{report_id}@example.com" # 送信元メールアドレス + reply_to: "" # 返信先アドレス(未指定の場合はReply-Toヘッダーをセットしない) + + imap: + uri: "imap://user@example.com:password@imap.example.com:993" # 認証情報のみ(プレースホルダ不可) 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@example.com:password@smtp.example.com:587" # 認証情報のみ(プレースホルダ不可) + secure: "STARTTLS" # "None", "SSL", "STARTTLS" mime-type: "text/plain" # "text/plain", "multipart/alternative" (HTMLへの変換は自動的に行います。) - + # セキュリティオプション (開発環境専用、詳細は下記セクション参照) security: ignore_tls_errors: false # TLS証明書検証をスキップ @@ -111,8 +115,7 @@ Markdownメールに埋め込むHTMLコンテンツのサニタイゼーショ 悪意あるコンテンツは、配信された場合あなただけでなく他の第三者にも被害を及ぼす可能性があるほか、 あなたのドメインやメールアドレス、IPアドレスの評判が損なわれる可能性もあります。 -``` - +```` ## 2. 環境変数マッピング CI/CDパイプラインやコンテナ環境での利用を想定し、すべての設定は環境変数で定義可能です。 @@ -155,15 +158,37 @@ CI/CDパイプラインやコンテナ環境での利用を想定し、すべて ### Email セキュリティ -| YAMLキー | 環境変数 | デフォルト | 説明 | -| ---------------------------------- | -------------------------------------------- | -------------- | ------------------------------ | -| `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.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サニタイゼーションを無効化 | +| 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. 参照 diff --git a/doc/email-imap.md b/doc/email-imap.md index cc51ade..189a4d6 100644 --- a/doc/email-imap.md +++ b/doc/email-imap.md @@ -10,7 +10,8 @@ - IMAPサーバーへの下書き保存 - ターミナルでのプレビュー表示 -**デフォルト動作**: フラグを指定しない場合、`.eml`ファイルのエクスポートとIMAP保存の両方を実行します。 +**デフォルト動作**: +フラグを指定しない場合、`.eml`ファイルのエクスポートとIMAP保存の両方を実行します。 ## IMAP URI形式 @@ -20,26 +21,60 @@ 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 + ``` -imap://user@gmail.com:app-password@imap.gmail.com:993 +# ユーザー名の@を%40にエンコード +imap://user%40gmail.com:app-password@imap.gmail.com:993 ``` **注意**: Gmailを使用する場合は、アプリパスワードが必要です。 + 1. Googleアカウントの2段階認証を有効化 2. [アプリパスワード](https://myaccount.google.com/apppasswords)を生成 -3. 生成されたパスワードをURI -に使用 +3. 生成されたパスワードをURIに使用(16文字のアプリパスワードはそのまま使用可能) #### Outlook / Microsoft 365 + ``` -imap://user@outlook.com:password@outlook.office365.com:993 +# ユーザー名の@を%40にエンコード + +imap://user%40outlook.com:password@outlook.office365.com:993 ``` #### 自己ホストDovecot + ``` imap://admin:secretpass@mail.example.com:993 ``` @@ -49,11 +84,13 @@ imap://admin:secretpass@mail.example.com:993 `down-force`は以下のURI形式の基本的な検証のみを実施します: - **スキーマ**: `imap://`または`imaps://`であること + - **ホスト名**: ホスト名が存在すること - **ポート**: ポート番号が数値であること(デフォルト: 993) - **認証情報**: ユーザー名とパスワードが含まれていること **整合性チェックは実施しません**: + - `secure: None`設定時にパスワードが設定されている - ポート番号とセキュリティ設定の不整合(例: ポート143でSSL指定) @@ -91,7 +128,8 @@ down-force email draft --upload --ignore-tls-errors ### RFC 6154 SPECIAL-USE属性 -`down-force`は、RFC 6154で定義されたSPECIAL-USE属性を使用して`\Drafts`メールボックスを自動検出します: +`down-force`は、RFC +6154で定義されたSPECIAL-USE属性を使用して`\Drafts`メールボックスを自動検出します: 1. `LIST`コマンドで全メールボックスをSPECIAL-USE属性付きで取得 2. `\Drafts`属性を持つメールボックスを検索 @@ -130,7 +168,7 @@ email: uri: "imap://user@example.com:password@imap.example.com:993" secure: "SSL" draft_folder: "INBOX.Drafts" - timeout: 60 # 秒単位(デフォルト: 30) + timeout: 60 # 秒単位(デフォルト: 30) ``` ### 環境変数でのオーバーライド @@ -155,8 +193,9 @@ down-force email draft --upload --imap-timeout 120 ```yaml email: + from: "abuse@example.com" # プレースホルダ対応: abuse+{report_id}@example.com imap: - uri: "imap://user@example.com:password@imap.example.com:993" + uri: "imap://user%40example.com:password@imap.example.com:993" # @を%40にエンコード secure: "SSL" ``` @@ -164,20 +203,31 @@ email: ```yaml email: + from: "abuse+{report_id}@example.com" # プレースホルダでレポートIDを埋め込み + reply_to: "" # 未指定の場合はReply-Toヘッダーをセットしない + imap: - uri: "imap://user@example.com:password@imap.example.com:993" + uri: "imap://user%40example.com:password@imap.example.com:993" # @を%40にエンコード secure: "SSL" draft_folder: "INBOX.Drafts" timeout: 30 smtp: - uri: "smtp://user@example.com:password@smtp.example.com:587" + uri: "smtp://user%40example.com:password@smtp.example.com:587" # @を%40にエンコード secure: "STARTTLS" - mime_type: "multipart/alternative" + 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`などを設定してください + ## 使用例 ### 基本的な使用方法 @@ -222,6 +272,7 @@ IMAP authentication failed: authentication failed ``` **解決方法**: + - ユーザー名とパスワードを確認 - Gmailの場合、アプリパスワードを使用しているか確認 - 2段階認証が有効になっているか確認 @@ -233,8 +284,10 @@ Failed to connect to IMAP server: dial tcp: i/o timeout ``` **解決方法**: + - ホスト名とポート番号を確認 - ファイアウォールでポート993が開放されているか確認 + - `--imap-timeout`フラグでタイムアウトを延長 ### TLS証明書エラー @@ -244,6 +297,7 @@ Failed to connect to IMAP server: x509: certificate signed by unknown authority ``` **解決方法**: + - 開発環境の場合: `--ignore-tls-errors`フラグを使用 - 本番環境の場合: 証明書の有効性を確認し、正しいCA証明書をインストール @@ -253,16 +307,56 @@ Failed to connect to IMAP server: x509: certificate signed by unknown authority No Drafts mailbox found, creating fallback: Drafts-down-force ``` -**説明**: これは通常の動作です。`Drafts-down-force`メールボックスが自動作成され、次回以降はこのメールボックスが使用されます。 +**説明**: +これは通常の動作です。`Drafts-down-force`メールボックスが自動作成され、次回以降はこのメールボックスが使用されます。 ## 設定の初期化 -初回設定には`config init`コマンドを使用できます: +プレースホルダ機能 -```bash -down-force config init +**重要**: +プレースホルダは`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設定を入力してください。 ## 環境変数マッピング @@ -280,3 +374,4 @@ down-force config init - [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/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go index 36abc86..3d21b82 100644 --- a/internal/appconfig/appconfig.go +++ b/internal/appconfig/appconfig.go @@ -91,6 +91,8 @@ type ConsentConfig struct { } type EmailConfig struct { + 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"` diff --git a/internal/appconfig/config.template.yaml b/internal/appconfig/config.template.yaml index 24080d3..66a9a64 100644 --- a/internal/appconfig/config.template.yaml +++ b/internal/appconfig/config.template.yaml @@ -63,13 +63,17 @@ 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への変換は自動的に行います。) diff --git a/internal/email/imap.go b/internal/email/imap.go index 9817038..ac8b6e8 100644 --- a/internal/email/imap.go +++ b/internal/email/imap.go @@ -20,6 +20,7 @@ import ( ) // 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) { diff --git a/internal/email/message.go b/internal/email/message.go index f215c6a..719ec97 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -98,28 +98,61 @@ func LoadReportContent(reportDir string) (*ReportContent, error) { func BuildEmailMessage(content *ReportContent, includeHTML bool, allowEmptySender bool) (*mail.Email, error) { cfg := appconfig.GetConfig() - // Default values for Message-ID generation + // Create placeholder context from report ID for From/Reply-To expansion + placeholderCtx, err := utils.NewPlaceholderContextFromReportDir(content.ReportID) + if err != nil { + log.Warnf("Failed to create placeholder context from report ID: %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" - - // Validate configuration - if cfg.Reporter.Contact.Email != "" { - // Parse sender email for Message-ID generation - senderParts := strings.Split(cfg.Reporter.Contact.Email, "@") + if fromAddress != "" { + senderParts := strings.Split(fromAddress, "@") if len(senderParts) == 2 { senderUsername = senderParts[0] senderDomain = senderParts[1] } else if !allowEmptySender { - return nil, fmt.Errorf("invalid sender email format: %s", cfg.Reporter.Contact.Email) + return nil, fmt.Errorf("invalid From email format after expansion: %s", fromAddress) } - } else if !allowEmptySender { - return nil, fmt.Errorf("sender email not configured in appconfig.Reporter.Contact.Email") } m := mail.NewMSG() - if cfg.Reporter.Contact.Email != "" { - m.SetFrom(cfg.Reporter.Contact.Email) - m.SetReplyTo(cfg.Reporter.Contact.Email) + 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 := "" diff --git a/internal/utils/email_address.go b/internal/utils/email_address.go new file mode 100644 index 0000000..6b7f039 --- /dev/null +++ b/internal/utils/email_address.go @@ -0,0 +1,160 @@ +/* +Copyright © 2025 canaria-computer +*/ +package utils + +import ( + "fmt" + "net/mail" + "path/filepath" + "regexp" + "strings" +) + +// PlaceholderContext holds values for template expansion in email addresses +type PlaceholderContext struct { + ReportID string // e.g., "report-20240101-120000" + Timestamp string // e.g., "20240101-120000" + Date string // e.g., "20240101" + Time string // e.g., "120000" +} + +// NewPlaceholderContextFromReportDir creates a PlaceholderContext from a report directory name +// Expected format: report-YYYYMMDD-HHMMSS (e.g., "report-20240101-120000") +func NewPlaceholderContextFromReportDir(reportDir string) (*PlaceholderContext, error) { + basename := filepath.Base(reportDir) + + // Match pattern: report-YYYYMMDD-HHMMSS + pattern := regexp.MustCompile(`^report-(\d{8})-(\d{6})$`) + matches := pattern.FindStringSubmatch(basename) + + if len(matches) != 3 { + return nil, fmt.Errorf("report directory name does not match expected format 'report-YYYYMMDD-HHMMSS': %s", basename) + } + + date := matches[1] + time := matches[2] + timestamp := date + "-" + time + + return &PlaceholderContext{ + ReportID: basename, + Timestamp: timestamp, + Date: date, + Time: time, + }, nil +} + +// ExpandPlaceholders replaces placeholders in the template string with values from the context +// Supported placeholders (case-insensitive): {report_id}, {timestamp}, {date}, {time} +// 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 + replacements := map[string]string{ + "{report_id}": ctx.ReportID, + "{timestamp}": ctx.Timestamp, + "{date}": ctx.Date, + "{time}": ctx.Time, + "{REPORT_ID}": ctx.ReportID, + "{TIMESTAMP}": ctx.Timestamp, + "{DATE}": ctx.Date, + "{TIME}": ctx.Time, + "{Report_Id}": ctx.ReportID, + "{Report_ID}": ctx.ReportID, + "{Timestamp}": ctx.Timestamp, + "{Date}": ctx.Date, + "{Time}": ctx.Time, + } + + 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}", "{timestamp}", "{date}", "{time}"} + placeholderPattern := regexp.MustCompile(`\{[^}]+\}`) + found := placeholderPattern.FindAllString(template, -1) + + for _, placeholder := range found { + valid := false + placeholderLower := strings.ToLower(placeholder) + for _, validPh := range validPlaceholders { + if placeholderLower == validPh { + valid = true + break + } + } + if !valid { + return fmt.Errorf("unknown placeholder in template: %s (valid: report_id, timestamp, date, time)", placeholder) + } + } + + // Try to create a dummy context and validate + dummyCtx := &PlaceholderContext{ + ReportID: "report-20000101-000000", + Timestamp: "20000101-000000", + Date: "20000101", + Time: "000000", + } + + 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..2e358dd --- /dev/null +++ b/internal/utils/email_address_test.go @@ -0,0 +1,311 @@ +/* +Copyright © 2025 canaria-computer +*/ +package utils + +import ( + "testing" +) + +func TestNewPlaceholderContextFromReportDir(t *testing.T) { + tests := []struct { + name string + reportDir string + wantReportID string + wantTimestamp string + wantDate string + wantTime string + wantErr bool + }{ + { + name: "valid report directory name", + reportDir: "report-20240101-120000", + wantReportID: "report-20240101-120000", + wantTimestamp: "20240101-120000", + wantDate: "20240101", + wantTime: "120000", + wantErr: false, + }, + { + name: "valid report with full path", + reportDir: "/path/to/report-20250115-143055", + wantReportID: "report-20250115-143055", + wantTimestamp: "20250115-143055", + wantDate: "20250115", + wantTime: "143055", + wantErr: false, + }, + { + name: "invalid format - missing date", + reportDir: "report-120000", + wantErr: true, + }, + { + name: "invalid format - wrong separator", + reportDir: "report_20240101_120000", + 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) + } + if ctx.Timestamp != tt.wantTimestamp { + t.Errorf("Timestamp = %v, want %v", ctx.Timestamp, tt.wantTimestamp) + } + if ctx.Date != tt.wantDate { + t.Errorf("Date = %v, want %v", ctx.Date, tt.wantDate) + } + if ctx.Time != tt.wantTime { + t.Errorf("Time = %v, want %v", ctx.Time, tt.wantTime) + } + } + }) + } +} + +func TestExpandPlaceholders(t *testing.T) { + ctx := &PlaceholderContext{ + ReportID: "report-20240101-120000", + Timestamp: "20240101-120000", + Date: "20240101", + Time: "120000", + } + + tests := []struct { + name string + template string + ctx *PlaceholderContext + want string + }{ + { + name: "expand report_id", + template: "abuse+{report_id}@example.com", + ctx: ctx, + want: "abuse+report-20240101-120000@example.com", + }, + { + name: "expand timestamp", + template: "abuse+{timestamp}@example.com", + ctx: ctx, + want: "abuse+20240101-120000@example.com", + }, + { + name: "expand date and time", + template: "abuse+{date}-{time}@example.com", + ctx: ctx, + want: "abuse+20240101-120000@example.com", + }, + { + name: "case insensitive - uppercase", + template: "abuse+{REPORT_ID}@example.com", + ctx: ctx, + want: "abuse+report-20240101-120000@example.com", + }, + { + name: "case insensitive - mixed case", + template: "abuse+{Report_ID}@example.com", + ctx: ctx, + want: "abuse+report-20240101-120000@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: "report-20240101-120000", + Timestamp: "20240101-120000", + Date: "20240101", + Time: "120000", + } + + 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+report-20240101-120000@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 with timestamp", + template: "abuse+{timestamp}@example.com", + wantErr: false, + }, + { + name: "valid template with date and time", + template: "abuse+{date}-{time}@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) + } + }) + } +} From 6dec51fcecfb204d29252a63bc04d4ccb0ceaa50 Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 11 Jan 2026 00:54:23 +0900 Subject: [PATCH 38/41] fix(doc/email-imap): update recommended encryption methods and remove unnecessary warnings --- doc/email-imap.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/doc/email-imap.md b/doc/email-imap.md index 189a4d6..8f96e49 100644 --- a/doc/email-imap.md +++ b/doc/email-imap.md @@ -108,16 +108,11 @@ IMAP接続の暗号化方法を指定します: | `STARTTLS` | 接続後にSTARTTLSコマンドでTLS開始 | 143 | | `None` | 暗号化なし(**非推奨**) | 143 | -**推奨**: 本番環境では`SSL`(ポート993)を使用してください。 +**推奨**: 本番環境では`STARTTLS`/`SSL`(ポート993)を使用してください。 ### `--ignore-tls-errors`フラグ -TLS証明書の検証をスキップします。以下の場合にのみ使用してください: - -- 開発環境で自己署名証明書を使用 -- テスト用のローカルIMAPサーバー - -**警告**: 本番環境では絶対に使用しないでください。中間者攻撃のリスクがあります。 +**警告**: 中間者攻撃のリスクがあります。 ```bash # 開発環境での使用例 From e13ba59c4be38b743b5d67771c4d9ad0002da75b Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 11 Jan 2026 01:39:46 +0900 Subject: [PATCH 39/41] Refactor report handling and email template placeholders - Updated consent settings in app-behavior-config.md for clarity. - Added dependency on btcsuite/btcutil in go.mod. - Enhanced report content structure to include report directory. - Modified report generation to use report directory instead of report ID. - Simplified email address placeholder context to only include report ID. - Updated tests to reflect changes in report directory handling and placeholder expansion. - Removed unused timestamp, date, and time fields from placeholder context and tests. --- cmd/lite.go | 72 +++++++++++++++------ doc/app-behavior-config.md | 9 +-- go.mod | 1 + go.sum | 31 +++++++++ internal/email/message.go | 10 +-- internal/report/report.go | 4 +- internal/utils/email_address.go | 81 ++++++++--------------- internal/utils/email_address_test.go | 96 ++++++++-------------------- 8 files changed, 155 insertions(+), 149 deletions(-) diff --git a/cmd/lite.go b/cmd/lite.go index 867ee65..770b0d7 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -8,7 +8,7 @@ package cmd import ( "bytes" "context" - "encoding/base64" + "encoding/binary" "errors" "fmt" "image" @@ -112,21 +112,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 if not present - snapshotPath := filepath.Join(reportID, "evidence.snapshot.yaml") + // 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") } @@ -163,18 +165,52 @@ func performPreflightChecks(targetURL string, userAgents []config.UserAgentConfi 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-") } - ts := time.Now().Format("20060102-150405") - encoded := base64.RawURLEncoding.EncodeToString([]byte(ts)) - return fmt.Sprintf("report-%s", encoded) + // Generate Base58-encoded UNIX timestamp + ts := time.Now().Unix() + tsBytes := make([]byte, 8) + binary.BigEndian.PutUint64(tsBytes, uint64(ts)) + return base58Encode(tsBytes) } -func processUserAgents(targetURL string, userAgents []config.UserAgentConfig, reportID string, isInteractive bool, browserPath string, accessInfo *utils.AccessInfo) { +// 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 + } + + return string(encoded) +} + +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 @@ -183,7 +219,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++ @@ -603,13 +639,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 @@ -622,11 +658,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")) } } diff --git a/doc/app-behavior-config.md b/doc/app-behavior-config.md index 4cc40c5..4f83ec4 100644 --- a/doc/app-behavior-config.md +++ b/doc/app-behavior-config.md @@ -73,22 +73,23 @@ 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: - # 送信元・返信先メールアドレス(プレースホルダ対応: {report_id}, {timestamp}, {date}, {time}) + # 送信元・返信先メールアドレス(プレースホルダ対応: {report_id} のみ) + # レポートIDはBase58エンコードされたUNIXタイムスタンプ(例: MjAyNjAxMDgtMTc0NTQ0) from: "abuse+{report_id}@example.com" # 送信元メールアドレス reply_to: "" # 返信先アドレス(未指定の場合はReply-Toヘッダーをセットしない) imap: - uri: "imap://user@example.com:password@imap.example.com:993" # 認証情報のみ(プレースホルダ不可) + 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" # 認証情報のみ(プレースホルダ不可) + 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への変換は自動的に行います。) diff --git a/go.mod b/go.mod index ea52ad8..fa95132 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( 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 diff --git a/go.sum b/go.sum index 137d349..efb61b4 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ 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= @@ -14,6 +15,16 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v 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= @@ -55,6 +66,7 @@ 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= @@ -74,6 +86,7 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 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= @@ -87,14 +100,19 @@ 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= @@ -148,6 +166,9 @@ 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= @@ -203,7 +224,9 @@ github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC 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= @@ -216,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= @@ -260,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/email/message.go b/internal/email/message.go index 719ec97..a4d586a 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -39,7 +39,8 @@ func MarkdownRenderer() goldmark.Markdown { // ReportContent holds the loaded report data type ReportContent struct { - ReportID string + ReportID string // Pure Report ID (e.g., "MjAyNjAxMDgtMTc0NTQ0") + ReportDir string // Report directory path (e.g., "report-MjAyNjAxMDgtMTc0NTQ0") Body []byte Evidence config.EvidenceConfig ScreenshotPath string @@ -88,6 +89,7 @@ func LoadReportContent(reportDir string) (*ReportContent, error) { return &ReportContent{ ReportID: reportID, + ReportDir: reportDir, Body: reportContent, Evidence: evidenceData, ScreenshotPath: screenshot, @@ -98,10 +100,10 @@ func LoadReportContent(reportDir string) (*ReportContent, error) { func BuildEmailMessage(content *ReportContent, includeHTML bool, allowEmptySender bool) (*mail.Email, error) { cfg := appconfig.GetConfig() - // Create placeholder context from report ID for From/Reply-To expansion - placeholderCtx, err := utils.NewPlaceholderContextFromReportDir(content.ReportID) + // 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 ID: %v", err) + log.Warnf("Failed to create placeholder context from report directory: %v", err) placeholderCtx = nil } diff --git a/internal/report/report.go b/internal/report/report.go index 02977dd..9bf2cd5 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -46,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) @@ -94,7 +94,7 @@ 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) diff --git a/internal/utils/email_address.go b/internal/utils/email_address.go index 6b7f039..3b5c937 100644 --- a/internal/utils/email_address.go +++ b/internal/utils/email_address.go @@ -7,45 +7,38 @@ import ( "fmt" "net/mail" "path/filepath" - "regexp" "strings" ) // PlaceholderContext holds values for template expansion in email addresses type PlaceholderContext struct { - ReportID string // e.g., "report-20240101-120000" - Timestamp string // e.g., "20240101-120000" - Date string // e.g., "20240101" - Time string // e.g., "120000" + ReportID string // Report ID (e.g., "MjAyNjAxMDgtMTc0NTQ0" or custom ID) } // NewPlaceholderContextFromReportDir creates a PlaceholderContext from a report directory name -// Expected format: report-YYYYMMDD-HHMMSS (e.g., "report-20240101-120000") +// Expected format: report-{REPORT_ID} (e.g., "report-MjAyNjAxMDgtMTc0NTQ0") func NewPlaceholderContextFromReportDir(reportDir string) (*PlaceholderContext, error) { basename := filepath.Base(reportDir) - // Match pattern: report-YYYYMMDD-HHMMSS - pattern := regexp.MustCompile(`^report-(\d{8})-(\d{6})$`) - matches := pattern.FindStringSubmatch(basename) - - if len(matches) != 3 { - return nil, fmt.Errorf("report directory name does not match expected format 'report-YYYYMMDD-HHMMSS': %s", basename) + // Directory name must start with "report-" + if !strings.HasPrefix(basename, "report-") { + return nil, fmt.Errorf("invalid report directory format (expected 'report-*'): %s", basename) } - date := matches[1] - time := matches[2] - timestamp := date + "-" + time + // 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: basename, - Timestamp: timestamp, - Date: date, - Time: time, + ReportID: reportID, }, nil } // ExpandPlaceholders replaces placeholders in the template string with values from the context -// Supported placeholders (case-insensitive): {report_id}, {timestamp}, {date}, {time} +// Supported placeholders (case-insensitive): {report_id} // If context is nil, returns the template unchanged func ExpandPlaceholders(template string, ctx *PlaceholderContext) string { if ctx == nil { @@ -54,21 +47,12 @@ func ExpandPlaceholders(template string, ctx *PlaceholderContext) string { result := template - // Case-insensitive replacement + // Case-insensitive replacement for {report_id} only replacements := map[string]string{ - "{report_id}": ctx.ReportID, - "{timestamp}": ctx.Timestamp, - "{date}": ctx.Date, - "{time}": ctx.Time, - "{REPORT_ID}": ctx.ReportID, - "{TIMESTAMP}": ctx.Timestamp, - "{DATE}": ctx.Date, - "{TIME}": ctx.Time, - "{Report_Id}": ctx.ReportID, - "{Report_ID}": ctx.ReportID, - "{Timestamp}": ctx.Timestamp, - "{Date}": ctx.Date, - "{Time}": ctx.Time, + "{report_id}": ctx.ReportID, + "{REPORT_ID}": ctx.ReportID, + "{Report_Id}": ctx.ReportID, + "{Report_ID}": ctx.ReportID, } for placeholder, value := range replacements { @@ -125,30 +109,21 @@ func ValidateEmailAddressTemplate(template string) error { } // Check for valid placeholder syntax only - validPlaceholders := []string{"{report_id}", "{timestamp}", "{date}", "{time}"} - placeholderPattern := regexp.MustCompile(`\{[^}]+\}`) - found := placeholderPattern.FindAllString(template, -1) - - for _, placeholder := range found { - valid := false - placeholderLower := strings.ToLower(placeholder) - for _, validPh := range validPlaceholders { - if placeholderLower == validPh { - valid = true - break - } - } - if !valid { - return fmt.Errorf("unknown placeholder in template: %s (valid: report_id, timestamp, date, time)", placeholder) + 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: "report-20000101-000000", - Timestamp: "20000101-000000", - Date: "20000101", - Time: "000000", + ReportID: "MjAyNjAxMDgtMTc0NTQ0", } expanded := ExpandPlaceholders(template, dummyCtx) diff --git a/internal/utils/email_address_test.go b/internal/utils/email_address_test.go index 2e358dd..bb4c7fb 100644 --- a/internal/utils/email_address_test.go +++ b/internal/utils/email_address_test.go @@ -9,40 +9,37 @@ import ( func TestNewPlaceholderContextFromReportDir(t *testing.T) { tests := []struct { - name string - reportDir string - wantReportID string - wantTimestamp string - wantDate string - wantTime string - wantErr bool + name string + reportDir string + wantReportID string + wantErr bool }{ { - name: "valid report directory name", - reportDir: "report-20240101-120000", - wantReportID: "report-20240101-120000", - wantTimestamp: "20240101-120000", - wantDate: "20240101", - wantTime: "120000", - wantErr: false, + name: "valid Base58 report directory", + reportDir: "report-MjAyNjAxMDgtMTc0NTQ0", + wantReportID: "MjAyNjAxMDgtMTc0NTQ0", + wantErr: false, }, { - name: "valid report with full path", - reportDir: "/path/to/report-20250115-143055", - wantReportID: "report-20250115-143055", - wantTimestamp: "20250115-143055", - wantDate: "20250115", - wantTime: "143055", - wantErr: false, + name: "valid Base58 with full path", + reportDir: "/path/to/report-MjAyNjAxMDgtMTc0NTQ0", + wantReportID: "MjAyNjAxMDgtMTc0NTQ0", + wantErr: false, }, { - name: "invalid format - missing date", - reportDir: "report-120000", + 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 - wrong separator", - reportDir: "report_20240101_120000", + name: "invalid format - empty report ID", + reportDir: "report-", wantErr: true, }, { @@ -63,15 +60,6 @@ func TestNewPlaceholderContextFromReportDir(t *testing.T) { if ctx.ReportID != tt.wantReportID { t.Errorf("ReportID = %v, want %v", ctx.ReportID, tt.wantReportID) } - if ctx.Timestamp != tt.wantTimestamp { - t.Errorf("Timestamp = %v, want %v", ctx.Timestamp, tt.wantTimestamp) - } - if ctx.Date != tt.wantDate { - t.Errorf("Date = %v, want %v", ctx.Date, tt.wantDate) - } - if ctx.Time != tt.wantTime { - t.Errorf("Time = %v, want %v", ctx.Time, tt.wantTime) - } } }) } @@ -79,10 +67,7 @@ func TestNewPlaceholderContextFromReportDir(t *testing.T) { func TestExpandPlaceholders(t *testing.T) { ctx := &PlaceholderContext{ - ReportID: "report-20240101-120000", - Timestamp: "20240101-120000", - Date: "20240101", - Time: "120000", + ReportID: "MjAyNjAxMDgtMTc0NTQ0", } tests := []struct { @@ -95,31 +80,19 @@ func TestExpandPlaceholders(t *testing.T) { name: "expand report_id", template: "abuse+{report_id}@example.com", ctx: ctx, - want: "abuse+report-20240101-120000@example.com", - }, - { - name: "expand timestamp", - template: "abuse+{timestamp}@example.com", - ctx: ctx, - want: "abuse+20240101-120000@example.com", - }, - { - name: "expand date and time", - template: "abuse+{date}-{time}@example.com", - ctx: ctx, - want: "abuse+20240101-120000@example.com", + want: "abuse+MjAyNjAxMDgtMTc0NTQ0@example.com", }, { name: "case insensitive - uppercase", template: "abuse+{REPORT_ID}@example.com", ctx: ctx, - want: "abuse+report-20240101-120000@example.com", + want: "abuse+MjAyNjAxMDgtMTc0NTQ0@example.com", }, { name: "case insensitive - mixed case", template: "abuse+{Report_ID}@example.com", ctx: ctx, - want: "abuse+report-20240101-120000@example.com", + want: "abuse+MjAyNjAxMDgtMTc0NTQ0@example.com", }, { name: "no placeholders", @@ -200,10 +173,7 @@ func TestValidateEmailAddress(t *testing.T) { func TestExpandAndValidateEmailAddress(t *testing.T) { ctx := &PlaceholderContext{ - ReportID: "report-20240101-120000", - Timestamp: "20240101-120000", - Date: "20240101", - Time: "120000", + ReportID: "MjAyNjAxMDgtMTc0NTQ0", } tests := []struct { @@ -217,7 +187,7 @@ func TestExpandAndValidateEmailAddress(t *testing.T) { name: "valid template with placeholder", template: "abuse+{report_id}@example.com", ctx: ctx, - want: "abuse+report-20240101-120000@example.com", + want: "abuse+MjAyNjAxMDgtMTc0NTQ0@example.com", wantErr: false, }, { @@ -268,16 +238,6 @@ func TestValidateEmailAddressTemplate(t *testing.T) { template: "abuse+{report_id}@example.com", wantErr: false, }, - { - name: "valid template with timestamp", - template: "abuse+{timestamp}@example.com", - wantErr: false, - }, - { - name: "valid template with date and time", - template: "abuse+{date}-{time}@example.com", - wantErr: false, - }, { name: "valid template without placeholders", template: "abuse@example.com", From 7358e548254db21dae435d156e4b3786d511fffb Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 11 Jan 2026 01:48:48 +0900 Subject: [PATCH 40/41] docs(email-imap): expand explanation of IMAP draft saving feature and its benefits --- doc/email-imap.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/doc/email-imap.md b/doc/email-imap.md index 8f96e49..46aafdd 100644 --- a/doc/email-imap.md +++ b/doc/email-imap.md @@ -13,6 +13,33 @@ **デフォルト動作**: フラグを指定しない場合、`.eml`ファイルのエクスポートとIMAP保存の両方を実行します。 +以下のような説明が適切だと思います。 +思想が伝わり、かつ冗長にならない形にしています。 + +--- + +## なぜ IMAP 下書き保存機能があるのか + +`down-force` は、証拠収集レポートを **即座に送信することが常に最適とは限らない** 、という前提で設計されています。 + +IMAP 下書き保存機能は、生成したメールを **送信せずに下書きとして保存** することで、次の課題を解決します。 + +- **最終確認を CUI 以外で行える** + CLI 上の表示だけでなく、普段使い慣れたメールクライアントで + 内容・添付・表示・宛先を目視確認できます。 + また、第三者によるレビューや役割分担も可能です。 + +- **誤送信リスクを下げる** + CLI では一度の実行が即送信につながるため、心理的・運用的な負担があります。 + 下書き保存にすることで「送信」という行為に時間を空けることができます。 + +- **配信制限を回避しやすい** + 下書き作成は多くの場合制限がなく、 + 後から手動で一斉送信することで API のレート制限を受けにくくなります。 + +この機能は、 **安全性・確認性・運用現実性を重視した送信フロー** を実現するためのものです。 +直接送信する SMTP や API 経由の配信に 並ぶ有効な選択肢となります。 + ## IMAP URI形式 IMAP接続には、HTTPSと同様の非公式標準URI形式を使用します: @@ -62,8 +89,8 @@ imap://user%40gmail.com:app-password@imap.gmail.com:993 1. Googleアカウントの2段階認証を有効化 2. [アプリパスワード](https://myaccount.google.com/apppasswords)を生成 - 3. 生成されたパスワードをURIに使用(16文字のアプリパスワードはそのまま使用可能) +4. [高度な保護オプション](https://landing.google.com/advancedprotection/) を利用しているユーザまたは組織ではご利用いただけません。 #### Outlook / Microsoft 365 From a923f01a274f3c464733547932b98700e61be4cb Mon Sep 17 00:00:00 2001 From: canaria-computer <121685177+canaria-computer@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:49:13 +0900 Subject: [PATCH 41/41] refactor: remove unused imports and clean up code formatting --- cmd/lite.go | 31 +++++-------------------------- main.go | 1 - 2 files changed, 5 insertions(+), 27 deletions(-) diff --git a/cmd/lite.go b/cmd/lite.go index 770b0d7..a8a931d 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -15,7 +15,6 @@ import ( "image/color" "image/draw" "image/png" - "io" "net" "net/netip" "net/url" @@ -113,7 +112,7 @@ func executeLiteCollection(confPath string, isInteractive bool, browserPath stri reportID := generateReportID(cfg.ReportID) reportDir := getReportDir(reportID) - + if err := os.MkdirAll(reportDir, dirPermissions); err != nil { log.Fatalf("Failed to create report directory: %v", err) } @@ -189,24 +188,24 @@ func getReportDir(reportID string) 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 } - + return string(encoded) } @@ -832,26 +831,6 @@ func init() { liteCmd.Flags().IntVar(&overallTimeout, "timeout", -1, "Overall page load timeout in seconds (overrides config)") } -// copyFile copies a file from src to dst with specified permissions -func copyFile(src, dst string, perm os.FileMode) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) - if err != nil { - return err - } - defer out.Close() - - if _, err := io.Copy(out, in); err != nil { - return err - } - return out.Close() -} - // 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 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