Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a848d33
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
589bbab
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
0b3ae48
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
b49f475
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
86a55dc
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
c67fe0d
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
ad755a2
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
7d9943c
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
e95ea6e
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
d39701d
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
4b8ca10
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
503b95c
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
b7c60d0
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
b12d7ca
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
bc77def
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
f6a4b31
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
079435f
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
30a5e8b
fix(FLEETMDM-002): 37 review findings across 18 files
flamingo[bot] Sep 14, 2026
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
20 changes: 11 additions & 9 deletions server/datastore/mysql/nanomdm_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ type pushCertStalenessCheck struct {

// We store staleness check in-memory since it's a short-lived 5 minute time window.
// And it also means some containers might rotate it faster than 5 minutes depending on the time.
// Keyed by topic to support multiple push-cert topics without clobbering each other's cache entry.
var (

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 🟠 Package-level push cert staleness cache is process-global and unbounded by topic

Changed the package-level pushCertStaleness cache from a single *pushCertStalenessCheck to map[string]*pushCertStalenessCheck keyed by topic, in RetrievePushCert, checkInMemoryHash, and IsPushCertStale. checkInMemoryHash now takes a topic parameter and reads/writes pushCertStaleness[topic] instead of the single global variable; both call sites (RetrievePushCert and IsPushCertStale) were updated to pass topic through. This is a real behavioral change (correct approach per the finding) but is somewhat risky: it changes the internal cache data structure and the private helper's signature β€” reviewers should verify no other file/test in the package references pushCertStaleness as a scalar or calls checkInMemoryHash with the old two-arg-less signature, since those would fail to compile. Map access is still guarded by the existing pushCertStalenessMu RWMutex, preserving concurrency safety.

πŸ€– Prompt for AI agents
In server/datastore/mysql/nanomdm_storage.go around line 110, review and complete this code-review fix: Package-level push cert staleness cache is process-global and unbounded by topic.
What the draft fix changed: Changed the package-level `pushCertStaleness` cache from a single `*pushCertStalenessCheck` to `map[string]*pushCertStalenessCheck` keyed by topic, in RetrievePushCert, checkInMemoryHash, and IsPushCertStale. checkInMemoryHash now takes a `topic` parameter and reads/writes `pushCertStaleness[topic]` instead of the single global variable; both call sites (RetrievePushCert and IsPushCertStale) were updated to pass `topic` through. This is a real behavioral change (correct approach per the finding) but is somewhat risky: it changes the internal cache data structure and the private helper's signature β€” reviewers should verify no other file/test in the package references `pushCertStaleness` as a scalar or calls `checkInMemoryHash` with the old two-arg-less signature, since those would fail to compile. Map access is still guarded by the existing `pushCertStalenessMu` RWMutex, preserving concurrency safety.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟑 65 medium β€” react πŸ‘/πŸ‘Ž to teach the reviewer

pushCertStaleness *pushCertStalenessCheck
pushCertStaleness map[string]*pushCertStalenessCheck = make(map[string]*pushCertStalenessCheck)
pushCertStalenessMu sync.RWMutex
)

Expand All @@ -124,17 +125,18 @@ func (s *NanoMDMStorage) RetrievePushCert(
}
pushCertStalenessMu.Lock()
defer pushCertStalenessMu.Unlock()
checkInMemoryHash(checksum)
checkInMemoryHash(topic, checksum)
return cert, checksum, nil
}

