Skip to content

sec(api/plans): stop leaking raw account UUID + DB error in plan-account validation log - #969

Merged
cristim merged 4 commits into
feat/multicloud-web-frontendfrom
sec/plan-account-provider-log-leak
Jun 8, 2026
Merged

cristim merged 4 commits into
feat/multicloud-web-frontendfrom
sec/plan-account-provider-log-leak

Conversation

@cristim

@cristim cristim commented Jun 5, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #965. Fixes the PII/info-leak gap left by #946 (which closed #944).

validatePlanAccountProviders called h.config.GetCloudAccount and on a DB error returned:

fmt.Errorf("accounts: failed to get account %s: %w", aid, getErr)

This non-ClientError propagated to the router's logging.Errorf("API error: %v"), logging the raw account UUID and raw DB error string. Issue #944(b) required structured logs with account count (never raw IDs).

Changes

  • internal/api/handler_accounts.go: wrap the GetCloudAccount DB-error path with mapCreatePlanStorageError, emitting a structured log with provider list + account count; returns a generic 500 ClientError. The ErrNotFound sentinel still maps to 404; the explicit (nil, nil) not-found path is unchanged.
  • pkg/logging/logger.go: add SetOutput as a test seam for capturing default-logger output in tests.
  • internal/api/handler_accounts_test.go: two regression tests - TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak and TestValidatePlanAccountProviders_AccountNotFound_Still404.
  • internal/api/handler_purchases_test.go + handler_purchases_guards_test.go: fix pre-existing build failure (Session.Role field removed in fix(api/purchases): base build broken - session.Role removed by #907 but still used in authorizeSessionExecuteDirect (#803) #940 but test not updated) and gofmt drift, so the test suite compiles.

Test plan

  • go test ./internal/api/... -count=1 passes (1460 tests)
  • TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak: DB error yields 500 ClientError with no UUID/DB-error in message or log
  • TestValidatePlanAccountProviders_AccountNotFound_Still404: not-found still returns 404 referencing the missing ID
  • gofmt -l clean on all changed files
  • go vet ./... clean
  • go build ./... succeeds

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling for plan and account operations to prevent sensitive identifiers and database error details from appearing in API responses and logs. Error messages are now generic and user-friendly.
  • Tests

    • Added regression tests to verify sensitive information is not exposed during error handling in plan management and account operations.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/internal Team-internal only type/security Security finding labels Jun 5, 2026
@cristim

cristim commented Jun 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 24 minutes and 14 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 55ebc758-ba7b-4af9-8f97-fa515d1f6e94

📥 Commits

Reviewing files that changed from the base of the PR and between e31ed68 and b409abf.

📒 Files selected for processing (7)
  • internal/api/handler_accounts.go
  • internal/api/handler_accounts_test.go
  • internal/api/handler_plans.go
  • internal/api/handler_plans_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • pkg/logging/logger.go
📝 Walkthrough

Walkthrough

This PR systematically prevents sensitive information (plan/account UUIDs and raw database error strings) from leaking through API error logs. It adds logger output redirection to the logging package, introduces a test helper for capturing logs, and applies mapCreatePlanStorageError error mapping across account validation, plan CRUD, and purchase/contact handlers.

Changes

PII/DB Error Leakage Prevention

Layer / File(s) Summary
Logger output redirection infrastructure
pkg/logging/logger.go
Logger gains an output field; New and With preserve it; new SetOutput function redirects the default logger and returns the previous writer for test restoration.
Test logging capture helper
internal/api/handler_accounts_test.go
Imports bytes and logging packages; adds captureDefaultLog helper that redirects default logger to a buffer for assertions on log contents.
Account provider validation error handling
internal/api/handler_accounts.go, internal/api/handler_accounts_test.go
validatePlanAccountProviders and getPlanForAccountProviderValidation replace fmt.Errorf wrapping with mapCreatePlanStorageError to map "not found" vs DB errors without leaking account/plan UUIDs; regression tests verify 500 ClientError responses and logs omit sensitive identifiers.
Plan CRUD operations error handling
internal/api/handler_plans.go, internal/api/handler_plans_test.go
updatePlan, getPlanForPurchaseCreation, and patchPlan replace fmt.Errorf with mapCreatePlanStorageError for plan fetch/update failures; regression tests verify 500 ClientError messages and logs exclude plan UUID and raw DB error strings.
Purchase and contact resolution error handling
internal/api/handler_purchases.go, internal/api/handler_purchases_test.go
disablePlan uses mapCreatePlanStorageError for plan failures; gatherAccountContactEmails returns generic 500 ClientError on lookup failures; lookupContactEmail removes account UUID and raw error from warning logs; regression tests verify no PII leakage in 500 ClientError responses or captured logs.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • LeanerCloud/CUDly#944: This PR directly addresses the issue by replacing raw fmt.Errorf wrapping in plan/account lookup paths with mapCreatePlanStorageError to prevent opaque 500s and PII/DB-error leakage.
  • LeanerCloud/CUDly#965: This PR modifies the same code paths in handler_accounts.go by wrapping GetCloudAccount errors in validatePlanAccountProviders with mapCreatePlanStorageError to stop leaking raw account UUIDs/DB error strings.

Possibly related PRs

  • LeanerCloud/CUDly#946: Both PRs introduce/use mapCreatePlanStorageError in internal/api/handler_plans.go to map storage ErrNotFound to 404 and standardize 500 error/log handling so raw DB/UUID details don't leak.
  • LeanerCloud/CUDly#228: Both PRs modify internal/api/handler_accounts.go around the plan/account provider validation path, specifically the validatePlanAccountProviders logic and its GetCloudAccount/GetPurchasePlan error handling.

Suggested labels

effort/s

Poem

🐰 A rabbit hops through logs today,
Hiding UUIDs, errors tucked away,
No secrets leaked, no PII in sight,
Just generic 500s, clean and right!
Error handling done with grace,
Testing keeps each leak in place. 🔒

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main security fix: preventing leakage of raw account UUIDs and DB error strings in logs during plan-account validation.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/plan-account-provider-log-leak

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai

coderabbitai Bot commented Jun 5, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim force-pushed the sec/plan-account-provider-log-leak branch from 6529b70 to 09d4658 Compare June 5, 2026 12:13
cristim added a commit that referenced this pull request Jun 5, 2026
…anch (refs #965)

getPlanForAccountProviderValidation returned fmt.Errorf("accounts: failed to
get plan: %w", err) on a non-ErrNotFound storage error. Because this is not
a ClientError, the router logged it via logging.Errorf("API error: %v"),
emitting the raw DB error string on every POST /plans and PUT /plans/:id/accounts
call that hits a DB failure (same endpoints as the sibling GetCloudAccount leak
closed by PR #969).

Fix: delegate to the existing mapCreatePlanStorageError helper (introduced by
PR #969 for the GetCloudAccount branch) so the response is a generic 500
ClientError and the log line contains only a static format string -- no raw DB
error, no plan UUID.

Adds two regression tests:
- TestGetPlanForAccountProviderValidation_GetPlanDBError_NoPIILeak: asserts
  the handler returns a 500 ClientError and that neither the client response
  nor the captured log buffer contain the raw DB error string; FAILS pre-fix.
- TestGetPlanForAccountProviderValidation_PlanNotFound_Still404: guards that
  ErrNotFound still maps to 404 after the refactor.
@cristim

cristim commented Jun 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 5, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added a commit that referenced this pull request Jun 6, 2026
…wup #965)

PR #969 closed the raw-UUID + raw-DB-error leak in
validatePlanAccountProviders and getPlanForAccountProviderValidation.
Adversarial review surfaced five sibling leak sites of the exact same
shape -- a non-ClientError wrapped with a raw account/plan UUID and the
underlying DB error -- that all propagate through the router's
`logging.Errorf("API error: %v")` path and leak both the UUID and the
raw DB error string.

Fixed sites (all use `mapCreatePlanStorageError` so a DB error becomes a
generic 500 ClientError, and a structured log line is emitted without
PII):

- handler_plans.go:209 updatePlan -- existingPlan fetch
- handler_plans.go:333 getPlanForPurchaseCreation -- plan fetch for
  createPlannedPurchases
- handler_plans.go:447 patchPlan -- plan fetch for PATCH
- handler_purchases.go:323/328 disablePlan -- GetPurchasePlan and
  UpdatePurchasePlan failure paths
- handler_purchases.go:2006 lookupContactEmail -- the Warnf used to log
  the raw account UUID and the raw err; now logs only the failure point
- handler_purchases.go:1955 gatherAccountContactEmails -- the wrap site
  used to re-leak the same account UUID through `%s: %w`; now returns a
  generic 500 ClientError (lookupContactEmail has already logged without
  PII)

Test changes:

- New: TestHandler_patchPlan_GetPlanDBError_NoPIILeak,
  TestHandler_updatePlan_GetPlanDBError_NoPIILeak,
  TestDisablePlan_GetPlanDBError_NoPIILeak,
  TestDisablePlan_UpdatePlanDBError_NoPIILeak,
  TestLookupContactEmail_GetAccountDBError_NoPIILeak,
  TestGatherAccountContactEmails_DBError_NoPIILeak -- each asserts the
  ClientError is 500 (server-side fault), the returned message contains
  neither the raw UUID nor the raw DB error string, and (where the path
  logs via the default logger) the captured log buffer contains neither
  either. Pattern mirrors the existing
  TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak.

- Updated: TestHandler_patchPlan_NotFound,
  TestHandler_createPlannedPurchases_PlanNotFound -- assertions moved
  off the legacy "failed to get plan" wrapper string to the new
  contract: 500 ClientError without the raw plan UUID. Both tests
  return a plain `errors.New("not found")` from the mock (not
  config.ErrNotFound) so they exercise the 500 branch, not the 404
  branch.

- Updated: TestHandler_resolveApprovalRecipients_LookupErrorPropagates
  -- the test's stated intent (no silent globalNotify fallback) is
  retained via the require.Error guard; the ErrorIs(transient) assertion
  was guarding the implementation detail that broke the no-PII-leak
  contract. Replaced with 500-ClientError + no-UUID + no-raw-DB-error
  assertions consistent with the rest of the issue #965 family.

Full internal/api suite: 1474 passed; full short suite: only two
pre-existing internal/auth failures unrelated to this PR.

refs #965
cristim added a commit that referenced this pull request Jun 7, 2026
…anch (refs #965)

getPlanForAccountProviderValidation returned fmt.Errorf("accounts: failed to
get plan: %w", err) on a non-ErrNotFound storage error. Because this is not
a ClientError, the router logged it via logging.Errorf("API error: %v"),
emitting the raw DB error string on every POST /plans and PUT /plans/:id/accounts
call that hits a DB failure (same endpoints as the sibling GetCloudAccount leak
closed by PR #969).

Fix: delegate to the existing mapCreatePlanStorageError helper (introduced by
PR #969 for the GetCloudAccount branch) so the response is a generic 500
ClientError and the log line contains only a static format string -- no raw DB
error, no plan UUID.

Adds two regression tests:
- TestGetPlanForAccountProviderValidation_GetPlanDBError_NoPIILeak: asserts
  the handler returns a 500 ClientError and that neither the client response
  nor the captured log buffer contain the raw DB error string; FAILS pre-fix.
- TestGetPlanForAccountProviderValidation_PlanNotFound_Still404: guards that
  ErrNotFound still maps to 404 after the refactor.
@cristim
cristim force-pushed the sec/plan-account-provider-log-leak branch from 9b1a285 to b783c27 Compare June 7, 2026 08:00
cristim added a commit that referenced this pull request Jun 7, 2026
…wup #965)

PR #969 closed the raw-UUID + raw-DB-error leak in
validatePlanAccountProviders and getPlanForAccountProviderValidation.
Adversarial review surfaced five sibling leak sites of the exact same
shape -- a non-ClientError wrapped with a raw account/plan UUID and the
underlying DB error -- that all propagate through the router's
`logging.Errorf("API error: %v")` path and leak both the UUID and the
raw DB error string.

Fixed sites (all use `mapCreatePlanStorageError` so a DB error becomes a
generic 500 ClientError, and a structured log line is emitted without
PII):

- handler_plans.go:209 updatePlan -- existingPlan fetch
- handler_plans.go:333 getPlanForPurchaseCreation -- plan fetch for
  createPlannedPurchases
- handler_plans.go:447 patchPlan -- plan fetch for PATCH
- handler_purchases.go:323/328 disablePlan -- GetPurchasePlan and
  UpdatePurchasePlan failure paths
- handler_purchases.go:2006 lookupContactEmail -- the Warnf used to log
  the raw account UUID and the raw err; now logs only the failure point
- handler_purchases.go:1955 gatherAccountContactEmails -- the wrap site
  used to re-leak the same account UUID through `%s: %w`; now returns a
  generic 500 ClientError (lookupContactEmail has already logged without
  PII)

Test changes:

- New: TestHandler_patchPlan_GetPlanDBError_NoPIILeak,
  TestHandler_updatePlan_GetPlanDBError_NoPIILeak,
  TestDisablePlan_GetPlanDBError_NoPIILeak,
  TestDisablePlan_UpdatePlanDBError_NoPIILeak,
  TestLookupContactEmail_GetAccountDBError_NoPIILeak,
  TestGatherAccountContactEmails_DBError_NoPIILeak -- each asserts the
  ClientError is 500 (server-side fault), the returned message contains
  neither the raw UUID nor the raw DB error string, and (where the path
  logs via the default logger) the captured log buffer contains neither
  either. Pattern mirrors the existing
  TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak.

- Updated: TestHandler_patchPlan_NotFound,
  TestHandler_createPlannedPurchases_PlanNotFound -- assertions moved
  off the legacy "failed to get plan" wrapper string to the new
  contract: 500 ClientError without the raw plan UUID. Both tests
  return a plain `errors.New("not found")` from the mock (not
  config.ErrNotFound) so they exercise the 500 branch, not the 404
  branch.

- Updated: TestHandler_resolveApprovalRecipients_LookupErrorPropagates
  -- the test's stated intent (no silent globalNotify fallback) is
  retained via the require.Error guard; the ErrorIs(transient) assertion
  was guarding the implementation detail that broke the no-PII-leak
  contract. Replaced with 500-ClientError + no-UUID + no-raw-DB-error
  assertions consistent with the rest of the issue #965 family.

Full internal/api suite: 1474 passed; full short suite: only two
pre-existing internal/auth failures unrelated to this PR.

refs #965
@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

Rebased onto origin/feat/multicloud-web-frontend (was DIRTY). Conflict in handler_purchases_test.go: the #950 ownership-gate tests (added to base by cb1abb746) were adjacent to the new PII-leak tests in commit 9b1a28505; resolved by keeping both sections. All 1502 API tests pass, build clean.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026 •

Copy link
Copy Markdown
Contributor

@cristim: Thanks for the detailed rebase summary. Keeping both the #950 ownership-gate tests and the new PII-leak regression tests in handler_purchases_test.go is the right call — the 1502-test count confirms both sections are intact and compiling cleanly. Triggering a fresh review now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.770Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), the Dashboard "Upcoming purchases" widget (`dashboard.ts`) has two Cancel entry points (card-level and modal) that both call `api.deletePlannedPurchase`. The `UpcomingPurchase` response DTO must include `CreatedByUserID *string` (omitempty, mirroring `PlannedPurchase`) so the frontend can gate these buttons via a `canCancelUpcomingPurchase` helper that mirrors `canManageScheduledPurchase` on the Plans page. Without this, the backend correctly 403s non-owners but the UI has no gate and the UX is broken. Regressions: `frontend/src/__tests__/dashboard-ownership-950.test.ts` (6 tests) and `TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID`. Fixed in commit 94326f6b9.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.770Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), `POST /api/plans/{id}/purchases` (`createPurchaseExecutionsTx`) must stamp `created_by_user_id` via `resolveCreatorUserID(session)` at creation time. Without this, every scheduled purchase created via the Plans-page "Create planned purchases" action ships a NULL creator, which the ownership gate treats as a legacy row and denies management to all non-`update-any` users — including the creator themselves. The admin API key path must set `CreatedByUserID` to nil (not the key identity). Regressions: `TestHandler_createPlannedPurchases_StampsCreator` and `TestHandler_createPlannedPurchases_AdminAPIKeyCreatorIsNil`. Fixed in commit 9ef1b4352.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/api/handler_plans.go (1)

210-223: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard updatePlan against (nil, nil) plan lookups to prevent a panic.

Line 222 dereferences existingPlan without a nil check. If the store returns (nil, nil) (already handled in sibling paths), this panics and turns not-found into a 500 crash path.

Suggested fix
 existingPlan, err := h.config.GetPurchasePlan(ctx, planID)
 if err != nil {
 	return nil, mapCreatePlanStorageError(err,
 		"plan not found", "failed to load plan",
 		"updatePlan: GetPurchasePlan failed")
 }
+if existingPlan == nil {
+	return nil, NewClientError(404, fmt.Sprintf("plan not found: %s", planID))
+}
 
 // Create new plan from request
 plan := req.toPurchasePlan()
 plan.ID = planID
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_plans.go` around lines 210 - 223, The updatePlan flow
calls h.config.GetPurchasePlan and immediately dereferences existingPlan; add an
explicit nil check after the GetPurchasePlan call to handle the (nil, nil) case:
if existingPlan == nil return nil with mapCreatePlanStorageError (using the same
"plan not found"/"failed to load plan"/"updatePlan: GetPurchasePlan failed"
semantics) so we don't panic when taking existingPlan.CreatedAt; keep the
remaining logic (creating plan via req.toPurchasePlan, setting plan.ID, copying
CreatedAt and setting UpdatedAt) unchanged.
internal/api/handler_purchases.go (1)

382-385: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sanitize the conflict-recovery fetch too.

deletePlannedPurchase no longer leaks on disablePlan, but this fallback still returns a raw fmt.Errorf with executionID and getErr. If the conflict-recovery GetExecutionByID hits a DB error, handleRequestError will log that verbatim, so this endpoint still leaks an identifier and backend error text on a realistic retry/conflict path.

Suggested fix
 	existing, getErr := h.config.GetExecutionByID(ctx, executionID)
 	if getErr != nil {
-		return nil, fmt.Errorf("disable plan: failed to get execution %s after conflict: %w", executionID, getErr)
+		logging.Errorf("cancelOrRecoverExecution: GetExecutionByID failed after conflict")
+		return nil, NewClientError(500, "failed to cancel execution")
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_purchases.go` around lines 382 - 385, The fallback error
returned after calling h.config.GetExecutionByID leaks sensitive details
(executionID and DB error) to handleRequestError; change the return to a
sanitized error message that does not include executionID or the raw getErr
(e.g., return fmt.Errorf("disable plan: failed to retrieve execution after
conflict")), leaving h.config.GetExecutionByID and executionID unchanged; ensure
this same sanitized message is used in the disablePlan/conflict-recovery path
(where deletePlannedPurchase is called) so handleRequestError no longer logs
backend error text or identifiers.
🧹 Nitpick comments (1)
internal/api/handler_plans_test.go (1)

1050-1095: ⚡ Quick win

Add a regression for updatePlan when GetPurchasePlan returns (nil, nil).

These new tests cover DB-error leakage well, but a nil-plan branch test is still missing for updatePlan. Adding it will lock in 404 behavior and prevent nil-deref regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_plans_test.go` around lines 1050 - 1095, Add a test that
covers the case where mockStore.GetPurchasePlan returns (nil, nil) so updatePlan
handles a missing plan without panicking and returns a 404 ClientError;
specifically, in the new test arrange mockStore.On("GetPurchasePlan", ctx,
planUUID).Return(nil, nil), call handler.updatePlan(ctx, req, planUUID) and
assert it returns a ClientError with code 404, nil result, and that neither the
planUUID nor any raw DB string appear in the error message or captured logs;
this keeps behavior consistent with the 404 branch and prevents nil-deref
regressions in updatePlan.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/handler_purchases.go`:
- Around line 2107-2112: In lookupContactEmail, change the log call that
currently uses logging.Warnf("lookupContactEmail: GetCloudAccount failed") to an
error-level log (logging.Errorf) so the server-side signal remains at error
severity; keep the message generic (do NOT include the raw id or err) exactly as
in the existing string to avoid leaking sensitive DB/account data, e.g., replace
the Warnf invocation with an Errorf invocation in the
lookupContactEmail/GetCloudAccount failure handling block.

---

Outside diff comments:
In `@internal/api/handler_plans.go`:
- Around line 210-223: The updatePlan flow calls h.config.GetPurchasePlan and
immediately dereferences existingPlan; add an explicit nil check after the
GetPurchasePlan call to handle the (nil, nil) case: if existingPlan == nil
return nil with mapCreatePlanStorageError (using the same "plan not
found"/"failed to load plan"/"updatePlan: GetPurchasePlan failed" semantics) so
we don't panic when taking existingPlan.CreatedAt; keep the remaining logic
(creating plan via req.toPurchasePlan, setting plan.ID, copying CreatedAt and
setting UpdatedAt) unchanged.

In `@internal/api/handler_purchases.go`:
- Around line 382-385: The fallback error returned after calling
h.config.GetExecutionByID leaks sensitive details (executionID and DB error) to
handleRequestError; change the return to a sanitized error message that does not
include executionID or the raw getErr (e.g., return fmt.Errorf("disable plan:
failed to retrieve execution after conflict")), leaving
h.config.GetExecutionByID and executionID unchanged; ensure this same sanitized
message is used in the disablePlan/conflict-recovery path (where
deletePlannedPurchase is called) so handleRequestError no longer logs backend
error text or identifiers.

---

Nitpick comments:
In `@internal/api/handler_plans_test.go`:
- Around line 1050-1095: Add a test that covers the case where
mockStore.GetPurchasePlan returns (nil, nil) so updatePlan handles a missing
plan without panicking and returns a 404 ClientError; specifically, in the new
test arrange mockStore.On("GetPurchasePlan", ctx, planUUID).Return(nil, nil),
call handler.updatePlan(ctx, req, planUUID) and assert it returns a ClientError
with code 404, nil result, and that neither the planUUID nor any raw DB string
appear in the error message or captured logs; this keeps behavior consistent
with the 404 branch and prevents nil-deref regressions in updatePlan.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 77770b34-eeba-43bd-8659-f20ca5b75cfb

📥 Commits

Reviewing files that changed from the base of the PR and between cbdc4be and b783c27.

📒 Files selected for processing (7)
  • internal/api/handler_accounts.go
  • internal/api/handler_accounts_test.go
  • internal/api/handler_plans.go
  • internal/api/handler_plans_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • pkg/logging/logger.go

Comment thread internal/api/handler_purchases.go Outdated
cristim added 3 commits June 7, 2026 21:16
…unt validation log

The GetCloudAccount DB-error path inside validatePlanAccountProviders
returned fmt.Errorf("accounts: failed to get account %s: %w", aid, getErr),
a non-ClientError that propagated to the router's
logging.Errorf("API error: %v"), leaking the raw account UUID and the
raw DB error string into logs (gap left by #946 which closed #944).

Wrap the error with the existing mapCreatePlanStorageError helper so
that: (a) a DB error emits a structured logging.Errorf carrying only
the derived provider list and account count (never the UUID), and
(b) returns a generic 500 ClientError ("failed to validate plan
accounts") with no raw UUID or DB string. The ErrNotFound sentinel
still maps to 404; the explicit (nil, nil) not-found path is unchanged.

Add logging.SetOutput as a test seam on the default logger and two
regression tests:
- TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak: asserts
  that a DB error yields a 500 ClientError whose message and emitted
  log contain neither the account UUID nor the raw DB error string.
- TestValidatePlanAccountProviders_AccountNotFound_Still404: guards that
  the not-found path still returns a 404 referencing the missing ID.

Also fixes the pre-existing build failure on the base branch
(Session.Role field removed in #940 but the test was not updated) and
gofmt drift in handler_purchases_guards_test.go.

closes #965
…anch (refs #965)

getPlanForAccountProviderValidation returned fmt.Errorf("accounts: failed to
get plan: %w", err) on a non-ErrNotFound storage error. Because this is not
a ClientError, the router logged it via logging.Errorf("API error: %v"),
emitting the raw DB error string on every POST /plans and PUT /plans/:id/accounts
call that hits a DB failure (same endpoints as the sibling GetCloudAccount leak
closed by PR #969).

Fix: delegate to the existing mapCreatePlanStorageError helper (introduced by
PR #969 for the GetCloudAccount branch) so the response is a generic 500
ClientError and the log line contains only a static format string -- no raw DB
error, no plan UUID.

Adds two regression tests:
- TestGetPlanForAccountProviderValidation_GetPlanDBError_NoPIILeak: asserts
  the handler returns a 500 ClientError and that neither the client response
  nor the captured log buffer contain the raw DB error string; FAILS pre-fix.
- TestGetPlanForAccountProviderValidation_PlanNotFound_Still404: guards that
  ErrNotFound still maps to 404 after the refactor.
…wup #965)

PR #969 closed the raw-UUID + raw-DB-error leak in
validatePlanAccountProviders and getPlanForAccountProviderValidation.
Adversarial review surfaced five sibling leak sites of the exact same
shape -- a non-ClientError wrapped with a raw account/plan UUID and the
underlying DB error -- that all propagate through the router's
`logging.Errorf("API error: %v")` path and leak both the UUID and the
raw DB error string.

Fixed sites (all use `mapCreatePlanStorageError` so a DB error becomes a
generic 500 ClientError, and a structured log line is emitted without
PII):

- handler_plans.go:209 updatePlan -- existingPlan fetch
- handler_plans.go:333 getPlanForPurchaseCreation -- plan fetch for
  createPlannedPurchases
- handler_plans.go:447 patchPlan -- plan fetch for PATCH
- handler_purchases.go:323/328 disablePlan -- GetPurchasePlan and
  UpdatePurchasePlan failure paths
- handler_purchases.go:2006 lookupContactEmail -- the Warnf used to log
  the raw account UUID and the raw err; now logs only the failure point
- handler_purchases.go:1955 gatherAccountContactEmails -- the wrap site
  used to re-leak the same account UUID through `%s: %w`; now returns a
  generic 500 ClientError (lookupContactEmail has already logged without
  PII)

Test changes:

- New: TestHandler_patchPlan_GetPlanDBError_NoPIILeak,
  TestHandler_updatePlan_GetPlanDBError_NoPIILeak,
  TestDisablePlan_GetPlanDBError_NoPIILeak,
  TestDisablePlan_UpdatePlanDBError_NoPIILeak,
  TestLookupContactEmail_GetAccountDBError_NoPIILeak,
  TestGatherAccountContactEmails_DBError_NoPIILeak -- each asserts the
  ClientError is 500 (server-side fault), the returned message contains
  neither the raw UUID nor the raw DB error string, and (where the path
  logs via the default logger) the captured log buffer contains neither
  either. Pattern mirrors the existing
  TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak.

- Updated: TestHandler_patchPlan_NotFound,
  TestHandler_createPlannedPurchases_PlanNotFound -- assertions moved
  off the legacy "failed to get plan" wrapper string to the new
  contract: 500 ClientError without the raw plan UUID. Both tests
  return a plain `errors.New("not found")` from the mock (not
  config.ErrNotFound) so they exercise the 500 branch, not the 404
  branch.

- Updated: TestHandler_resolveApprovalRecipients_LookupErrorPropagates
  -- the test's stated intent (no silent globalNotify fallback) is
  retained via the require.Error guard; the ErrorIs(transient) assertion
  was guarding the implementation detail that broke the no-PII-leak
  contract. Replaced with 500-ClientError + no-UUID + no-raw-DB-error
  assertions consistent with the rest of the issue #965 family.

Full internal/api suite: 1474 passed; full short suite: only two
pre-existing internal/auth failures unrelated to this PR.

refs #965
@cristim
cristim force-pushed the sec/plan-account-provider-log-leak branch from b783c27 to 3c6e0b6 Compare June 8, 2026 04:17
The GetCloudAccount failure in lookupContactEmail was logged at Warnf,
but its caller gatherAccountContactEmails maps the error into a generic
500 ClientError. The router returns ClientErrors via the branch that
skips logging.Errorf("API error: %v"), so this log line is the only
error-level breadcrumb for the resulting user-visible 500. Downgrading
it to Warnf weakened error-level alerting for exactly the outage this
change set is preserving visibility for.

Restore Errorf while keeping the message generic (no raw account UUID,
no raw DB error string) so the PII-leak fix is unaffected. Extend the
regression test to assert the [ERROR] severity and the generic message;
it fails pre-fix (emits [WARN]) and passes after.

Addresses CodeRabbit review on PR #969.
@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

Addressed the unresolved CodeRabbit finding.

Finding (internal/api/handler_purchases.go, lookupContactEmail, Major / Quick win): the GetCloudAccount failure was logged at Warnf, but its caller gatherAccountContactEmails maps the error into a generic 500 ClientError. The router returns ClientErrors via the branch that skips logging.Errorf("API error: %v") (handler.go:409 returns before line 412), so this log line is the only error-level breadcrumb for the resulting user-visible 500. Downgrading it to Warnf weakened error-level alerting for exactly the outage this PR preserves visibility for.

Fix (b409abfd2): restored logging.Errorf("lookupContactEmail: GetCloudAccount failed") while keeping the message generic (no raw account UUID, no raw DB error string), so the PII-leak fix is unaffected. Extended TestLookupContactEmail_GetAccountDBError_NoPIILeak to assert the [ERROR] severity and the generic message; it fails pre-fix (emits [WARN]) and passes after.

Verification: go build ./... clean, go vet ./internal/api/... clean, targeted regression tests pass (and confirmed they fail against the pre-fix Warnf). gofmt clean.

@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@cristim
cristim merged commit 0293ace into feat/multicloud-web-frontend Jun 8, 2026
4 checks passed
@cristim
cristim deleted the sec/plan-account-provider-log-leak branch July 27, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

impact/internal Team-internal only priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/security Security finding urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant