Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ require (
github.com/c-robinson/iplib v1.0.8
github.com/cenkalti/backoff v2.2.1+incompatible
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/google/go-cmp v0.7.0
github.com/google/go-containerregistry v0.21.2
github.com/google/subcommands v1.2.0
Expand Down
4 changes: 0 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,6 @@ github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0o
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
Expand Down
151 changes: 120 additions & 31 deletions reporter/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,83 @@ import (
"fmt"
"net"
"net/mail"
"net/smtp"
"strings"
"time"

sasl "github.com/emersion/go-sasl"
smtp "github.com/emersion/go-smtp"
"golang.org/x/xerrors"

"github.com/future-architect/vuls/config"
"github.com/future-architect/vuls/models"
)

// plainAuth implements smtp.Auth for the PLAIN mechanism without
// stdlib's TLS enforcement, preserving behavioral parity with the
// previously used go-smtp library for TLSMode "None" configurations.
type plainAuth struct {
identity, username, password string
}
Comment on lines +21 to +23
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plainAuth includes a host field but it’s never read. Either remove it to reduce confusion, or use it for an explicit server name check (e.g., compare against server.Name/configured host) so the field has a purpose.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Removed the unused host field from plainAuth struct and the corresponding argument in newAuth. Fixed in 7898f1e.


func (a *plainAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
if !server.TLS {
advertised := false
for _, mech := range server.Auth {
if strings.EqualFold(mech, "PLAIN") {
advertised = true
break
}
}
if !advertised {
return "", nil, xerrors.New("unencrypted connection: PLAIN auth requires TLS or explicit server advertisement")
}
}
resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password)
return "PLAIN", resp, nil
}

func (a *plainAuth) Next(_ []byte, more bool) ([]byte, error) {
if more {
return nil, xerrors.New("unexpected server challenge")
}
return nil, nil
Comment on lines +25 to +46
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new plainAuth behavior isn’t covered by tests (only loginAuth is). Since this auth implementation is security- and interoperability-sensitive, add unit tests for plainAuth.Start (TLS vs non-TLS + advertised mechanisms) and plainAuth.Next (unexpected challenge when more==true).

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TestPlainAuthStart and TestPlainAuthNext covering TLS/non-TLS paths, identity inclusion in response, and challenge handling. Fixed in 7898f1e.

}

// loginAuth implements smtp.Auth for the LOGIN mechanism.
type loginAuth struct {
username, password string
}

func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
if !server.TLS {
advertised := false
for _, mech := range server.Auth {
if strings.EqualFold(mech, "LOGIN") {
advertised = true
break
}
}
if !advertised {
return "", nil, xerrors.New("unencrypted connection: LOGIN auth requires TLS or explicit server advertisement")
Comment on lines +56 to +64
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loginAuth.Start allows LOGIN auth on non-TLS connections as long as the server advertises "LOGIN". Advertising an auth mechanism doesn’t imply transport security, so this can still send credentials in cleartext (e.g., TLSMode "None" or auto-mode when STARTTLS isn’t available). Consider enforcing the same safety gate as smtp.PlainAuth (require server.TLS unless the server is localhost), or otherwise require an explicit user opt-in before permitting LOGIN over plaintext.

Suggested change
advertised := false
for _, mech := range server.Auth {
if mech == "LOGIN" {
advertised = true
break
}
}
if !advertised {
return "", nil, xerrors.New("unencrypted connection: LOGIN auth requires TLS or explicit server advertisement")
host, _, err := net.SplitHostPort(server.Name)
if err != nil {
host = server.Name
}
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
return "", nil, xerrors.New("unencrypted connection: LOGIN auth requires TLS when not connecting to localhost")

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the suggestion. The current loginAuth.Start already checks server.TLS and only falls through to non-TLS if the server explicitly advertises LOGIN. I have also added strings.EqualFold for case-insensitive mechanism matching (see comment 3 fix).

Regarding making it even stricter (fully blocking non-TLS like smtp.PlainAuth): this PR is focused on removing the go-smtp dependency with behavioral parity. The original go-smtp code did not enforce TLS checks on LOGIN auth. Changing security policy would be a separate concern for a follow-up PR.

That said, I have applied the same "TLS or explicit advertisement" guard pattern consistently to both plainAuth and loginAuth, which is a reasonable middle ground. Fixed in 4bfa654.

}
}
return "LOGIN", nil, nil
}

func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if !more {
return nil, nil
}
prompt := strings.TrimSpace(strings.ToLower(string(fromServer)))
switch {
case strings.Contains(prompt, "username"):
return []byte(a.username), nil
case strings.Contains(prompt, "password"):
return []byte(a.password), nil
default:
return nil, xerrors.Errorf("unexpected server challenge: %q", fromServer)
}
}

// EMailWriter send mail
type EMailWriter struct {
FormatOneEMail bool
Expand Down Expand Up @@ -99,8 +165,37 @@ type emailSender struct {
conf config.SMTPConf
}

func (e *emailSender) dialTLS(addr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return nil, xerrors.Errorf("Failed to create TLS connection to SMTP server: %w", err)
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
_ = conn.Close()
return nil, xerrors.Errorf("Failed to parse SMTP server address: %w", err)
}
c, err := smtp.NewClient(conn, host)
if err != nil {
_ = conn.Close()
return nil, xerrors.Errorf("Failed to create SMTP client over TLS: %w", err)
}
Comment on lines +168 to +182
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

net.SplitHostPort(addr) errors are ignored; if parsing fails (e.g., unexpected addr format), host may be empty and the SMTP client will be initialized with an incorrect server name. Handle the error explicitly and fail fast (closing the TLS conn) or fall back to a known-good host value (e.g., the configured hostname) rather than proceeding with an empty/invalid host.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd3f0dd. net.SplitHostPort error is now checked and returns an error with conn.Close() cleanup, consistent with the existing error-handling pattern in dialTLS.

return c, nil
}

func (e *emailSender) dialStartTLS(addr string, tlsConfig *tls.Config) (*smtp.Client, error) {
c, err := smtp.Dial(addr)
if err != nil {
return nil, xerrors.Errorf("Failed to create connection to SMTP server: %w", err)
}
if err := c.StartTLS(tlsConfig); err != nil {
_ = c.Close()
return nil, xerrors.Errorf("Failed to STARTTLS: %w", err)
}
return c, nil
}