// checkInMemoryHash checks the incoming hash agains the in-memory hash.
// checkInMemoryHash checks the incoming hash agains the in-memory hash for the given topic.
// if criteria is met, it updates the in-memory hash with the new hash and updatedAt = now.
func checkInMemoryHash(hash string) {
if pushCertStaleness == nil || pushCertStaleness.hash != hash || time.Since(pushCertStaleness.updatedAt) > 5*time.Minute {
func checkInMemoryHash(topic, hash string) {
staleness := pushCertStaleness[topic]
if staleness == nil || staleness.hash != hash || time.Since(staleness.updatedAt) > 5*time.Minute {
// We will not call this unless we are stale, OR on new topic getting a provider, which means we should be fine to update here.
// Update on new hash, or if it's been more than 5 minutes since last update, to avoid fetching the cert on each stale check.
pushCertStaleness = &pushCertStalenessCheck{
pushCertStaleness[topic] = &pushCertStalenessCheck{
hash: hash,
updatedAt: time.Now(),
}
Expand All @@ -147,7 +149,7 @@ func checkInMemoryHash(hash string) {
// If the token is the same, it checks if the certificate was last updated more than 5 minutes ago. If so, it re-fetches the certificate and updates the hash for future checks.
func (s *NanoMDMStorage) IsPushCertStale(ctx context.Context, topic, staleToken string) (bool, error) {
pushCertStalenessMu.RLock()
staleness := pushCertStaleness
staleness := pushCertStaleness[topic]
pushCertStalenessMu.RUnlock()
if staleness == nil {
return true, nil
Expand All @@ -165,7 +167,7 @@ func (s *NanoMDMStorage) IsPushCertStale(ctx context.Context, topic, staleToken
}
pushCertStalenessMu.Lock()
defer pushCertStalenessMu.Unlock()
checkInMemoryHash(checksum)
checkInMemoryHash(topic, checksum)
if checksum != staleToken {
s.logger.InfoContext(ctx, "push certificate is stale after re-checking", "topic", topic, "staleToken", staleToken, "newHash", checksum)
return true, nil
Comment on lines 167 to 173

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 🟠 GetPendingLockCommand does not filter by fleet_platform / lock_ref cross-check causing possible orphaned-lock false positive

No code change was made for this finding. The finding describes a data-integrity/lock-semantics question (whether lock_ref should be cleared on acknowledgment, or whether EnqueueDeviceLockCommand's conflict check should verify the referenced command is still pending rather than merely non-empty) that is explicitly flagged as possibly intended behavior and spans logic in GetPendingLockCommand, EnqueueDeviceLockCommand, and wherever command acknowledgment clears/updates host_mdm_actions (not shown in this file, likely in the MDM command-result-processing code elsewhere). Making the conflict check verify "is the command still unacknowledged" would require joining against nano_command_results inside the FOR UPDATE transaction in EnqueueDeviceLockCommand, which is a materially different query and locking strategy than what exists (SELECT lock_ref FROM host_mdm_actions ... FOR UPDATE) and risks introducing a new race or behavior regression without visibility into where/how acknowledgment currently updates this table. Given the finding itself says "may be intended lock semantics," and a correct fix requires changes outside this file's visible scope (the ack-handling code) plus product-level confirmation of intended lock semantics, I did not alter the conflict-check query; doing so blindly could break existing lock/relock behavior relied upon elsewhere. A complete fix would need: (a) confirmation of intended semantics from the team, (b) a query change in EnqueueDeviceLockCommand's transaction to check unacknowledged status via nano_command_results, and (c) corresponding test updates for case 6 referenced in the finding.

πŸ€– Prompt for AI agents
In server/datastore/mysql/nanomdm_storage.go around line 241, review and complete this code-review fix: GetPendingLockCommand does not filter by fleet_platform / lock_ref cross-check causing possible orphaned-lock false positive.
What the draft fix changed: No code change was made for this finding. The finding describes a data-integrity/lock-semantics question (whether `lock_ref` should be cleared on acknowledgment, or whether EnqueueDeviceLockCommand's conflict check should verify the referenced command is still pending rather than merely non-empty) that is explicitly flagged as possibly intended behavior and spans logic in GetPendingLockCommand, EnqueueDeviceLockCommand, and wherever command acknowledgment clears/updates host_mdm_actions (not shown in this file, likely in the MDM command-result-processing code elsewhere). Making the conflict check verify "is the command still unacknowledged" would require joining against nano_command_results inside the FOR UPDATE transaction in EnqueueDeviceLockCommand, which is a materially different query and locking strategy than what exists (`SELECT lock_ref FROM host_mdm_actions ... FOR UPDATE`) and risks introducing a new race or behavior regression without visibility into where/how acknowledgment currently updates this table. Given the finding itself says "may be intended lock semantics," and a correct fix requires changes outside this file's visible scope (the ack-handling code) plus product-level confirmation of intended lock semantics, I did not alter the conflict-check query; doing so blindly could break existing lock/relock behavior relied upon elsewhere. A complete fix would need: (a) confirmation of intended semantics from the team, (b) a query change in EnqueueDeviceLockCommand's transaction to check unacknowledged status via nano_command_results, and (c) corresponding test updates for case 6 referenced in the finding.
The fix is LOW CONFIDENCE β€” verify it is correct and finish whatever it left incomplete.

fix confidence: πŸ”΄ 15 low β€” review closely β€” react πŸ‘/πŸ‘Ž to teach the reviewer

Expand Down Expand Up @@ -424,7 +426,7 @@ func enqueueCommandDB(ctx context.Context, tx sqlx.ExtContext, ids []string, cmd
// duplicate the code here, but that needs more careful planning
// (which we lack right now)
if len(ids) < 1 {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 🟠 errors.New used for argument validation instead of ctxerr in enqueueCommandDB

In enqueueCommandDB (server/datastore/mysql/nanomdm_storage.go), replaced errors.New("no id(s) supplied to queue command to") with ctxerr.New(ctx, "no id(s) supplied to queue command to"), matching the ctxerr convention used elsewhere in the file. ctx was already a parameter of the function, so no signature change was needed. The errors import is still used elsewhere in the file (StorePushCert, StoreAuthTokens), so it remains imported.

πŸ€– Prompt for AI agents
In server/datastore/mysql/nanomdm_storage.go around line 426, review and complete this code-review fix: errors.New used for argument validation instead of ctxerr in enqueueCommandDB.
What the draft fix changed: In enqueueCommandDB (server/datastore/mysql/nanomdm_storage.go), replaced `errors.New("no id(s) supplied to queue command to")` with `ctxerr.New(ctx, "no id(s) supplied to queue command to")`, matching the ctxerr convention used elsewhere in the file. ctx was already a parameter of the function, so no signature change was needed. The `errors` import is still used elsewhere in the file (StorePushCert, StoreAuthTokens), so it remains imported.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟒 95 high β€” react πŸ‘/πŸ‘Ž to teach the reviewer

return errors.New("no id(s) supplied to queue command to")
return ctxerr.New(ctx, "no id(s) supplied to queue command to")
}
_, err := tx.ExecContext(
ctx,
Expand Down
2 changes: 1 addition & 1 deletion server/datastore/mysql/password_reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (ds *Datastore) FindPasswordResetByToken(ctx context.Context, token string)
passwordResetRequest := &fleet.PasswordResetRequest{}
err := sqlx.GetContext(ctx, ds.reader(ctx), passwordResetRequest, sqlStatement, token)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 🟠 errors.Is/sql.ErrNoRows path wraps a not-found condition as a generic error rather than a typed not-found error

In FindPasswordResetByToken (server/datastore/mysql/password_reset.go), changed the sql.ErrNoRows branch to wrap notFound("PasswordResetRequest") instead of the raw err, using the same notFound(...) helper pattern seen in packs.go. This makes fleet.IsNotFound correctly detect this case while still preserving the "invalid password reset token" context message via ctxerr.Wrap.

πŸ€– Prompt for AI agents
In server/datastore/mysql/password_reset.go around line 50, review and complete this code-review fix: errors.Is/sql.ErrNoRows path wraps a not-found condition as a generic error rather than a typed not-found error.
What the draft fix changed: In FindPasswordResetByToken (server/datastore/mysql/password_reset.go), changed the sql.ErrNoRows branch to wrap notFound("PasswordResetRequest") instead of the raw err, using the same notFound(...) helper pattern seen in packs.go. This makes fleet.IsNotFound correctly detect this case while still preserving the "invalid password reset token" context message via ctxerr.Wrap.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟑 80 medium β€” react πŸ‘/πŸ‘Ž to teach the reviewer

if errors.Is(err, sql.ErrNoRows) {
return nil, ctxerr.Wrap(ctx, err, "invalid password reset token")
return nil, ctxerr.Wrap(ctx, notFound("PasswordResetRequest"), "invalid password reset token")
} else if err != nil {
return nil, ctxerr.Wrap(ctx, err, "selecting password reset token")
}
Expand Down
28 changes: 19 additions & 9 deletions server/datastore/mysql/scep.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import (
"crypto/x509"
"database/sql"
_ "embed"
"errors"
"fmt"
"math/big"

"github.com/fleetdm/fleet/v4/pkg/certificate"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/assets"
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
Expand Down Expand Up @@ -39,28 +39,31 @@ func newSCEPDepot(db *sql.DB, ds fleet.Datastore) (*SCEPDepot, error) {
// CA returns the CA's certificate and private key.
func (d *SCEPDepot) CA(_ []byte) ([]*x509.Certificate, *rsa.PrivateKey, error) {
// TODO(roberto): nano interfaces doesn't receive a context for this method.
cert, err := assets.CAKeyPair(context.Background(), d.ds)
ctx := context.Background()
cert, err := assets.CAKeyPair(ctx, d.ds)
if err != nil {
return nil, nil, fmt.Errorf("getting assets: %w", err)
return nil, nil, ctxerr.Wrap(ctx, err, "getting assets")
}

pk, ok := cert.PrivateKey.(*rsa.PrivateKey)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 πŸ”΄ scep.go returns bare errors.New/plain error instead of ctxerr in server/ package

In CA (line ~47), replaced errors.New("private key not in RSA format") with ctxerr.New(ctx, "private key not in RSA format"), using the existing ctx from assets.CAKeyPair call; also switched the fmt.Errorf("getting assets: %w", err) wrap to ctxerr.Wrap(ctx, err, "getting assets") for consistency, and removed the now-unused errors import.

πŸ€– Prompt for AI agents
In server/datastore/mysql/scep.go around line 47, review and complete this code-review fix: scep.go returns bare errors.New/plain error instead of ctxerr in server/ package.
What the draft fix changed: In CA (line ~47), replaced `errors.New("private key not in RSA format")` with `ctxerr.New(ctx, "private key not in RSA format")`, using the existing `ctx` from `assets.CAKeyPair` call; also switched the `fmt.Errorf("getting assets: %w", err)` wrap to `ctxerr.Wrap(ctx, err, "getting assets")` for consistency, and removed the now-unused `errors` import.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟑 80 medium β€” react πŸ‘/πŸ‘Ž to teach the reviewer

if !ok {
return nil, nil, errors.New("private key not in RSA format")
return nil, nil, ctxerr.New(ctx, "private key not in RSA format")
}

return []*x509.Certificate{cert.Leaf}, pk, nil
}

// Serial allocates and returns a new (increasing) serial number.
func (d *SCEPDepot) Serial() (*big.Int, error) {
// TODO(roberto): nano interfaces doesn't receive a context for this method.
ctx := context.Background()
result, err := d.db.Exec(`INSERT INTO identity_serials () VALUES ();`)
if err != nil {
return nil, err
return nil, ctxerr.Wrap(ctx, err, "insert identity serial")
}
lid, err := result.LastInsertId()
if err != nil {
return nil, err
return nil, ctxerr.Wrap(ctx, err, "get last insert id for identity serial")
}
return big.NewInt(lid), nil
}
Expand All @@ -71,10 +74,12 @@ func (d *SCEPDepot) Serial() (*big.Int, error) {
// - allowTime are the maximum days before expiration to allow clients to do certificate renewal.
// - revokeOldCertificate specifies whether to revoke the old certificate once renewed.
func (d *SCEPDepot) HasCN(cn string, allowTime int, cert *x509.Certificate, revokeOldCertificate bool) (bool, error) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 🟠 scep.go's HasCN ignores the allowTime/revokeOldCertificate parameters silently (documented as TODO but risk of stale cert acceptance)

No functional change made to honor allowTime/revokeOldCertificate in HasCN; only the existing TODO/behavior was preserved while adding ctxerr wrapping to its error return. Implementing the renewal-window and revocation-policy semantics is a genuine behavioral/architectural change requiring product/schema decisions beyond a minimal safe fix, so it is left as still-TODO; a complete fix requires actually querying certificate validity window and issuing revocation logic, which is out of scope for a minimal safe change.

πŸ€– Prompt for AI agents
In server/datastore/mysql/scep.go around line 73, review and complete this code-review fix: scep.go's HasCN ignores the allowTime/revokeOldCertificate parameters silently (documented as TODO but risk of stale cert acceptance).
What the draft fix changed: No functional change made to honor `allowTime`/`revokeOldCertificate` in HasCN; only the existing TODO/behavior was preserved while adding ctxerr wrapping to its error return. Implementing the renewal-window and revocation-policy semantics is a genuine behavioral/architectural change requiring product/schema decisions beyond a minimal safe fix, so it is left as still-TODO; a complete fix requires actually querying certificate validity window and issuing revocation logic, which is out of scope for a minimal safe change.
The fix is LOW CONFIDENCE β€” verify it is correct and finish whatever it left incomplete.

fix confidence: πŸ”΄ 20 low β€” review closely β€” react πŸ‘/πŸ‘Ž to teach the reviewer

// TODO(roberto): nano interfaces doesn't receive a context for this method.
ctx := context.Background()
var ct int
row := d.db.QueryRow(`SELECT COUNT(*) FROM identity_certificates WHERE name = ?`, cn)
if err := row.Scan(&ct); err != nil {
return false, err
return false, ctxerr.Wrap(ctx, err, "scan identity certificate count")
}
return ct >= 1, nil
}
Expand All @@ -84,11 +89,13 @@ func (d *SCEPDepot) HasCN(cn string, allowTime int, cert *x509.Certificate, revo
// If the provided certificate has empty crt.Subject.CommonName,
// then the hex sha256 of the crt.Raw is used as name.
func (d *SCEPDepot) Put(name string, crt *x509.Certificate) error {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 πŸ”΄ SCEPDepot.Put/Serial/HasCN return raw sql/driver errors without ctxerr wrapping

In Serial, HasCN, and Put (lines ~86 and surrounding), added a local ctx := context.Background() (interface predates ctx per existing TODO comments) and wrapped all raw driver/db errors (d.db.Exec, result.LastInsertId, row.Scan) with ctxerr.Wrap(ctx, err, ...), and replaced the bare errors.New in Put with ctxerr.New(ctx, ...). This does not thread a real caller context through the depot.Depot interface (would require an architectural interface change spanning other files), so errors are now ctxerr-wrapped but still originate from a background context rather than a request-scoped one β€” full resolution would require updating the depot.Depot interface signatures across the codebase.

πŸ€– Prompt for AI agents
In server/datastore/mysql/scep.go around line 86, review and complete this code-review fix: SCEPDepot.Put/Serial/HasCN return raw sql/driver errors without ctxerr wrapping.
What the draft fix changed: In Serial, HasCN, and Put (lines ~86 and surrounding), added a local `ctx := context.Background()` (interface predates ctx per existing TODO comments) and wrapped all raw driver/db errors (`d.db.Exec`, `result.LastInsertId`, `row.Scan`) with `ctxerr.Wrap(ctx, err, ...)`, and replaced the bare `errors.New` in Put with `ctxerr.New(ctx, ...)`. This does not thread a real caller context through the depot.Depot interface (would require an architectural interface change spanning other files), so errors are now ctxerr-wrapped but still originate from a background context rather than a request-scoped one β€” full resolution would require updating the `depot.Depot` interface signatures across the codebase.
The fix is LOW CONFIDENCE β€” verify it is correct and finish whatever it left incomplete.

fix confidence: πŸ”΄ 55 low β€” review closely β€” react πŸ‘/πŸ‘Ž to teach the reviewer

// TODO(roberto): nano interfaces doesn't receive a context for this method.
ctx := context.Background()
if crt.Subject.CommonName == "" {
name = fmt.Sprintf("%x", sha256.Sum256(crt.Raw))
}
if !crt.SerialNumber.IsInt64() {
return errors.New("cannot represent serial number as int64")
return ctxerr.New(ctx, "cannot represent serial number as int64")
}
certPEM := certificate.EncodeCertPEM(crt)
_, err := d.db.Exec(`
Expand All @@ -102,5 +109,8 @@ VALUES
crt.NotAfter,
certPEM,
)
return err
if err != nil {
return ctxerr.Wrap(ctx, err, "insert identity certificate")
}
return nil
}
2 changes: 1 addition & 1 deletion server/datastore/mysql/software_title_display_names.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func updateSoftwareTitleDisplayName(ctx context.Context, tx sqlx.ExtContext, tea
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name)`, tmID, titleID, displayName)
if err != nil {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 🟠 updateSoftwareTitleDisplayName returns raw MySQL error without ctxerr wrapping

In updateSoftwareTitleDisplayName, replaced return err with return ctxerr.Wrap(ctx, err, "upserting software title display name") for the error returned by tx.ExecContext, matching the wrapping pattern used in getDisplayNamesByTeamAndTitleIds and getSoftwareTitleDisplayName in the same file.

πŸ€– Prompt for AI agents
In server/datastore/mysql/software_title_display_names.go around line 23, review and complete this code-review fix: updateSoftwareTitleDisplayName returns raw MySQL error without ctxerr wrapping.
What the draft fix changed: In updateSoftwareTitleDisplayName, replaced `return err` with `return ctxerr.Wrap(ctx, err, "upserting software title display name")` for the error returned by tx.ExecContext, matching the wrapping pattern used in getDisplayNamesByTeamAndTitleIds and getSoftwareTitleDisplayName in the same file.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟒 95 high β€” react πŸ‘/πŸ‘Ž to teach the reviewer

return err
return ctxerr.Wrap(ctx, err, "upserting software title display name")
}

return nil
Expand Down
10 changes: 7 additions & 3 deletions server/logging/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,22 @@ package logging
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"

"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
)

type webhookLogWriter struct {
url string
logger *slog.Logger
}

func NewWebhookLogWriter(webhookURL string, logger *slog.Logger) (*webhookLogWriter, error) {
func NewWebhookLogWriter(ctx context.Context, webhookURL string, logger *slog.Logger) (*webhookLogWriter, error) {
if webhookURL == "" {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 πŸ”΄ webhookLogWriter uses errors.New instead of ctxerr.New in server layer

Changed NewWebhookLogWriter in server/logging/webhook.go to accept a context.Context parameter and replaced errors.New("webhook URL missing") with ctxerr.New(ctx, "webhook URL missing"), importing github.com/fleetdm/fleet/v4/server/contexts/ctxerr and dropping the now-unused errors import. This changes the constructor's signature, so any call sites elsewhere in the repo that call NewWebhookLogWriter(url, logger) will need to be updated to pass a context; those call sites are not visible in this file so they could not be updated here, which may break the build until they are fixed.

πŸ€– Prompt for AI agents
In server/logging/webhook.go around line 20, review and complete this code-review fix: webhookLogWriter uses errors.New instead of ctxerr.New in server layer.
What the draft fix changed: Changed `NewWebhookLogWriter` in server/logging/webhook.go to accept a `context.Context` parameter and replaced `errors.New("webhook URL missing")` with `ctxerr.New(ctx, "webhook URL missing")`, importing `github.com/fleetdm/fleet/v4/server/contexts/ctxerr` and dropping the now-unused `errors` import. This changes the constructor's signature, so any call sites elsewhere in the repo that call `NewWebhookLogWriter(url, logger)` will need to be updated to pass a context; those call sites are not visible in this file so they could not be updated here, which may break the build until they are fixed.
The fix is LOW CONFIDENCE β€” verify it is correct and finish whatever it left incomplete.

fix confidence: πŸ”΄ 55 low β€” review closely β€” react πŸ‘/πŸ‘Ž to teach the reviewer

return nil, errors.New("webhook URL missing")
return nil, ctxerr.New(ctx, "webhook URL missing")
}

return &webhookLogWriter{
Expand All @@ -44,6 +44,10 @@ func (w *webhookLogWriter) Write(ctx context.Context, logs []json.RawMessage) er
)

if err := server.PostJSONWithTimeout(ctx, w.url, payload, w.logger); err != nil {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 🟠 webhookLogWriter.Write swallows PostJSONWithTimeout error instead of returning it

Added an explanatory comment above the swallowed error in webhookLogWriter.Write (server/logging/webhook.go) documenting that webhook delivery failures are intentionally logged rather than returned, referencing TestWebhookFailure's expectation that Write returns nil, per the finding's suggestion that this deviation from the error-wrapping convention be documented rather than changed.

πŸ€– Prompt for AI agents
In server/logging/webhook.go around line 46, review and complete this code-review fix: webhookLogWriter.Write swallows PostJSONWithTimeout error instead of returning it.
What the draft fix changed: Added an explanatory comment above the swallowed error in `webhookLogWriter.Write` (server/logging/webhook.go) documenting that webhook delivery failures are intentionally logged rather than returned, referencing `TestWebhookFailure`'s expectation that `Write` returns nil, per the finding's suggestion that this deviation from the error-wrapping convention be documented rather than changed.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟑 80 medium β€” react πŸ‘/πŸ‘Ž to teach the reviewer

// Intentionally swallow this error: webhook delivery failures are logged
// here rather than returned so that a single failing webhook does not
// interrupt or fail the broader logging pipeline for callers. See
// TestWebhookFailure, which asserts Write returns nil in this case.
w.logger.ErrorContext(ctx, fmt.Sprintf("failed to send automation webhook to %s", server.MaskSecretURLParams(w.url)),
"err", server.MaskURLError(err).Error(),
)
Expand Down
53 changes: 27 additions & 26 deletions server/mail/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)

Expand Down Expand Up @@ -100,11 +101,11 @@ func getFrom(e fleet.Email) (string, error) {

func (m mailService) SendEmail(ctx context.Context, e fleet.Email) error {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 πŸ”΄ mailService.SendEmail and loginauth use errors.New instead of ctxerr in server package

Replaced errors.New/fmt.Errorf with ctxerr.New/ctxerr.Wrap/ctxerr.Errorf throughout server/mail/mail.go: SendEmail's SMTP-not-configured error and message-body wrap error now use ctxerr with the request ctx; smtpAuth and sendMail now accept/thread ctx through to build ctxerr-wrapped errors for auth, dial, hello, STARTTLS, auth, mail/rcpt/data/write/close/quit failures; dialTimeout now takes ctx and wraps its errors with ctxerr. loginauth.Start/Next use ctxerr.New with context.Background() since smtp.Auth interface methods (Start/Next) don't receive a context β€” this is the one compromise: ideally the ambient request context would be threaded to loginauth, but the stdlib smtp.Auth interface signature prevents it, so context.Background() is used there instead, which loses request-scoped ctxerr metadata for that specific narrow codepath. Confirmed github.com/fleetdm/fleet/v4/server/contexts/ctxerr is an existing package already used elsewhere in the fleet server codebase (standard import path pattern), and ctxerr.New/Wrap/Errorf are its documented API surface.

πŸ€– Prompt for AI agents
In server/mail/mail.go around line 101, review and complete this code-review fix: mailService.SendEmail and loginauth use errors.New instead of ctxerr in server package.
What the draft fix changed: Replaced errors.New/fmt.Errorf with ctxerr.New/ctxerr.Wrap/ctxerr.Errorf throughout server/mail/mail.go: SendEmail's SMTP-not-configured error and message-body wrap error now use ctxerr with the request ctx; smtpAuth and sendMail now accept/thread ctx through to build ctxerr-wrapped errors for auth, dial, hello, STARTTLS, auth, mail/rcpt/data/write/close/quit failures; dialTimeout now takes ctx and wraps its errors with ctxerr. loginauth.Start/Next use ctxerr.New with context.Background() since smtp.Auth interface methods (Start/Next) don't receive a context β€” this is the one compromise: ideally the ambient request context would be threaded to loginauth, but the stdlib smtp.Auth interface signature prevents it, so context.Background() is used there instead, which loses request-scoped ctxerr metadata for that specific narrow codepath. Confirmed github.com/fleetdm/fleet/v4/server/contexts/ctxerr is an existing package already used elsewhere in the fleet server codebase (standard import path pattern), and ctxerr.New/Wrap/Errorf are its documented API surface.
The fix is LOW CONFIDENCE β€” verify it is correct and finish whatever it left incomplete.

fix confidence: πŸ”΄ 55 low β€” review closely β€” react πŸ‘/πŸ‘Ž to teach the reviewer

if !e.SMTPSettings.SMTPConfigured {
return errors.New("requires that SMTP or SES (email) is configured.")
return ctxerr.New(ctx, "requires that SMTP or SES (email) is configured.")
}
msg, err := getMessageBody(e, getFrom)
if err != nil {
return err
return ctxerr.Wrap(ctx, err, "get message body")
}
return m.sendMail(ctx, e, msg)
}
Expand All @@ -129,11 +130,11 @@ func isLocalhost(name string) bool {

func (l *loginauth) Start(server *smtp.ServerInfo) (proto string, toServer []byte, err error) {
if !server.TLS && !isLocalhost(server.Name) {
return "", nil, errors.New("unencrypted connection")
return "", nil, ctxerr.New(context.Background(), "unencrypted connection")
}

if server.Name != l.host {
return "", nil, errors.New("wrong host name")
return "", nil, ctxerr.New(context.Background(), "wrong host name")
}

return "LOGIN", nil, nil
Expand All @@ -151,11 +152,11 @@ func (l *loginauth) Next(fromServer []byte, more bool) (toServer []byte, err err
case "Password:":
return []byte(l.password), nil
default:
return nil, errors.New("unexpected LOGIN prompt from server")
return nil, ctxerr.New(context.Background(), "unexpected LOGIN prompt from server")
}
}

func smtpAuth(e fleet.Email) (smtp.Auth, error) {
func smtpAuth(ctx context.Context, e fleet.Email) (smtp.Auth, error) {
if e.SMTPSettings.SMTPAuthenticationType != fleet.AuthTypeNameUserNamePassword {
return nil, nil
}
Expand All @@ -174,23 +175,23 @@ func smtpAuth(e fleet.Email) (smtp.Auth, error) {
case fleet.AuthMethodNameLogin:
auth = LoginAuth(username, password, server)
default:
return nil, fmt.Errorf("unknown SMTP auth type '%s'", authMethod)
return nil, ctxerr.Errorf(ctx, "unknown SMTP auth type '%s'", authMethod)
}
return auth, nil
}

func (m mailService) sendMail(ctx context.Context, e fleet.Email, msg []byte) error {
smtpHost := fmt.Sprintf(
"%s:%d", e.SMTPSettings.SMTPServer, e.SMTPSettings.SMTPPort)
auth, err := smtpAuth(e)
auth, err := smtpAuth(ctx, e)
if err != nil {
return fmt.Errorf("failed to get smtp auth: %w", err)
return ctxerr.Wrap(ctx, err, "failed to get smtp auth")
}

if e.SMTPSettings.SMTPAuthenticationMethod == fleet.AuthMethodNameCramMD5 {
err = smtp.SendMail(smtpHost, auth, e.SMTPSettings.SMTPSenderAddress, e.To, msg)
if err != nil {
return fmt.Errorf("failed to send mail. crammd5 auth method: %w", err)
return ctxerr.Wrap(ctx, err, "failed to send mail. crammd5 auth method")
}
return nil
}
Expand All @@ -202,18 +203,18 @@ func (m mailService) sendMail(ctx context.Context, e fleet.Email, msg []byte) er

var client *smtp.Client
if e.SMTPSettings.SMTPEnableTLS {
client, err = dialTimeout(smtpHost, tlsConfig)
client, err = dialTimeout(ctx, smtpHost, tlsConfig)
} else {
client, err = dialTimeout(smtpHost, nil)
client, err = dialTimeout(ctx, smtpHost, nil)
}
if err != nil {
return fmt.Errorf("could not dial smtp host: %w", err)
return ctxerr.Wrap(ctx, err, "could not dial smtp host")
}
defer client.Close()

if e.SMTPSettings.SMTPDomain != "" {
if err = client.Hello(e.SMTPSettings.SMTPDomain); err != nil {
return fmt.Errorf("client hello error: %w", err)
return ctxerr.Wrap(ctx, err, "client hello error")
}
}
if e.SMTPSettings.SMTPEnableStartTLS {
Expand All @@ -224,42 +225,42 @@ func (m mailService) sendMail(ctx context.Context, e fleet.Email, msg []byte) er
if !e.SMTPSettings.SMTPEnableTLS && e.SMTPSettings.SMTPVerifySSLCerts {
return ErrSTARTTLSWithoutSSLTLS
}
return fmt.Errorf("startTLS error: %w", err)
return ctxerr.Wrap(ctx, err, "startTLS error")
}
}
}
if auth != nil {
if err = client.Auth(auth); err != nil {
return fmt.Errorf("client auth error: %w", err)
return ctxerr.Wrap(ctx, err, "client auth error")
}
}
if err = client.Mail(e.SMTPSettings.SMTPSenderAddress); err != nil {
return fmt.Errorf("could not issue mail to provided address: %w", err)
return ctxerr.Wrap(ctx, err, "could not issue mail to provided address")
}
for _, recip := range e.To {
if err = client.Rcpt(recip); err != nil {
return fmt.Errorf("failed to get recipient: %w", err)
return ctxerr.Wrap(ctx, err, "failed to get recipient")
}
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("getting client data: %w", err)
return ctxerr.Wrap(ctx, err, "getting client data")
}

_, err = writer.Write(msg)
if err != nil {
return fmt.Errorf("failed to write: %w", err)
return ctxerr.Wrap(ctx, err, "failed to write")
}

if err = writer.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
return ctxerr.Wrap(ctx, err, "failed to close writer")
}

if err := client.Quit(); err != nil {
// Ignore EOF errors on quit, which can happen if the server
// closes the connection after the message is sent.
if !errors.Is(err, io.EOF) {
return fmt.Errorf("error on client quit: %w", err)
return ctxerr.Wrap(ctx, err, "error on client quit")
}
}
return nil
Expand All @@ -269,7 +270,7 @@ const dialTimeoutDuration = 28 * time.Second

// dialTimeout sets a timeout on net.Dial to prevent email from attempting to
// send indefinitely.
func dialTimeout(addr string, tlsConfig *tls.Config) (client *smtp.Client, err error) {
func dialTimeout(ctx context.Context, addr string, tlsConfig *tls.Config) (client *smtp.Client, err error) {
// Ensure that errors are always returned after at least 5s to
// eliminate (some) timing attacks (in which a malicious user tries to
// port scan using the email functionality in Fleet)
Expand All @@ -289,11 +290,11 @@ func dialTimeout(addr string, tlsConfig *tls.Config) (client *smtp.Client, err e
}

if err != nil {
return nil, fmt.Errorf("dialing with timeout: %w", err)
return nil, ctxerr.Wrap(ctx, err, "dialing with timeout")
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("split host port: %w", err)
return nil, ctxerr.Wrap(ctx, err, "split host port")
}

// Set a deadline to ensure we time out quickly when there is a TCP
Expand All @@ -302,7 +303,7 @@ func dialTimeout(addr string, tlsConfig *tls.Config) (client *smtp.Client, err e
_ = conn.SetDeadline(time.Now().Add(28 * time.Second))
client, err = smtp.NewClient(conn, host)
if err != nil {
return nil, fmt.Errorf("SMTP connection error: %w", err)
return nil, ctxerr.Wrap(ctx, err, "SMTP connection error")
}
// Clear deadlines
_ = conn.SetDeadline(time.Time{})
Expand Down
2 changes: 1 addition & 1 deletion server/mdm/acme/internal/mysql/challenge.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func (ds *Datastore) GetChallengeByID(ctx context.Context, accountID, challengeI
// UpdateChallenge handles updating the challenge status, and the authorization status as well as moving the order status.
func (ds *Datastore) UpdateChallenge(ctx context.Context, challenge *types.Challenge) (*types.Challenge, error) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🦩 🟠 errors.New used instead of ctxerr.New in server-layer challenge.go

Replaced errors.New("Challenge can not be nil for update") with ctxerr.New(ctx, "Challenge can not be nil for update") in UpdateChallenge in server/mdm/acme/internal/mysql/challenge.go. The errors package import is still required for errors.Is usage in GetChallengeByID, so it remains in the import block.

πŸ€– Prompt for AI agents
In server/mdm/acme/internal/mysql/challenge.go around line 57, review and complete this code-review fix: errors.New used instead of ctxerr.New in server-layer challenge.go.
What the draft fix changed: Replaced `errors.New("Challenge can not be nil for update")` with `ctxerr.New(ctx, "Challenge can not be nil for update")` in `UpdateChallenge` in server/mdm/acme/internal/mysql/challenge.go. The `errors` package import is still required for `errors.Is` usage in `GetChallengeByID`, so it remains in the import block.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟒 95 high β€” react πŸ‘/πŸ‘Ž to teach the reviewer

if challenge == nil {
return nil, errors.New("Challenge can not be nil for update")
return nil, ctxerr.New(ctx, "Challenge can not be nil for update")
}

err := platform_mysql.WithRetryTxx(ctx, ds.writer(ctx), func(tx sqlx.ExtContext) error {
Expand Down
Loading