Skip to content

Fix hunter-agent findings batch #1 - #551

Merged
okorach-sonar merged 4 commits into
mainfrom
fix/550-hunter-agent-findings-batch-1
Sep 1, 2026
Merged

Fix hunter-agent findings batch #1#551
okorach-sonar merged 4 commits into
mainfrom
fix/550-hunter-agent-findings-batch-1

Conversation

@okorach-sonar

Copy link
Copy Markdown
Contributor

Summary

Closes #550. Resolves all 13 SonarCloud "hunter-agent" business-logic/security findings, previously marked WONTFIX/ACCEPTED, per the issue's explicit request to resolve them.

  • Reset command hardening: --organization scoping (anchored regex, mirrors transfer's --project_key), --dry-run, and a fail-closed default for ConfirmedOrgs (an empty scope now errors instead of wiping every mapped org). Config-file-driven callers can supply confirmed_orgs directly.
  • Permission-template safety nets: resetPermissionTemplates hard-fails when the built-in "Default Template" can't be found by name instead of silently proceeding; deleteTemplates also refuses to delete anything that's currently an org's default template, independent of name.
  • Privilege escalation blocked: the sonar-usersMembers alias can no longer auto-grant admin org-wide. Permission-grant tasks now surface an error when every attempted grant failed, instead of always reporting success.
  • Silent data-loss fixes: --default_organization is validated against the live API before being persisted to organizations.csv (closes a bug where a bad first attempt permanently locked out a corrected retry — also fixed in the sync-issues entry point, which had the same bug). Numeric-looking org/identifier keys no longer get silently coerced to float64 and dropped from the migration. Fresh project creation treats an empty returned key as a failure rather than propagating it.
  • Trust-boundary fixes: extract run directories must match the real date-shaped ID format before being treated as "latest" (closes a directory-poisoning path). The wizard now verifies each phase's real on-disk prerequisites before dispatching into it, rather than trusting a resumed state file's phase field verbatim.
  • Also hardens the shared migrate test fixtures (a hand-typed template-name literal had silently drifted out of sync with production code during review; replaced with a direct reference to the real constant, plus clarifying docs on two easily-confused mock server constructors).

Test plan

  • go build ./... / go vet ./... clean
  • go test ./... -count=1 — all packages pass
  • Each fix has dedicated regression tests reproducing its finding's exact scenario (renamed built-in template still-default not deleted, corrected --default_organization applies after a failed first attempt, numeric org key survives as a string, rogue run-ID directory excluded from "latest", total permission-grant failure surfaces an error, fabricated wizard state rejected before entering migrate)

🤖 Generated with Claude Code

Resolves 13 SonarCloud "hunter-agent" business-logic/security findings,
mostly about the reset command's blast radius, a permission-escalation
path, and several silent-data-loss / trust-boundary gaps. All 13 were
previously marked WONTFIX/ACCEPTED in SonarCloud; #550 explicitly asks
to resolve them, superseding that earlier triage.

Reset command hardening:
- Add --organization (anchored regex, mirrors transfer's --project_key)
  to scope reset to a subset of mapped orgs before the confirm/--yes
  prompt, and --dry-run to print the plan without any destructive call.
- ConfirmedOrgs now fails closed: an empty/nil scope errors out instead
  of defaulting to "reset every mapped org". Config-file-driven callers
  can now supply confirmed_orgs directly.
- resetPermissionTemplates hard-fails instead of silently proceeding
  when the built-in "Default Template" can't be found by name (no
  isBuiltIn flag exists for permission templates in the real API), and
  deleteTemplates additionally refuses to delete anything that's
  currently an org's default template, independent of name — closing
  the "renamed built-in gets wiped" gap.

Permission handling:
- The sonar-users -> Members built-in group alias can no longer
  auto-grant admin org-wide; the grant is skipped with a loud warning
  requiring manual review, while other permissions still apply.
- Permission-grant tasks now return an error when every attempted grant
  failed (previously always reported success even at 0% success rate),
  so the migration DAG halts instead of silently proceeding as if
  permissions were in place.

Data integrity:
- --default_organization is now validated against the live API before
  being written to organizations.csv, so a bad first attempt no longer
  locks a corrected retry out via the "already mapped" check. Same fix
  applied to the sync-issues entry point, which had the identical bug.
- Numeric-looking organization/identifier keys (e.g. "12345") are no
  longer silently coerced to float64 by the CSV loader and dropped from
  the migration.
- Fresh project creation now treats an empty returned project key as a
  failure instead of silently propagating it downstream.

Trust boundaries:
- Extract run directories must match the real YYYY-MM-DD-NNNN shape
  before being considered "latest", closing a directory-poisoning path
  left open by the #543 ordering fix's string-compare fallback.
- The wizard now verifies each phase's real on-disk prerequisites
  before dispatching into it, rather than trusting a resumed
  .wizard_state.json's Phase field verbatim — closes the path where a
  tampered or corrupted state file could skip straight into a migration
  phase against an arbitrary target.

Also hardens the shared migrate test fixtures: replaces hand-typed
copies of the built-in permission template name with a direct reference
to the production constant, and documents the two easily-confused mock
Cloud server constructors in testutil_test.go, after an incorrect edit
to the wrong one surfaced this exact drift during review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@okorach-sonar
okorach-sonar requested a review from a team as a code owner August 31, 2026 08:52
Comment thread go/internal/migrate/tasks_create.go Outdated
Comment thread go/internal/migrate/tasks_delete.go
Comment thread go/internal/common/runid.go Outdated
Comment thread go/internal/migrate/tasks_permissions.go Outdated
Comment thread go/internal/migrate/tasks_permissions.go Outdated
Comment thread go/internal/migrate/tasks_permissions.go
Comment thread go/cmd/reset.go
gitar-bot[bot]

This comment was marked as resolved.

okorach and others added 2 commits August 31, 2026 11:06
- cmd/reset.go: confirmResetOrgs grew to complexity 43 (vs 15 allowed)
  after adding --organization scoping and config-file presetOrgs
  support. Split it into filterOrgsByPattern, resolveOrgSelection (a
  shared dedupe-and-classify helper that removes real duplication
  between the interactive and presetOrgs paths), confirmResetOrgsAutoYes,
  and confirmResetOrgsInteractive.
- internal/migrate/reset.go: RunReset grew to 16 after adding the
  fail-closed ConfirmedOrgs check and dry-run branch. Extracted the
  target-task selection loop into resetTargets and the confirmed-orgs
  set construction into a small toSet helper.
- internal/migrate/tasks_create.go: runCreateProjects grew to 17 after
  adding the empty-key defensive check. Split the already-exists and
  fresh-create branches into handleExistingProject/handleFreshProject.
- internal/migrate/tasks_create_test.go: removed an unnecessary
  variable declaration flagged by godre:S8193.
- internal/structure/csv_test.go: TestCoerceCSVValue's added numeric
  case pushed its type-switch assertion to complexity 18. Replaced the
  5-branch type switch with reflect.DeepEqual.

No behavior changes; go build/vet/test all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Gitar's review of the #550 hunter-agent fixes surfaced 7 issues, the
most serious being a real regression this batch introduced: a single
project lacking a cloud key (createProjects' "failed" record) caused
every grant on it to fail, which aborted the shared errgroup context
and cancelled permission grants for every other project in the run.

- Critical: skip createProjects records with status:"failed" before
  granting migration-user permissions, instead of attempting (and
  failing) all of them and aborting the whole task.
- Important: resetPermissionTemplates now fails only the affected org
  when the built-in "Default Template" can't be found by name, rather
  than hard-failing every org's reset.
- Important: IsValidRunID accepts both the current ISO run-ID shape
  and the legacy pre-#108 MM-DD-YYYY-N shape still present in export
  directories on disk, instead of silently dropping the latter.
- Important: runSetOrgGroupPermissions / runSetProfileGroupPermissions
  / runSetTemplateGroupPermissions now accumulate per-item total-
  failure errors and report one aggregated error at the end, instead
  of returning per-item errors that cancel the errgroup and abort
  every other item.
- Minor: fixed a false total-failure in runSetTemplateGroupPermissions
  by tracking attempted/succeeded per (cloudTemplate, group) pair
  across both extract feeds, rather than per individual apply() call.
- Minor: deliberate admin-escalation skips (sonar-users -> Members
  alias) no longer count as counter.Fail() - they're a security
  decision, not a failure.
- Minor: cmd/reset.go's --dry-run now prints a preview message and
  skips the interactive confirmation prompt instead of always showing
  the deletion warning.

Adds a regression test for the critical fix
(TestRunGrantMigrationUserProjectPermissionsSkipsFailedRecords) and
updates the one test whose assertion inverted with the
resetPermissionTemplates fix.
Comment thread go/internal/migrate/tasks_permissions.go
@gitar-bot
gitar-bot Bot dismissed their stale review August 31, 2026 12:13

✅ Code review updated (blocking issues remain unresolved).

Configure merge blocking

Gitar's follow-up review found the previous round's fixes incomplete
in three ways:

- runGrantMigrationUserProjectPermissions still returned a hard error
  from inside the forEachMigrateItem closure on 100% grant failure,
  which cancels the shared errgroup context and aborts every other
  project still in flight. Switched to the same accumulate-then-report
  pattern already used by the three sibling permission tasks.

- The "skip createProjects records with status:failed" guard only
  existed at the one call site Gitar's first review flagged
  (grantMigrationUserProjectPermissions). Every other createProjects
  consumer had no such filter, so a project that failed to create
  (cross-org key collision #525, or empty key #550) would still be
  processed by 12 other tasks across tasks_associate.go,
  tasks_hotspotsync.go, tasks_issuesync.go, tasks_read.go,
  tasks_portfolios.go, tasks_projectdata.go, and
  tasks_setglobalsettings.go, issuing API calls against a project that
  doesn't exist. Factored the check into a shared
  isFailedMigrateRecord helper and applied it at every createProjects
  consumer.

- resetPermissionTemplates skipping an org (built-in template renamed)
  left deleteTemplates free to run against that org anyway. Its
  isCurrentDefaultTemplate net only protects whatever IS currently the
  org's default, which doesn't cover a renamed built-in that migration
  already demoted in favor of a promoted custom template. Reset now
  writes an explicit failed record for that org, and deleteTemplates
  reads it to skip template deletion entirely for orgs where the
  built-in couldn't be confirmed.

Adds regression tests for all three: one proving a genuinely-failing
project no longer aborts a sibling project's grants, one proving
deleteTemplates skips an org whose built-in reset failed, and one
proving a representative createProjects consumer
(syncHotspotMetadata) skips a failed record.
@sonarqubecloud

sonarqubecloud Bot commented Aug 31, 2026

Copy link
Copy Markdown

Comment on lines 276 to +279
continue
}
for _, item := range items {
if isFailedMigrateRecord(item) {

@gitar-bot gitar-bot Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Bug: Reset now leaves empty-key-created projects behind undeleted

handleFreshProject writes a status:"failed" record when Create succeeded but returned an empty key — the project genuinely exists in the target org under requestedKey, which is what the record stores as cloud_project_key (tasks_create.go:150-170). Adding the blanket isFailedMigrateRecord filter to runGetCreatedProjects and MigrateCreatedProjectCounts means reset now silently skips that real project: it is never fed to deleteProjects and it is not counted in the #381 confirmation prompt, so a migration-created project is left behind on SonarQube Cloud forever. Only the #525 cross-org-collision failure describes a project that isn't ours; distinguish the two (e.g. an error_kind/cross_org marker on the record, or filter on the collision case only) so the empty-key case still gets cleaned up.

Tag the cross-org collision failure and let reset still delete the project created with an empty returned key.:

// tasks_create.go: mark which failures leave nothing of ours behind
result := common.EnrichRaw(item, map[string]any{
	"cloud_project_key":  cloudKey,
	"sonarcloud_org_key": orgKey,
	"status":             "failed",
	"failure_kind":       "cross_org_key_collision", // not our project
	"error":              msg,
})

// tasks_read.go (both sites): only skip projects that aren't ours
if isFailedMigrateRecord(item) && extractField(item, "failure_kind") == "cross_org_key_collision" {
	continue
}

Was this helpful? React with 👍 / 👎

Comment on lines 677 to +691
if builtIn == nil {
e.Logger.Warn("resetPermissionTemplates: no built-in \"Default Template\" found; deleteTemplates may fail to delete the current default",
// Fail this org, not the whole run: forEachMigrateItem fans
// out over an errgroup, so returning an error here would
// cancel every other confirmed org's reset too — one
// renamed built-in shouldn't block the rest.
//
// isCurrentDefaultTemplate only protects whichever template
// is *currently* the org's default for some qualifier — a
// renamed built-in that migration already demoted (because
// a custom template was promoted to default) matches
// neither that check nor isBuiltInPermissionTemplate, so it
// is NOT safe against deleteTemplates on its own (#551).
// Write an explicit failed record so runDeleteTemplates can
// skip template deletion entirely for this org instead.
e.Logger.Error("resetPermissionTemplates: no built-in \"Default Template\" found; skipping template-default reset for this org",

@gitar-bot gitar-bot Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Bug: deleteTemplates still runs when reset's search_templates call failed

runResetPermissionTemplates writes the new status:"failed" marker only in the builtIn == nil branch; when searchPermissionTemplates itself errors (line 663-666) it just calls failAPI and returns nil with no record. runDeleteTemplates skips only orgs present in unresetOrgs, so for that org it proceeds to list and delete templates even though the built-in was never confirmed or promoted — a renamed built-in that migration had already demoted matches neither isBuiltInPermissionTemplate nor isCurrentDefaultTemplate and gets destroyed, which is exactly the loss this commit set out to prevent. Write the same failed record on the listing-error path.

Emit the skip marker when the template listing fails, not only when the built-in is missing.:

templates, defaults, err := searchPermissionTemplates(ctx, e, orgKey)
if err != nil {
	failAPI(counter, e.Logger, "resetPermissionTemplates: listing templates failed", err, "org", orgKey)
	// #551: the built-in could not be confirmed for this org, so
	// deleteTemplates must skip it entirely (same as the
	// no-built-in-found branch below).
	result, _ := json.Marshal(map[string]any{
		"sonarcloud_org_key": orgKey,
		"status":             "failed",
		"error":              fmt.Sprintf("listing permission templates failed: %v", err),
	})
	return w.WriteOne(result)
}

Was this helpful? React with 👍 / 👎

Comment on lines 133 to +135
counter := TaskCounterFromContext(ctx)
var failMu sync.Mutex
var totalFailures []string

@gitar-bot gitar-bot Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Quality: Aggregated grant error can concatenate one entry per project

totalFailures accumulates one line per project and every line is joined into a single error string. The sibling tasks that established this pattern iterate groups/profiles (tens of items), whereas grantMigrationUserProjectPermissions iterates every created project — if the migration token lacks admin instance-wide, all N projects land in the list and the returned error (which gets logged and surfaced in the run report) becomes tens of thousands of lines. Cap the detail, e.g. list the first 10 and append "and N more".

Truncate the aggregated detail so the error stays readable at migration scale.:

if err == nil && len(totalFailures) > 0 {
	sort.Strings(totalFailures)
	shown := totalFailures
	suffix := ""
	if len(shown) > 10 {
		shown, suffix = shown[:10], fmt.Sprintf(" (and %d more)", len(totalFailures)-10)
	}
	err = fmt.Errorf("grantMigrationUserProjectPermissions: %d project(s) received no permissions at all: %s%s",
		len(totalFailures), strings.Join(shown, "; "), suffix)
}

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown
Code Review ⚠️ Changes requested 10 resolved / 13 findings

Addresses 13 SonarCloud findings with hardened reset command scoping, permission-template safety nets, privilege-escalation blocks, and trust-boundary fixes. Three important issues remain: migration-created projects with empty keys are now silently skipped during reset and left behind undeleted; deleteTemplates still runs when the permission-template search fails, risking deletion of renamed built-ins; and the aggregated grant-failure error can balloon to tens of thousands of lines when instance-wide admin is lacking.

⚠️ Bug: Reset now leaves empty-key-created projects behind undeleted

📄 go/internal/migrate/tasks_read.go:276-279 📄 go/internal/migrate/tasks_read.go:337-340 📄 go/internal/migrate/tasks_create.go:147-161

handleFreshProject writes a status:"failed" record when Create succeeded but returned an empty key — the project genuinely exists in the target org under requestedKey, which is what the record stores as cloud_project_key (tasks_create.go:150-170). Adding the blanket isFailedMigrateRecord filter to runGetCreatedProjects and MigrateCreatedProjectCounts means reset now silently skips that real project: it is never fed to deleteProjects and it is not counted in the #381 confirmation prompt, so a migration-created project is left behind on SonarQube Cloud forever. Only the #525 cross-org-collision failure describes a project that isn't ours; distinguish the two (e.g. an error_kind/cross_org marker on the record, or filter on the collision case only) so the empty-key case still gets cleaned up.

Tag the cross-org collision failure and let reset still delete the project created with an empty returned key.
// tasks_create.go: mark which failures leave nothing of ours behind
result := common.EnrichRaw(item, map[string]any{
	"cloud_project_key":  cloudKey,
	"sonarcloud_org_key": orgKey,
	"status":             "failed",
	"failure_kind":       "cross_org_key_collision", // not our project
	"error":              msg,
})

// tasks_read.go (both sites): only skip projects that aren't ours
if isFailedMigrateRecord(item) && extractField(item, "failure_kind") == "cross_org_key_collision" {
	continue
}
⚠️ Bug: deleteTemplates still runs when reset's search_templates call failed

📄 go/internal/migrate/tasks_delete.go:662-666 📄 go/internal/migrate/tasks_delete.go:677-691 📄 go/internal/migrate/tasks_delete.go:364-378

runResetPermissionTemplates writes the new status:"failed" marker only in the builtIn == nil branch; when searchPermissionTemplates itself errors (line 663-666) it just calls failAPI and returns nil with no record. runDeleteTemplates skips only orgs present in unresetOrgs, so for that org it proceeds to list and delete templates even though the built-in was never confirmed or promoted — a renamed built-in that migration had already demoted matches neither isBuiltInPermissionTemplate nor isCurrentDefaultTemplate and gets destroyed, which is exactly the loss this commit set out to prevent. Write the same failed record on the listing-error path.

Emit the skip marker when the template listing fails, not only when the built-in is missing.
templates, defaults, err := searchPermissionTemplates(ctx, e, orgKey)
if err != nil {
	failAPI(counter, e.Logger, "resetPermissionTemplates: listing templates failed", err, "org", orgKey)
	// #551: the built-in could not be confirmed for this org, so
	// deleteTemplates must skip it entirely (same as the
	// no-built-in-found branch below).
	result, _ := json.Marshal(map[string]any{
		"sonarcloud_org_key": orgKey,
		"status":             "failed",
		"error":              fmt.Sprintf("listing permission templates failed: %v", err),
	})
	return w.WriteOne(result)
}
💡 Quality: Aggregated grant error can concatenate one entry per project

📄 go/internal/migrate/tasks_permissions.go:133-135 📄 go/internal/migrate/tasks_permissions.go:181-193

totalFailures accumulates one line per project and every line is joined into a single error string. The sibling tasks that established this pattern iterate groups/profiles (tens of items), whereas grantMigrationUserProjectPermissions iterates every created project — if the migration token lacks admin instance-wide, all N projects land in the list and the returned error (which gets logged and surfaced in the run report) becomes tens of thousands of lines. Cap the detail, e.g. list the first 10 and append "and N more".

Truncate the aggregated detail so the error stays readable at migration scale.
if err == nil && len(totalFailures) > 0 {
	sort.Strings(totalFailures)
	shown := totalFailures
	suffix := ""
	if len(shown) > 10 {
		shown, suffix = shown[:10], fmt.Sprintf(" (and %d more)", len(totalFailures)-10)
	}
	err = fmt.Errorf("grantMigrationUserProjectPermissions: %d project(s) received no permissions at all: %s%s",
		len(totalFailures), strings.Join(shown, "; "), suffix)
}
✅ 10 resolved
Bug: Failed createProjects records now abort the whole migration

📄 go/internal/migrate/tasks_permissions.go:137-151 📄 go/internal/migrate/tasks_create.go:126-140
runGrantMigrationUserProjectPermissions reads every createProjects record and never filters on the status field, so it also processes the deliberate failure records written by the already-exists-in-a-different-org branch (#525) and by this PR's new empty-key branch (cloud_project_key: requestedKey, status: "failed", tasks_create.go:151-157). For such a record the project does not exist in the target org, so all four add_user calls fail, attempted=4 / succeeded=0, and the new return fmt.Errorf(...) propagates out of forEachMigrateItem's errgroup — cancelling the context, aborting the remaining projects, failing the task and halting the whole migrate DAG. Previously these failures were warn-and-swallowed. Filter out records whose status is failed (or that carry an error field) before attempting grants.

Bug: resetPermissionTemplates hard-fail aborts the entire reset run

📄 go/internal/migrate/tasks_delete.go:655-662 📄 go/internal/migrate/tasks_delete.go:383-395 📄 go/internal/migrate/reset.go:214-228
runResetPermissionTemplates now returns an error when no template is named "Default Template". That error propagates through forEachMigrateItem's errgroup and then through runResetPhase's errgroup (g.Wait()), so RunReset returns an error and every subsequent reset phase is skipped — one org whose built-in template was renamed blocks the reset for all confirmed orgs. This is exactly the renamed-built-in scenario the PR's own isCurrentDefaultTemplate net in runDeleteTemplates was added to make safe, so the hard-fail is no longer needed to protect the delete sweep. Log the error and counter.Fail(); return nil so the failure is visible per-org without aborting the run.

Bug: IsValidRunID rejects legacy MM-DD-YYYY extract dirs, dropping them

📄 go/internal/common/runid.go:83-97 📄 go/internal/common/runid.go:50-58 📄 go/internal/structure/extract.go:54-66
IsValidRunID accepts only ^\d{4}-\d{2}-\d{2}-\d+$, but the repo elsewhere deliberately accepts both naming conventions because run directories from pre-#108 releases exist on users' disks: gui/history.go:38 and regtest/suite.go:127 both use ^(\d{2}-\d{2}-\d{4}|\d{4}-\d{2}-\d{2})-\d+$, and RunIDAfter's own doc (runid.go:55-56) names "the legacy MM-DD-YYYY" prefix as supported. Applying the ISO-only guard in buildURLMappings makes GetUniqueExtracts silently return no mapping for a server whose only extract dir is legacy-named — structure/migrate then read zero extract data with nothing but a slog.Debug line. Widen the pattern to match the two sibling regexes.

Bug: One group's total grant failure aborts grants for all remaining orgs

📄 go/internal/migrate/tasks_permissions.go:284-286 📄 go/internal/migrate/tasks_permissions.go:323-326 📄 go/internal/migrate/tasks_permissions.go:381-384 📄 go/internal/migrate/tasks_permissions.go:518-522
applyOrgPermissions, runSetProfileGroupPermissions and runSetTemplateGroupPermissions now return an error on 100% grant failure, and both forEachMigrateItem and forEachExtractItem run items in an errgroup whose first error cancels the shared context and aborts every remaining item. A single group that simply doesn't exist on the target (e.g. group creation was skipped, so add_group 404s on its only permission) therefore halts the task and stops permission migration for every other org and group, instead of the previous warn-and-continue. Accumulate the per-item total failures and return one aggregated error after all items have been processed, so the migration still applies everything it can.

Bug: Template dedup makes "all N grants failed" claim a false total failure

📄 go/internal/migrate/tasks_permissions.go:487-501
In runSetTemplateGroupPermissions, attempted++ sits after the applied[k] dedup check, so permissions already granted by the getTemplateGroupsScanners feed are not counted when the getTemplateGroupsViewers feed revisits the same (template, group) pair. If that second row carries one additional permission and it fails, attempted=1 / succeeded=0 and the task reports "all 1 permission grant(s) failed for group X on template Y" and errors out — even though the group did receive grants from the first feed. Track successful grants per (template, group) across feeds, or only treat it as a total failure when nothing has ever been applied for that pair.

...and 5 more resolved from earlier reviews

🤖 Prompt for agents
Code Review: Addresses 13 SonarCloud findings with hardened reset command scoping, permission-template safety nets, privilege-escalation blocks, and trust-boundary fixes. Three important issues remain: migration-created projects with empty keys are now silently skipped during reset and left behind undeleted; `deleteTemplates` still runs when the permission-template search fails, risking deletion of renamed built-ins; and the aggregated grant-failure error can balloon to tens of thousands of lines when instance-wide admin is lacking.

1. ⚠️ Bug: Reset now leaves empty-key-created projects behind undeleted
   Files: go/internal/migrate/tasks_read.go:276-279, go/internal/migrate/tasks_read.go:337-340, go/internal/migrate/tasks_create.go:147-161

   `handleFreshProject` writes a `status:"failed"` record when Create *succeeded* but returned an empty key — the project genuinely exists in the target org under `requestedKey`, which is what the record stores as `cloud_project_key` (tasks_create.go:150-170). Adding the blanket `isFailedMigrateRecord` filter to `runGetCreatedProjects` and `MigrateCreatedProjectCounts` means reset now silently skips that real project: it is never fed to `deleteProjects` and it is not counted in the #381 confirmation prompt, so a migration-created project is left behind on SonarQube Cloud forever. Only the #525 cross-org-collision failure describes a project that isn't ours; distinguish the two (e.g. an `error_kind`/`cross_org` marker on the record, or filter on the collision case only) so the empty-key case still gets cleaned up.

   Fix (Tag the cross-org collision failure and let reset still delete the project created with an empty returned key.):
   // tasks_create.go: mark which failures leave nothing of ours behind
   result := common.EnrichRaw(item, map[string]any{
   	"cloud_project_key":  cloudKey,
   	"sonarcloud_org_key": orgKey,
   	"status":             "failed",
   	"failure_kind":       "cross_org_key_collision", // not our project
   	"error":              msg,
   })
   
   // tasks_read.go (both sites): only skip projects that aren't ours
   if isFailedMigrateRecord(item) && extractField(item, "failure_kind") == "cross_org_key_collision" {
   	continue
   }

2. ⚠️ Bug: deleteTemplates still runs when reset's search_templates call failed
   Files: go/internal/migrate/tasks_delete.go:662-666, go/internal/migrate/tasks_delete.go:677-691, go/internal/migrate/tasks_delete.go:364-378

   `runResetPermissionTemplates` writes the new `status:"failed"` marker only in the `builtIn == nil` branch; when `searchPermissionTemplates` itself errors (line 663-666) it just calls `failAPI` and returns nil with no record. `runDeleteTemplates` skips only orgs present in `unresetOrgs`, so for that org it proceeds to list and delete templates even though the built-in was never confirmed or promoted — a renamed built-in that migration had already demoted matches neither `isBuiltInPermissionTemplate` nor `isCurrentDefaultTemplate` and gets destroyed, which is exactly the loss this commit set out to prevent. Write the same failed record on the listing-error path.

   Fix (Emit the skip marker when the template listing fails, not only when the built-in is missing.):
   templates, defaults, err := searchPermissionTemplates(ctx, e, orgKey)
   if err != nil {
   	failAPI(counter, e.Logger, "resetPermissionTemplates: listing templates failed", err, "org", orgKey)
   	// #551: the built-in could not be confirmed for this org, so
   	// deleteTemplates must skip it entirely (same as the
   	// no-built-in-found branch below).
   	result, _ := json.Marshal(map[string]any{
   		"sonarcloud_org_key": orgKey,
   		"status":             "failed",
   		"error":              fmt.Sprintf("listing permission templates failed: %v", err),
   	})
   	return w.WriteOne(result)
   }

3. 💡 Quality: Aggregated grant error can concatenate one entry per project
   Files: go/internal/migrate/tasks_permissions.go:133-135, go/internal/migrate/tasks_permissions.go:181-193

   `totalFailures` accumulates one line per project and every line is joined into a single error string. The sibling tasks that established this pattern iterate groups/profiles (tens of items), whereas `grantMigrationUserProjectPermissions` iterates every created project — if the migration token lacks admin instance-wide, all N projects land in the list and the returned error (which gets logged and surfaced in the run report) becomes tens of thousands of lines. Cap the detail, e.g. list the first 10 and append "and N more".

   Fix (Truncate the aggregated detail so the error stays readable at migration scale.):
   if err == nil && len(totalFailures) > 0 {
   	sort.Strings(totalFailures)
   	shown := totalFailures
   	suffix := ""
   	if len(shown) > 10 {
   		shown, suffix = shown[:10], fmt.Sprintf(" (and %d more)", len(totalFailures)-10)
   	}
   	err = fmt.Errorf("grantMigrationUserProjectPermissions: %d project(s) received no permissions at all: %s%s",
   		len(totalFailures), strings.Join(shown, "; "), suffix)
   }

Implementation Status ✅ 13 of 13 objectives covered
#550 - 13 of 13 objectives covered

This PR covers the resolution of all listed hunter-agent security and reliability issues from batch #1.

✅ 13 covered here
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e88787369
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e8878735b
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e88787372
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e88787342
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e88787353
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e88787361
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e88787347
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e88787357
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e88787365
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e8878738e
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e8878738a
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e8878733d
  • ✅ Resolve hunter agent issue da-01a0247f-d96d-7505-ae88-302e8878734f
Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.
Unblock → Override a blocking verdict and allow merging.

Comment with these commands to change the behavior for this request:

Auto-apply Compact Unblock
gitar auto-apply:on         
gitar display:verbose         
gitar unblock         

Was this helpful? React with 👍 / 👎 | Gitar

@adrian-deleon-sonarsource adrian-deleon-sonarsource left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

approved

@okorach-sonar
okorach-sonar merged commit 7be9776 into main Sep 1, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix hunter agent issues - batch #1

3 participants