func (e *emailSender) sendMail(smtpServerAddr, message string) (err error) {
var auth sasl.Client
emailConf := e.conf
tlsConfig := &tls.Config{
ServerName: emailConf.SMTPAddr,
Expand All @@ -112,61 +207,57 @@ func (e *emailSender) sendMail(smtpServerAddr, message string) (err error) {
case "":
switch emailConf.SMTPPort {
case "465":
c, err = smtp.DialTLS(smtpServerAddr, tlsConfig)
c, err = e.dialTLS(smtpServerAddr, tlsConfig)
if err != nil {
return xerrors.Errorf("Failed to create TLS connection to SMTP server: %w", err)
return err
}
defer c.Close()
default:
c, err = smtp.Dial(smtpServerAddr)
if err != nil {
return xerrors.Errorf("Failed to create connection to SMTP server: %w", err)
}
defer c.Close()

if ok, _ := c.Extension("STARTTLS"); ok {
c, err = smtp.DialStartTLS(smtpServerAddr, tlsConfig)
if err != nil {
return xerrors.Errorf("Failed to create STARTTLS connection to SMTP server: %w", err)
if err := c.StartTLS(tlsConfig); err != nil {
_ = c.Close()
return xerrors.Errorf("Failed to STARTTLS: %w", err)
}
defer c.Close()
}
}
case "None":
c, err = smtp.Dial(smtpServerAddr)
if err != nil {
return xerrors.Errorf("Failed to create connection to SMTP server: %w", err)
}
defer c.Close()
case "STARTTLS":
c, err = smtp.DialStartTLS(smtpServerAddr, tlsConfig)
c, err = e.dialStartTLS(smtpServerAddr, tlsConfig)
if err != nil {
return xerrors.Errorf("Failed to create STARTTLS connection to SMTP server: %w", err)
return err
}
defer c.Close()
case "SMTPS":
c, err = smtp.DialTLS(smtpServerAddr, tlsConfig)
c, err = e.dialTLS(smtpServerAddr, tlsConfig)
if err != nil {
return xerrors.Errorf("Failed to create TLS connection to SMTP server: %w", err)
return err
}
defer c.Close()
default:
return xerrors.New(`invalid TLS mode. accepts: ["", "None", "STARTTLS", "SMTPS"]`)
}
defer c.Close()

if ok, param := c.Extension("AUTH"); ok {
authList := strings.Split(param, " ")
auth = e.newSaslClient(authList)
if err = c.Auth(auth); err != nil {
return xerrors.Errorf("Failed to authenticate: %w", err)
authList := strings.Fields(param)
auth := e.newAuth(authList)
if auth != nil {
if err = c.Auth(auth); err != nil {
return xerrors.Errorf("Failed to authenticate: %w", err)
}
}
}

if err = c.Mail(emailConf.From, nil); err != nil {
if err = c.Mail(emailConf.From); err != nil {
return xerrors.Errorf("Failed to send Mail command: %w", err)
}
for _, to := range emailConf.To {
if err = c.Rcpt(to, nil); err != nil {
if err = c.Rcpt(to); err != nil {
return xerrors.Errorf("Failed to send Rcpt command: %w", err)
}
}
Expand Down Expand Up @@ -222,15 +313,13 @@ func NewEMailSender(cnf config.SMTPConf) EMailSender {
return &emailSender{cnf}
}

func (e *emailSender) newSaslClient(authList []string) sasl.Client {
func (e *emailSender) newAuth(authList []string) smtp.Auth {
for _, v := range authList {
switch v {
switch strings.ToUpper(v) {
case "PLAIN":
auth := sasl.NewPlainClient("", e.conf.User, e.conf.Password)
return auth
return &plainAuth{identity: "", username: e.conf.User, password: e.conf.Password}
case "LOGIN":
Comment on lines 319 to 321
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using smtp.PlainAuth here changes behavior for TLSMode "None" (or any plaintext connection): stdlib PlainAuth refuses to send credentials unless server.TLS is true (except localhost), so AUTH PLAIN may now fail even when users intentionally configured plaintext. If preserving the previous behavior is required, consider either (1) skipping auth on plaintext, (2) allowing plaintext auth only when TLSMode == "None" via a dedicated opt-in path, or (3) implementing an explicit insecure plain auth implementation for that mode.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent catch. You are right that stdlib smtp.PlainAuth refuses to send credentials on non-TLS connections (except localhost), which would break users who intentionally configured TLSMode: "None".

Fixed: replaced smtp.PlainAuth with a custom plainAuth implementation that mirrors the same guard pattern as loginAuth - it allows PLAIN auth on non-TLS connections if the server explicitly advertises the PLAIN mechanism. This preserves behavioral parity with the original go-smtp library that this PR replaces.

The custom plainAuth still constructs the SASL PLAIN response correctly (\x00username\x00password) and warns on non-TLS when the mechanism is not explicitly advertised. Fixed in 4bfa654.

auth := sasl.NewLoginClient(e.conf.User, e.conf.Password)
return auth
return &loginAuth{username: e.conf.User, password: e.conf.Password}
}
Comment on lines +316 to 323
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

newAuth matches auth mechanism names case-sensitively ("PLAIN"/"LOGIN"), but SMTP extension tokens are case-insensitive and some servers may advertise these as e.g. "login". Consider normalizing (e.g., strings.ToUpper(v) or strings.EqualFold) before switching so auth negotiation doesn’t fail unexpectedly.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid point - RFC 4954 specifies that SASL mechanism names are case-insensitive. Fixed: newAuth now uses strings.ToUpper(v) for the switch, and both loginAuth.Start and the new plainAuth.Start use strings.EqualFold when scanning server.Auth. Fixed in 4bfa654.

}
return nil
Expand Down
Loading
Loading