diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index d300267..0000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/BUILD.md b/BUILD.md index 3bee213..44af5e5 100644 --- a/BUILD.md +++ b/BUILD.md @@ -26,10 +26,6 @@ go build -o bin/gomailtest.exe ./cmd/gomailtest go build -ldflags="-s -w" -o bin/gomailtest.exe ./cmd/gomailtest ``` -## Note on Legacy Binaries - -The individual shim binaries (`smtptool`, `imaptool`, `pop3tool`, `jmaptool`, `msgraphtool`) that existed in v3.0.x have been removed in v3.1.0. Use `gomailtest ` instead. - ## Cross-Platform Builds ### Build for Linux diff --git a/ChangeLog/3.4.1.md b/ChangeLog/3.4.1.md new file mode 100644 index 0000000..0ae2039 --- /dev/null +++ b/ChangeLog/3.4.1.md @@ -0,0 +1,29 @@ +# Version 3.4.1 + +## Changes + +### Changed + +- Centralized TLS version parsing/formatting into the shared `internal/common/tls` + package (relocated from `internal/smtp/tls`). SMTP, IMAP, POP3, and EWS now use + the single `ParseTLSVersion`/`TLSVersionString` helpers instead of their own + copies. As a side effect, EWS now honors `--tlsversion 1.0`/`1.1` consistently + with the other protocols. + +### Added + +- Unit tests for the shared `internal/common/tls` package covering TLS version + parsing/formatting, cipher-strength analysis, TLS warnings/recommendations, and + certificate-chain analysis (raising the package from 0% to ~81% coverage). + +### Removed + +- Duplicated local `parseTLSVersion()` implementations in the IMAP and POP3 + clients, and the EWS `buildTLSConfig` version switch and local + `tlsVersionString` helper. + +### Documentation + +- Removed stale "legacy per-protocol binaries were removed in v3.1.0" + announcements from current documentation (README, BUILD, TOOLS, and the + per-protocol docs), keeping the legacy-name migration aids. diff --git a/README.md b/README.md index 0258412..75bc31f 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ Each protocol uses a dedicated prefix: ## Migrating from Legacy Binary Names -The individual tool binaries (`smtptool`, `imaptool`, `pop3tool`, `jmaptool`, `msgraphtool`) were removed in v3.1.0. Replace them with `gomailtest --flag`: +Replace legacy `*tool` invocations with `gomailtest --flag`: | Old | New | |-----|-----| diff --git a/TOOLS.md b/TOOLS.md index 18f2959..5e5facc 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -8,8 +8,6 @@ This document provides a comprehensive comparison of all protocols supported by > ``` > gomailtest [flags] > ``` -> -> The legacy per-protocol binaries (`smtptool`, `imaptool`, `pop3tool`, `jmaptool`, `ewstool`, `msgraphtool`) were removed in v3.1.0. ## Quick Reference diff --git a/cr-results/CODE_REVIEW_AND_IMPROVEMENTS_2026_06_22a.md b/cr-results/CODE_REVIEW_AND_IMPROVEMENTS_2026_06_22a.md new file mode 100644 index 0000000..6b2c4bf --- /dev/null +++ b/cr-results/CODE_REVIEW_AND_IMPROVEMENTS_2026_06_22a.md @@ -0,0 +1,281 @@ +# Repository and Architecture Evaluation: gomailtesttool +**Date:** 2026-06-22 +**Version Reviewed:** `3.4.1` +**Reviewer:** GitHub Copilot CLI + +## 1. Executive Summary + +The repository is in a **healthy and maintainable state overall**. It has a clear product direction, a coherent Go module layout, a unified CLI entry point, and a straightforward build/test flow. The main architectural risk is not disorder at the top level, but **duplication and boundary blur** inside the command layer. + +**Overall assessment:** +* **Strength:** the codebase has the right macro-structure for a multi-protocol Go CLI suite. +* **Primary concern:** command orchestration is repeated across many subcommands, which increases maintenance cost and drift risk. +* **Recommended direction:** keep the current structure, but refactor toward **shared action execution paths** and a cleaner separation between Cobra wiring and reusable protocol operations. + +--- + +## 2. Current Repository Assessment + +### 2.1 High-Level Shape Is Good + +The current structure is sensible for the product: + +* `cmd/gomailtest/` is a thin entry point. +* `internal/common/` holds cross-cutting concerns such as bootstrap, logging, retry, validation, versioning, networking, and TLS helpers. +* `internal/protocols/*` contains protocol-specific command/config/action code. +* lower-level protocol details live in dedicated packages such as `internal/smtp/protocol`, `internal/imap/protocol`, `internal/pop3/protocol`, and `internal/jmap/protocol`. +* optional additional surfaces exist in `internal/serve` and `internal/devtools`. + +This is a strong base. The repo already behaves like a **well-scoped tool suite**, not an ad hoc collection of scripts. + +### 2.2 Product Direction Is Coherent + +The repository presents a unified binary, `gomailtest`, covering: + +* SMTP +* IMAP +* POP3 +* JMAP +* EWS +* Microsoft Graph +* serve mode +* developer tooling + +That scope is reflected consistently in `README.md`, `BUILD.md`, the `Makefile`, and the active module path in `go.mod`. + +### 2.3 Documentation Coverage Is Strong + +The project has unusually good repo-level documentation for a CLI utility: + +* `README.md` +* `BUILD.md` +* `UNIT_TESTS.md` +* protocol-specific docs under `docs/protocols/` +* security and release documentation + +That documentation makes the codebase easier to extend safely, even when the implementation has some duplication. + +--- + +## 3. Architecture Review + +### 3.1 The Top-Level Architecture Is Sound + +The command registration in `cmd/gomailtest/root.go` is clean and direct. The root command delegates to focused subcommands without accumulating protocol logic in the entry point. + +This is the correct shape: + +* one binary +* multiple protocol subcommands +* shared internal utilities +* protocol-specific implementation packages + +There is no evidence that the repository needs a rewrite or a major re-layout. + +### 3.2 The Command Layer Has Too Much Repeated Plumbing + +The largest architectural issue is repeated setup logic across protocol commands. In multiple `internal/protocols/*/cmd.go` files, each action repeats the same sequence: + +1. bind flags +2. load config file +3. build config from Viper +4. validate config +5. create signal context +6. initialize loggers +7. run the action + +This pattern is visible across `smtp`, `imap`, `pop3`, `jmap`, `ews`, and `msgraph`. + +**Why it matters:** +* bug fixes to command startup behavior must be applied in many places +* logging/config behavior can drift between protocols +* adding new actions becomes more copy/paste-driven than design-driven + +**Recommendation:** introduce a shared command runner/helper that encapsulates the standard lifecycle and leaves each action responsible only for protocol-specific config and execution. + +### 3.3 CLI Wiring and Reusable Operations Are Not Yet Separated Cleanly Enough + +`internal/protocols/*` currently mixes: + +* Cobra wiring +* config loading +* validation +* operational behavior + +This is workable, but it weakens reuse. The best signal is `internal/serve`, which already needs to construct protocol configs and call protocol-layer behavior directly. + +**Why it matters:** +* the CLI and HTTP server are two frontends for related behavior +* if reusable execution logic stays embedded inside command-oriented packages, cross-surface reuse remains awkward +* future surfaces such as automation hooks or additional APIs will increase this pressure + +**Recommendation:** move toward a clearer split: + +* **command packages** for Cobra flags, help text, and argument binding +* **service/execution packages** for protocol actions that can be called from both CLI and `serve` + +This should be done incrementally, not as a large rewrite. + +### 3.4 Context Handling Is Duplicated and Partly Redundant + +`cmd/gomailtest/root.go` sets a signal-aware context in `PersistentPreRunE`, but many subcommands also call `bootstrap.SetupSignalContext()` again inside `RunE`. + +**Why it matters:** +* cancellation behavior is not clearly centralized +* the root-level setup becomes less meaningful if actions create replacement contexts +* this makes future context propagation harder to reason about + +**Recommendation:** choose one model and standardize it: + +* either the root command owns context creation and subcommands use `cmd.Context()` +* or subcommands own context setup and the root stops creating one + +The first model is usually cleaner for Cobra applications. + +### 3.5 The `serve` Surface Is Useful but Highlights the Missing Execution Boundary + +`internal/serve/server.go` is well-scoped and appropriately small. It exposes a narrow REST surface, applies API key middleware, and keeps request handling direct. + +At the same time, `internal/serve/cmd.go` has to know about: + +* SMTP config loading +* MS Graph config loading +* Graph client initialization +* availability rules for backend actions + +That is a hint that the execution boundary is still too low-level. + +**Recommendation:** preserve `serve`, but reduce how much backend assembly logic it owns over time. + +--- + +## 4. Quality and Test Signal + +### 4.1 Baseline Health Is Good + +Current repository tests pass with: + +```bash +go test ./... +go test -cover ./... +``` + +That is a strong baseline and means the repo is not in a broken or unstable state. + +### 4.2 Coverage Is Uneven in Important Areas + +Coverage is good in several shared utility packages: + +* `internal/common/email` — 97.3% +* `internal/common/network` — 89.7% +* `internal/common/ratelimit` — 100.0% +* `internal/common/tls` — 81.4% +* `internal/common/validation` — 89.6% + +But several operational packages remain thinly covered: + +* `internal/common/retry` — 0.0% +* `internal/protocols/imap` — 4.5% +* `internal/protocols/jmap` — 8.3% +* `internal/protocols/msgraph` — 6.9% +* `internal/protocols/pop3` — 3.8% +* `internal/protocols/ews` — 13.0% +* `internal/serve` — 46.9% + +**Why it matters:** the repo is strongest around helpers and validation, but weaker around real action execution paths and retry behavior. + +### 4.3 Shared Retry Logic Deserves Immediate Attention + +`internal/common/retry/retry.go` is a particularly important package because it influences user-visible reliability and failure behavior. Right now it has no tests, and it contains logic that deserves closer scrutiny, including error classification by string matching. + +This is a small package with high leverage. It should be one of the first places to strengthen. + +--- + +## 5. Main Findings + +### 5.1 What Is Working Well + +* repository structure is easy to navigate +* module/build story is clear +* documentation is strong +* the command tree is understandable +* shared utility packages are reasonably well factored +* the repo is currently passing tests + +### 5.2 Main Risks + +* repeated command startup/orchestration logic +* weak separation between CLI wiring and reusable operations +* duplicated context lifecycle setup +* under-tested retry and action-layer behavior +* slight drift risk between architecture docs and the live tree + +### 5.3 Architecture Verdict + +This is **not a repo with a bad architecture**. It is a repo with a **good core architecture that now needs consolidation work**. The next improvement phase should focus on reducing duplication and clarifying boundaries, not on changing the whole shape of the project. + +--- + +## 6. Recommended Improvement Plan + +### Phase 1: Shared Command Lifecycle Cleanup (High Priority) + +Create a shared helper for the repeated command flow: + +* bind flags +* load config +* validate config +* initialize context +* initialize logging +* invoke action + +This reduces drift across all protocol actions and makes future additions cheaper. + +### Phase 2: Context Model Standardization (High Priority) + +Standardize around one cancellation model: + +* root command creates context +* subcommands consume `cmd.Context()` + +Remove duplicate signal-context creation from individual actions once that path is in place. + +### Phase 3: Execution Boundary Extraction (Medium Priority) + +Refactor reusable operational logic out of Cobra-oriented packages where practical so both: + +* `gomailtest ` +* `gomailtest serve` + +call the same execution layer. + +This will improve consistency and make future automation surfaces safer. + +### Phase 4: Targeted Test Hardening (Medium Priority) + +Focus on: + +* `internal/common/retry` +* `internal/serve` +* msgraph action paths +* protocol action success/failure behavior + +Prefer targeted tests over broad coverage chasing. + +### Phase 5: Architecture Doc Refresh (Low/Medium Priority) + +Update `ARCHITECTURE_DIAGRAM.md` so it reflects the current tree more precisely, especially newer `internal/common/*` packages and the actual active surfaces. + +--- + +## 7. Final Recommendation + +The repository should continue in its current direction. The structure is already strong enough to support future work. The priority now is **consolidation, not reinvention**: + +* centralize repeated command plumbing +* clean up context ownership +* extract reusable execution seams +* strengthen tests where runtime behavior is most sensitive + +If those improvements are made, the existing architecture should scale well for the next phase of growth. diff --git a/docs/protocols/imap.md b/docs/protocols/imap.md index 93021dc..2c2ea19 100644 --- a/docs/protocols/imap.md +++ b/docs/protocols/imap.md @@ -2,7 +2,7 @@ IMAP server connectivity, TLS configuration, authentication, and folder listing. -> **Legacy name:** `imaptool`. The legacy binary was removed in v3.1. Use `gomailtest imap --flag` (see the migration table in README.md). +> **Legacy name:** `imaptool`. Use `gomailtest imap --flag` (see the migration table in README.md). ## Quick Start diff --git a/docs/protocols/jmap.md b/docs/protocols/jmap.md index cb312d7..aa947e8 100644 --- a/docs/protocols/jmap.md +++ b/docs/protocols/jmap.md @@ -2,7 +2,7 @@ JMAP (JSON Meta Application Protocol) server connectivity, authentication, and mailbox listing. -> **Legacy name:** `jmaptool`. The legacy binary was removed in v3.1. Use `gomailtest jmap --flag` (see the migration table in README.md). +> **Legacy name:** `jmaptool`. Use `gomailtest jmap --flag` (see the migration table in README.md). ## What is JMAP? diff --git a/docs/protocols/msgraph.md b/docs/protocols/msgraph.md index a93aa37..86588e3 100644 --- a/docs/protocols/msgraph.md +++ b/docs/protocols/msgraph.md @@ -2,7 +2,7 @@ Exchange Online mailbox operations via Microsoft Graph API: send emails, manage calendar events, export inbox. -> **Legacy name:** `msgraphtool`. The legacy binary was removed in v3.1. Use `gomailtest msgraph --flag` (see the migration table in README.md). +> **Legacy name:** `msgraphtool`. Use `gomailtest msgraph --flag` (see the migration table in README.md). ## Quick Start diff --git a/docs/protocols/pop3.md b/docs/protocols/pop3.md index 8ab8689..7b08434 100644 --- a/docs/protocols/pop3.md +++ b/docs/protocols/pop3.md @@ -2,7 +2,7 @@ POP3 server connectivity, TLS configuration, authentication, and message listing. -> **Legacy name:** `pop3tool`. The legacy binary was removed in v3.1. Use `gomailtest pop3 --flag` (see the migration table in README.md). +> **Legacy name:** `pop3tool`. Use `gomailtest pop3 --flag` (see the migration table in README.md). ## Quick Start diff --git a/docs/protocols/smtp.md b/docs/protocols/smtp.md index 42c4149..a4259c3 100644 --- a/docs/protocols/smtp.md +++ b/docs/protocols/smtp.md @@ -2,7 +2,7 @@ SMTP connectivity, TLS diagnostics, authentication, and email sending. -> **Legacy name:** `smtptool`. The legacy binary was removed in v3.1. Use `gomailtest smtp --flag` (see the migration table in README.md). +> **Legacy name:** `smtptool`. Use `gomailtest smtp --flag` (see the migration table in README.md). ## Quick Start diff --git a/internal/smtp/tls/certificate.go b/internal/common/tls/certificate.go similarity index 100% rename from internal/smtp/tls/certificate.go rename to internal/common/tls/certificate.go diff --git a/internal/common/tls/certificate_test.go b/internal/common/tls/certificate_test.go new file mode 100644 index 0000000..4ae9624 --- /dev/null +++ b/internal/common/tls/certificate_test.go @@ -0,0 +1,162 @@ +//go:build !integration +// +build !integration + +package tls + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "testing" + "time" +) + +// makeSelfSignedCert builds a self-signed leaf certificate for testing the +// certificate analysis helpers without needing a live TLS handshake. +func makeSelfSignedCert(t *testing.T, cn string, dnsNames []string, notBefore, notAfter time.Time, keyBits int) *x509.Certificate { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, keyBits) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + Issuer: pkix.Name{CommonName: cn}, + NotBefore: notBefore, + NotAfter: notAfter, + DNSNames: dnsNames, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | + x509.KeyUsageCertSign | x509.KeyUsageDataEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + x509.ExtKeyUsageEmailProtection, + }, + BasicConstraintsValid: true, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + return cert +} + +func TestAnalyzeCertificateChain_Empty(t *testing.T) { + info := AnalyzeCertificateChain(nil, "example.com") + if info == nil { + t.Fatal("expected non-nil info for empty chain") + } + if info.ChainLength != 0 { + t.Errorf("ChainLength = %d, want 0", info.ChainLength) + } +} + +func TestAnalyzeCertificateChain_Valid(t *testing.T) { + now := time.Now() + cert := makeSelfSignedCert(t, "example.com", []string{"example.com", "www.example.com"}, + now.Add(-time.Hour), now.Add(365*24*time.Hour), 2048) + + info := AnalyzeCertificateChain([]*x509.Certificate{cert}, "example.com") + + if info.IsExpired { + t.Error("certificate should not be expired") + } + if !info.IsSelfSigned { + t.Error("certificate should be detected as self-signed") + } + if info.PublicKeySize != 2048 { + t.Errorf("PublicKeySize = %d, want 2048", info.PublicKeySize) + } + if len(info.SANs) != 2 { + t.Errorf("SANs = %v, want 2 entries", info.SANs) + } + if info.ChainLength != 1 { + t.Errorf("ChainLength = %d, want 1", info.ChainLength) + } +} + +func TestAnalyzeCertificateChain_Expired(t *testing.T) { + now := time.Now() + cert := makeSelfSignedCert(t, "old.example.com", []string{"old.example.com"}, + now.Add(-48*time.Hour), now.Add(-24*time.Hour), 2048) + + info := AnalyzeCertificateChain([]*x509.Certificate{cert}, "old.example.com") + if !info.IsExpired { + t.Error("certificate should be detected as expired") + } + + // Expired + self-signed should surface warnings via the shared helper. + warnings := CheckTLSWarnings(&TLSInfo{Version: "TLS 1.3", CipherSuiteStrength: "strong"}, info, false) + if !containsSubstr(warnings, "expired") { + t.Errorf("expected expiry warning, got %v", warnings) + } + if !containsSubstr(warnings, "Self-signed") { + t.Errorf("expected self-signed warning, got %v", warnings) + } +} + +func TestCheckTLSWarnings_ExpiringSoon(t *testing.T) { + now := time.Now() + cert := makeSelfSignedCert(t, "soon.example.com", []string{"soon.example.com"}, + now.Add(-time.Hour), now.Add(10*24*time.Hour), 2048) + + info := AnalyzeCertificateChain([]*x509.Certificate{cert}, "soon.example.com") + warnings := CheckTLSWarnings(&TLSInfo{Version: "TLS 1.3", CipherSuiteStrength: "strong"}, info, false) + if !containsSubstr(warnings, "expires soon") { + t.Errorf("expected 'expires soon' warning, got %v", warnings) + } +} + +func TestCheckTLSWarnings_LongValidity(t *testing.T) { + now := time.Now() + cert := makeSelfSignedCert(t, "long.example.com", []string{"long.example.com"}, + now.Add(-time.Hour), now.Add(500*24*time.Hour), 2048) + + info := AnalyzeCertificateChain([]*x509.Certificate{cert}, "long.example.com") + warnings := CheckTLSWarnings(&TLSInfo{Version: "TLS 1.3", CipherSuiteStrength: "strong"}, info, false) + if !containsSubstr(warnings, "exceeds 398 days") { + t.Errorf("expected long-validity warning, got %v", warnings) + } +} + +func TestAnalyzeCertificateChain_HostnameMismatch(t *testing.T) { + now := time.Now() + cert := makeSelfSignedCert(t, "example.com", []string{"example.com"}, + now.Add(-time.Hour), now.Add(24*time.Hour), 2048) + + info := AnalyzeCertificateChain([]*x509.Certificate{cert}, "wrong-host.com") + if info.VerificationStatus != "hostname_mismatch" { + t.Errorf("VerificationStatus = %q, want hostname_mismatch", info.VerificationStatus) + } + + warnings := CheckTLSWarnings(&TLSInfo{Version: "TLS 1.3", CipherSuiteStrength: "strong"}, info, false) + if !containsSubstr(warnings, "hostname does not match") { + t.Errorf("expected hostname mismatch warning, got %v", warnings) + } +} + +func TestAnalyzeCertificateChain_WeakKey(t *testing.T) { + now := time.Now() + cert := makeSelfSignedCert(t, "weak.example.com", []string{"weak.example.com"}, + now.Add(-time.Hour), now.Add(24*time.Hour), 1024) + + info := AnalyzeCertificateChain([]*x509.Certificate{cert}, "weak.example.com") + if info.PublicKeySize != 1024 { + t.Errorf("PublicKeySize = %d, want 1024", info.PublicKeySize) + } + + warnings := CheckTLSWarnings(&TLSInfo{Version: "TLS 1.3", CipherSuiteStrength: "strong"}, info, false) + if !containsSubstr(warnings, "Weak public key") { + t.Errorf("expected weak key warning, got %v", warnings) + } +} diff --git a/internal/smtp/tls/validation.go b/internal/common/tls/validation.go similarity index 100% rename from internal/smtp/tls/validation.go rename to internal/common/tls/validation.go diff --git a/internal/common/tls/validation_test.go b/internal/common/tls/validation_test.go new file mode 100644 index 0000000..396c46a --- /dev/null +++ b/internal/common/tls/validation_test.go @@ -0,0 +1,181 @@ +//go:build !integration +// +build !integration + +package tls + +import ( + "crypto/tls" + "strings" + "testing" +) + +// TestParseTLSVersion covers the consolidated string->constant parser that all +// protocol clients (SMTP, IMAP, POP3, EWS) now share. +func TestParseTLSVersion(t *testing.T) { + tests := []struct { + name string + input string + want uint16 + }{ + {"TLS 1.0", "1.0", tls.VersionTLS10}, + {"TLS 1.1", "1.1", tls.VersionTLS11}, + {"TLS 1.2", "1.2", tls.VersionTLS12}, + {"TLS 1.3", "1.3", tls.VersionTLS13}, + {"Whitespace trimmed", " 1.3 ", tls.VersionTLS13}, + {"Empty defaults to 1.2", "", tls.VersionTLS12}, + {"Unknown defaults to 1.2", "9.9", tls.VersionTLS12}, + {"Garbage defaults to 1.2", "tls1.2", tls.VersionTLS12}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ParseTLSVersion(tt.input); got != tt.want { + t.Errorf("ParseTLSVersion(%q) = 0x%04X, want 0x%04X", tt.input, got, tt.want) + } + }) + } +} + +// TestTLSVersionString covers the consolidated constant->display formatter. +func TestTLSVersionString(t *testing.T) { + tests := []struct { + name string + input uint16 + want string + }{ + {"TLS 1.0", tls.VersionTLS10, "TLS 1.0"}, + {"TLS 1.1", tls.VersionTLS11, "TLS 1.1"}, + {"TLS 1.2", tls.VersionTLS12, "TLS 1.2"}, + {"TLS 1.3", tls.VersionTLS13, "TLS 1.3"}, + {"SSL 3.0", 0x0300, "SSL 3.0"}, + {"Unknown", 0x9999, "Unknown (0x9999)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := TLSVersionString(tt.input); got != tt.want { + t.Errorf("TLSVersionString(0x%04X) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// TestParseTLSVersionRoundTrip ensures parse and format agree for the +// supported versions. +func TestParseTLSVersionRoundTrip(t *testing.T) { + for _, v := range []string{"1.0", "1.1", "1.2", "1.3"} { + if got := TLSVersionString(ParseTLSVersion(v)); got != "TLS "+v { + t.Errorf("round trip %q = %q, want %q", v, got, "TLS "+v) + } + } +} + +func TestAnalyzeCipherStrength(t *testing.T) { + tests := []struct { + name string + cipher uint16 + want string + }{ + {"AES-GCM is strong", tls.TLS_AES_128_GCM_SHA256, "strong"}, + {"ChaCha20-Poly1305 is strong", tls.TLS_CHACHA20_POLY1305_SHA256, "strong"}, + {"ECDHE-GCM is strong", tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, "strong"}, + {"CBC w/o SHA256 is weak", tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, "weak"}, + {"RC4 is deprecated", tls.TLS_RSA_WITH_RC4_128_SHA, "deprecated"}, + {"3DES is deprecated", tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, "deprecated"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := AnalyzeCipherStrength(tt.cipher); got != tt.want { + t.Errorf("AnalyzeCipherStrength(%s) = %q, want %q", + tls.CipherSuiteName(tt.cipher), got, tt.want) + } + }) + } +} + +func TestCheckTLSWarnings(t *testing.T) { + t.Run("deprecated version and weak cipher warn", func(t *testing.T) { + info := &TLSInfo{Version: "TLS 1.0", CipherSuiteStrength: "weak", CipherSuite: "X"} + warnings := CheckTLSWarnings(info, nil, false) + if !containsSubstr(warnings, "Deprecated TLS version") { + t.Errorf("expected deprecated TLS version warning, got %v", warnings) + } + if !containsSubstr(warnings, "Weak cipher suite") { + t.Errorf("expected weak cipher warning, got %v", warnings) + } + }) + + t.Run("SSL 3.0 warns", func(t *testing.T) { + info := &TLSInfo{Version: "SSL 3.0", CipherSuiteStrength: "strong"} + if !containsSubstr(CheckTLSWarnings(info, nil, false), "SSL 3.0") { + t.Error("expected SSL 3.0 warning") + } + }) + + t.Run("skipverify warns", func(t *testing.T) { + info := &TLSInfo{Version: "TLS 1.3", CipherSuiteStrength: "strong"} + if !containsSubstr(CheckTLSWarnings(info, nil, true), "verification disabled") { + t.Error("expected skipverify warning") + } + }) + + t.Run("clean config has no warnings", func(t *testing.T) { + info := &TLSInfo{Version: "TLS 1.3", CipherSuiteStrength: "strong"} + if w := CheckTLSWarnings(info, nil, false); len(w) != 0 { + t.Errorf("expected no warnings, got %v", w) + } + }) +} + +func TestGetTLSRecommendations(t *testing.T) { + t.Run("old version and weak cipher recommend upgrades", func(t *testing.T) { + info := &TLSInfo{Version: "TLS 1.0", CipherSuiteStrength: "weak"} + recs := GetTLSRecommendations(info) + if !containsSubstr(recs, "Upgrade to TLS 1.2 or 1.3") { + t.Errorf("expected version upgrade recommendation, got %v", recs) + } + if !containsSubstr(recs, "AEAD cipher") { + t.Errorf("expected cipher recommendation, got %v", recs) + } + }) + + t.Run("modern config has no recommendations", func(t *testing.T) { + info := &TLSInfo{Version: "TLS 1.3", CipherSuiteStrength: "strong"} + if r := GetTLSRecommendations(info); len(r) != 0 { + t.Errorf("expected no recommendations, got %v", r) + } + }) +} + +func TestAnalyzeTLSConnection(t *testing.T) { + state := &tls.ConnectionState{ + Version: tls.VersionTLS13, + CipherSuite: tls.TLS_AES_128_GCM_SHA256, + ServerName: "mail.example.com", + NegotiatedProtocol: "h2", + } + + info := AnalyzeTLSConnection(state) + if info.Version != "TLS 1.3" { + t.Errorf("Version = %q, want TLS 1.3", info.Version) + } + if info.CipherSuiteStrength != "strong" { + t.Errorf("CipherSuiteStrength = %q, want strong", info.CipherSuiteStrength) + } + if info.ServerName != "mail.example.com" { + t.Errorf("ServerName = %q, want mail.example.com", info.ServerName) + } + if info.NegotiatedProtocol != "h2" { + t.Errorf("NegotiatedProtocol = %q, want h2", info.NegotiatedProtocol) + } +} + +func containsSubstr(haystack []string, needle string) bool { + for _, s := range haystack { + if strings.Contains(s, needle) { + return true + } + } + return false +} diff --git a/internal/common/version/version.go b/internal/common/version/version.go index 3bc2dad..b96b826 100644 --- a/internal/common/version/version.go +++ b/internal/common/version/version.go @@ -2,13 +2,13 @@ package version // Version is the current version of the tool suite. // This is the single source of truth for versioning across all tools. -// All tools (msgraphtool, smtptool) share the same version number. +// Every protocol subcommand of the gomailtest binary shares this version. // // To update the version: // 1. Change the Version constant below // 2. Create a changelog entry in ChangeLog/{version}.md // 3. Commit with message: "Bump version to {version}" -const Version = "3.4.0" +const Version = "3.4.1" // Get returns the current version string. // This is a convenience function for accessing the Version constant. diff --git a/internal/protocols/ews/ews_client.go b/internal/protocols/ews/ews_client.go index 0d0bed9..28214d4 100644 --- a/internal/protocols/ews/ews_client.go +++ b/internal/protocols/ews/ews_client.go @@ -15,6 +15,7 @@ import ( "github.com/Azure/go-ntlmssp" "github.com/ehlo-pl/gomailtesttool/internal/common/network" + tlsutil "github.com/ehlo-pl/gomailtesttool/internal/common/tls" ) const ( @@ -255,12 +256,7 @@ func (c *EWSClient) applyAuth(req *http.Request) { func buildTLSConfig(config *Config) (*tls.Config, error) { tlsCfg := &tls.Config{ InsecureSkipVerify: config.SkipVerify, //nolint:gosec // user-controlled flag with warning shown in validateConfiguration - } - switch config.TLSVersion { - case "1.3": - tlsCfg.MinVersion = tls.VersionTLS13 - default: // 1.2 - tlsCfg.MinVersion = tls.VersionTLS12 + MinVersion: tlsutil.ParseTLSVersion(config.TLSVersion), } return tlsCfg, nil } diff --git a/internal/protocols/ews/testconnect.go b/internal/protocols/ews/testconnect.go index 6b72638..cd9478f 100644 --- a/internal/protocols/ews/testconnect.go +++ b/internal/protocols/ews/testconnect.go @@ -10,6 +10,7 @@ import ( "time" "github.com/ehlo-pl/gomailtesttool/internal/common/logger" + tlsutil "github.com/ehlo-pl/gomailtesttool/internal/common/tls" ) // testConnect performs an HTTP/TLS probe against the EWS endpoint. @@ -65,7 +66,7 @@ func testConnect(ctx context.Context, config *Config, csvLogger logger.Logger, s if resp.TLS != nil { state := resp.TLS - tlsVersion = tlsVersionString(state.Version) + tlsVersion = tlsutil.TLSVersionString(state.Version) cipherSuite = tlsCipherName(state.CipherSuite) certs = state.PeerCertificates diff --git a/internal/protocols/ews/utils.go b/internal/protocols/ews/utils.go index 20c0a12..2b7f27f 100644 --- a/internal/protocols/ews/utils.go +++ b/internal/protocols/ews/utils.go @@ -7,25 +7,6 @@ import ( "strings" ) -// tlsVersionString converts a TLS version constant to a display string. -func tlsVersionString(version uint16) string { - switch version { - case tls.VersionTLS10: - return "TLS 1.0" - case tls.VersionTLS11: - return "TLS 1.1" - case tls.VersionTLS12: - return "TLS 1.2" - case tls.VersionTLS13: - return "TLS 1.3" - default: - if version == 0 { - return "" - } - return fmt.Sprintf("TLS 0x%04x", version) - } -} - // tlsCipherName returns the cipher suite name, or hex if unknown. func tlsCipherName(id uint16) string { name := tls.CipherSuiteName(id) diff --git a/internal/protocols/imap/imap_client.go b/internal/protocols/imap/imap_client.go index 289ac67..9a9be30 100644 --- a/internal/protocols/imap/imap_client.go +++ b/internal/protocols/imap/imap_client.go @@ -13,6 +13,7 @@ import ( "github.com/ehlo-pl/gomailtesttool/internal/common/network" "github.com/ehlo-pl/gomailtesttool/internal/common/ratelimit" + tlsutil "github.com/ehlo-pl/gomailtesttool/internal/common/tls" imapprotocol "github.com/ehlo-pl/gomailtesttool/internal/imap/protocol" ) @@ -75,7 +76,7 @@ func (c *IMAPClient) Connect(ctx context.Context) error { TLSConfig: &tls.Config{ ServerName: c.host, InsecureSkipVerify: c.config.SkipVerify, - MinVersion: parseTLSVersion(c.config.TLSVersion), + MinVersion: tlsutil.ParseTLSVersion(c.config.TLSVersion), }, } @@ -336,22 +337,6 @@ func (c *IMAPClient) Close() error { return nil } -// parseTLSVersion parses a TLS version string to a constant. -func parseTLSVersion(version string) uint16 { - switch version { - case "1.3": - return tls.VersionTLS13 - case "1.2": - return tls.VersionTLS12 - case "1.1": - return tls.VersionTLS11 - case "1.0": - return tls.VersionTLS10 - default: - return tls.VersionTLS12 - } -} - // convertCaps converts go-imap capabilities to our protocol.Capabilities. func convertCaps(caps imap.CapSet) *imapprotocol.Capabilities { var capsList []string diff --git a/internal/protocols/pop3/pop3_client.go b/internal/protocols/pop3/pop3_client.go index 6b18032..5f50a67 100644 --- a/internal/protocols/pop3/pop3_client.go +++ b/internal/protocols/pop3/pop3_client.go @@ -12,6 +12,7 @@ import ( "github.com/ehlo-pl/gomailtesttool/internal/common/network" "github.com/ehlo-pl/gomailtesttool/internal/common/ratelimit" + tlsutil "github.com/ehlo-pl/gomailtesttool/internal/common/tls" "github.com/ehlo-pl/gomailtesttool/internal/pop3/protocol" ) @@ -75,7 +76,7 @@ func (c *POP3Client) Connect(ctx context.Context) error { tlsConfig := &tls.Config{ ServerName: c.host, InsecureSkipVerify: c.config.SkipVerify, - MinVersion: parseTLSVersion(c.config.TLSVersion), + MinVersion: tlsutil.ParseTLSVersion(c.config.TLSVersion), } conn, err = tls.DialWithDialer(dialer, "tcp", address, tlsConfig) if err != nil { @@ -149,7 +150,7 @@ func (c *POP3Client) StartTLS(tlsConfig *tls.Config) error { tlsConfig = &tls.Config{ ServerName: c.host, InsecureSkipVerify: c.config.SkipVerify, - MinVersion: parseTLSVersion(c.config.TLSVersion), + MinVersion: tlsutil.ParseTLSVersion(c.config.TLSVersion), } } @@ -418,19 +419,3 @@ func (c *POP3Client) Close() error { } return nil } - -// parseTLSVersion parses a TLS version string to a constant. -func parseTLSVersion(version string) uint16 { - switch version { - case "1.3": - return tls.VersionTLS13 - case "1.2": - return tls.VersionTLS12 - case "1.1": - return tls.VersionTLS11 - case "1.0": - return tls.VersionTLS10 - default: - return tls.VersionTLS12 - } -} diff --git a/internal/protocols/smtp/sendmail.go b/internal/protocols/smtp/sendmail.go index 0f8c2dc..c785ff8 100644 --- a/internal/protocols/smtp/sendmail.go +++ b/internal/protocols/smtp/sendmail.go @@ -16,7 +16,7 @@ import ( "github.com/ehlo-pl/gomailtesttool/internal/common/email" "github.com/ehlo-pl/gomailtesttool/internal/common/logger" "github.com/ehlo-pl/gomailtesttool/internal/common/security" - smtptls "github.com/ehlo-pl/gomailtesttool/internal/smtp/tls" + tlsutil "github.com/ehlo-pl/gomailtesttool/internal/common/tls" ) // SendMail performs end-to-end email sending test. @@ -129,7 +129,7 @@ func SendMail(ctx context.Context, config *Config, csvLogger logger.Logger, slog // STARTTLS if on common SMTP submission ports and available // Ports: 25 (SMTP), 587 (Submission), 2525/2526 (Alternative submission), 1025 (Testing/Alt) fmt.Println("Upgrading to TLS...") - tlsVersion := smtptls.ParseTLSVersion(config.TLSVersion) + tlsVersion := tlsutil.ParseTLSVersion(config.TLSVersion) tlsConfig := &tls.Config{ ServerName: client.GetHost(), // resolved MX hostname if --use-mx, otherwise --host InsecureSkipVerify: config.SkipVerify, diff --git a/internal/protocols/smtp/smtp_client.go b/internal/protocols/smtp/smtp_client.go index d222338..0523acc 100644 --- a/internal/protocols/smtp/smtp_client.go +++ b/internal/protocols/smtp/smtp_client.go @@ -11,15 +11,15 @@ import ( "strings" "github.com/Azure/go-ntlmssp" + "github.com/ehlo-pl/gomailtesttool/internal/common/network" + "github.com/ehlo-pl/gomailtesttool/internal/common/ratelimit" + tlsutil "github.com/ehlo-pl/gomailtesttool/internal/common/tls" + "github.com/ehlo-pl/gomailtesttool/internal/smtp/protocol" krb5client "github.com/jcmturner/gokrb5/v8/client" krb5config "github.com/jcmturner/gokrb5/v8/config" "github.com/jcmturner/gokrb5/v8/gssapi" "github.com/jcmturner/gokrb5/v8/spnego" "github.com/jcmturner/gokrb5/v8/types" - "github.com/ehlo-pl/gomailtesttool/internal/common/network" - "github.com/ehlo-pl/gomailtesttool/internal/common/ratelimit" - "github.com/ehlo-pl/gomailtesttool/internal/smtp/protocol" - smtptls "github.com/ehlo-pl/gomailtesttool/internal/smtp/tls" ) // SMTPClient wraps SMTP connection with enhanced diagnostics. @@ -129,7 +129,7 @@ func (c *SMTPClient) Connect(ctx context.Context) error { if c.config.SMTPS { c.debugLogMessage("SMTPS mode: Performing immediate TLS handshake...") - tlsVersion := smtptls.ParseTLSVersion(c.config.TLSVersion) + tlsVersion := tlsutil.ParseTLSVersion(c.config.TLSVersion) tlsConfig := &tls.Config{ ServerName: c.host, InsecureSkipVerify: c.config.SkipVerify, diff --git a/internal/protocols/smtp/testauth.go b/internal/protocols/smtp/testauth.go index 92e23b2..c4e2ef3 100644 --- a/internal/protocols/smtp/testauth.go +++ b/internal/protocols/smtp/testauth.go @@ -10,7 +10,7 @@ import ( "github.com/ehlo-pl/gomailtesttool/internal/common/logger" "github.com/ehlo-pl/gomailtesttool/internal/common/security" - smtptls "github.com/ehlo-pl/gomailtesttool/internal/smtp/tls" + tlsutil "github.com/ehlo-pl/gomailtesttool/internal/common/tls" ) // testAuth performs SMTP authentication testing. @@ -121,7 +121,7 @@ func testAuth(ctx context.Context, config *Config, csvLogger logger.Logger, slog } else if !config.NoStartTLS && (config.Port == 25 || config.Port == 587) && caps.SupportsSTARTTLS() { // STARTTLS if on port 25/587 and available fmt.Println("Upgrading to TLS before authentication...") - tlsVersion := smtptls.ParseTLSVersion(config.TLSVersion) + tlsVersion := tlsutil.ParseTLSVersion(config.TLSVersion) tlsConfig := &tls.Config{ ServerName: client.GetHost(), // resolved MX hostname if --use-mx, otherwise --host InsecureSkipVerify: config.SkipVerify, diff --git a/internal/protocols/smtp/teststarttls.go b/internal/protocols/smtp/teststarttls.go index f281390..1024855 100644 --- a/internal/protocols/smtp/teststarttls.go +++ b/internal/protocols/smtp/teststarttls.go @@ -10,7 +10,7 @@ import ( "time" "github.com/ehlo-pl/gomailtesttool/internal/common/logger" - smtptls "github.com/ehlo-pl/gomailtesttool/internal/smtp/tls" + tlsutil "github.com/ehlo-pl/gomailtesttool/internal/common/tls" ) // testStartTLS performs comprehensive TLS/SSL testing with detailed diagnostics. @@ -122,7 +122,7 @@ func testStartTLS(ctx context.Context, config *Config, csvLogger logger.Logger, // Perform STARTTLS handshake fmt.Println("Performing TLS handshake...") - tlsVersion := smtptls.ParseTLSVersion(config.TLSVersion) + tlsVersion := tlsutil.ParseTLSVersion(config.TLSVersion) tlsConfig := &tls.Config{ ServerName: client.GetHost(), // resolved MX hostname if --use-mx, otherwise --host InsecureSkipVerify: config.SkipVerify, @@ -151,15 +151,15 @@ func testStartTLS(ctx context.Context, config *Config, csvLogger logger.Logger, } // Analyze TLS connection - tlsInfo := smtptls.AnalyzeTLSConnection(connState) + tlsInfo := tlsutil.AnalyzeTLSConnection(connState) printTLSInfo(tlsInfo) // Analyze certificate chain - certInfo := smtptls.AnalyzeCertificateChain(connState.PeerCertificates, client.GetHost()) + certInfo := tlsutil.AnalyzeCertificateChain(connState.PeerCertificates, client.GetHost()) printCertificateInfo(certInfo) // Check for warnings - warnings := smtptls.CheckTLSWarnings(tlsInfo, certInfo, config.SkipVerify) + warnings := tlsutil.CheckTLSWarnings(tlsInfo, certInfo, config.SkipVerify) if len(warnings) > 0 { fmt.Println("\n⚠ TLS Warnings:") fmt.Println(strings.Repeat("─", 60)) @@ -170,7 +170,7 @@ func testStartTLS(ctx context.Context, config *Config, csvLogger logger.Logger, } // Get recommendations - recommendations := smtptls.GetTLSRecommendations(tlsInfo) + recommendations := tlsutil.GetTLSRecommendations(tlsInfo) if len(recommendations) > 0 { fmt.Println("\n💡 Recommendations:") fmt.Println(strings.Repeat("─", 60)) @@ -228,7 +228,7 @@ func testStartTLS(ctx context.Context, config *Config, csvLogger logger.Logger, } // printTLSInfo displays TLS connection details. -func printTLSInfo(info *smtptls.TLSInfo) { +func printTLSInfo(info *tlsutil.TLSInfo) { fmt.Println("TLS Connection Details:") fmt.Println(strings.Repeat("═", 60)) fmt.Printf(" Protocol Version: %s\n", info.Version) @@ -244,7 +244,7 @@ func printTLSInfo(info *smtptls.TLSInfo) { } // printCertificateInfo displays certificate details. -func printCertificateInfo(info *smtptls.CertificateInfo) { +func printCertificateInfo(info *tlsutil.CertificateInfo) { fmt.Println("\nCertificate Information:") fmt.Println(strings.Repeat("═", 60)) fmt.Printf(" Subject: %s\n", info.Subject) diff --git a/internal/protocols/smtp/tls_display.go b/internal/protocols/smtp/tls_display.go index 84c4530..6158f8a 100644 --- a/internal/protocols/smtp/tls_display.go +++ b/internal/protocols/smtp/tls_display.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - smtptls "github.com/ehlo-pl/gomailtesttool/internal/smtp/tls" + tlsutil "github.com/ehlo-pl/gomailtesttool/internal/common/tls" ) // TLSCSVData holds formatted TLS and certificate data for CSV logging. @@ -33,20 +33,20 @@ func displayComprehensiveTLSInfo(tlsState *tls.ConnectionState, hostname string, } // Analyze TLS connection - tlsInfo := smtptls.AnalyzeTLSConnection(tlsState) + tlsInfo := tlsutil.AnalyzeTLSConnection(tlsState) if tlsInfo != nil { displayTLSConnectionInfo(tlsInfo) } // Analyze certificate chain if len(tlsState.PeerCertificates) > 0 { - certInfo := smtptls.AnalyzeCertificateChain(tlsState.PeerCertificates, hostname) + certInfo := tlsutil.AnalyzeCertificateChain(tlsState.PeerCertificates, hostname) if certInfo != nil { displayCertificateInfo(certInfo) } // Check for TLS warnings (skipVerify=false for display purposes) - warnings := smtptls.CheckTLSWarnings(tlsInfo, certInfo, false) + warnings := tlsutil.CheckTLSWarnings(tlsInfo, certInfo, false) if len(warnings) > 0 && verbose { fmt.Println("\nSecurity Warnings:") fmt.Println(strings.Repeat("═", 60)) @@ -58,7 +58,7 @@ func displayComprehensiveTLSInfo(tlsState *tls.ConnectionState, hostname string, // Show recommendations if in verbose mode if verbose { - recommendations := smtptls.GetTLSRecommendations(tlsInfo) + recommendations := tlsutil.GetTLSRecommendations(tlsInfo) if len(recommendations) > 0 { fmt.Println("\nRecommendations:") fmt.Println(strings.Repeat("═", 60)) @@ -72,7 +72,7 @@ func displayComprehensiveTLSInfo(tlsState *tls.ConnectionState, hostname string, } // displayTLSConnectionInfo displays TLS connection details. -func displayTLSConnectionInfo(info *smtptls.TLSInfo) { +func displayTLSConnectionInfo(info *tlsutil.TLSInfo) { if info == nil { return } @@ -92,7 +92,7 @@ func displayTLSConnectionInfo(info *smtptls.TLSInfo) { } // displayCertificateInfo displays certificate details. -func displayCertificateInfo(info *smtptls.CertificateInfo) { +func displayCertificateInfo(info *tlsutil.CertificateInfo) { if info == nil { return } @@ -159,12 +159,12 @@ func formatTLSInfoForCSV(tlsState *tls.ConnectionState, hostname string) TLSCSVD } // Analyze TLS connection - tlsInfo := smtptls.AnalyzeTLSConnection(tlsState) + tlsInfo := tlsutil.AnalyzeTLSConnection(tlsState) // Analyze certificate chain - var certInfo *smtptls.CertificateInfo + var certInfo *tlsutil.CertificateInfo if len(tlsState.PeerCertificates) > 0 { - certInfo = smtptls.AnalyzeCertificateChain(tlsState.PeerCertificates, hostname) + certInfo = tlsutil.AnalyzeCertificateChain(tlsState.PeerCertificates, hostname) } // Build CSV data structure