From 3e2829a5749b9800944eaec06526656dbb02d477 Mon Sep 17 00:00:00 2001 From: Katie Strader Date: Tue, 14 Jul 2026 13:39:07 -0700 Subject: [PATCH 1/8] fix: forcing objectids toUpper --- models/app-member.go | 8 +- models/app-owner.go | 7 +- models/app-role-assignments.go | 26 +++ models/app.go | 11 ++ models/automation-account.go | 17 +- models/azure/descendant-info.go | 13 ++ models/container-registry.go | 17 +- models/device-owner.go | 7 +- models/device.go | 11 ++ models/function-app.go | 17 +- models/group-member.go | 7 +- models/group-owner.go | 7 +- models/group.go | 12 ++ models/key-vault-access-policy.go | 15 +- models/key-vault-contributor.go | 22 ++- models/key-vault-kvcontributor.go | 22 ++- models/key-vault-owner.go | 22 ++- models/key-vault-user-access-admin.go | 22 ++- models/key-vault.go | 16 +- models/logic-app.go | 17 +- models/managed-cluster.go | 16 +- models/management-group-contributor.go | 18 +++ models/management-group-owner.go | 18 +++ models/management-group-user-access-admin.go | 18 +++ models/marshal_test.go | 158 +++++++++++++++++++ models/mgmt-group.go | 11 ++ models/resource-group-contributor.go | 22 ++- models/resource-group-owner.go | 18 +++ models/resource-group-user-access-admin.go | 18 +++ models/resource-group.go | 16 +- models/role-assignments.go | 28 ++++ models/role-eligibility-schedule-instance.go | 14 ++ models/role-management-policy-assignment.go | 17 +- models/role.go | 11 ++ models/service-principal-owner.go | 7 +- models/service-principal.go | 17 +- models/subscription-contributor.go | 22 ++- models/subscription-owner.go | 22 ++- models/subscription-user-access-admin.go | 22 ++- models/subscription.go | 15 +- models/tenant.go | 14 +- models/user.go | 12 ++ models/utils.go | 63 ++++++++ models/utils_test.go | 32 ++++ models/virtual-machine-admin-login.go | 22 ++- models/virtual-machine-avere-contributor.go | 22 ++- models/virtual-machine-contributor.go | 22 ++- models/virtual-machine-owner.go | 22 ++- models/virtual-machine-user-access-admin.go | 22 ++- models/virtual-machine-vmcontributor.go | 22 ++- models/virtual-machine.go | 17 +- models/vm-scale-set.go | 17 +- models/web-app.go | 17 +- 53 files changed, 1042 insertions(+), 46 deletions(-) create mode 100644 models/marshal_test.go diff --git a/models/app-member.go b/models/app-member.go index c56b3f6e..6e07d4ee 100644 --- a/models/app-member.go +++ b/models/app-member.go @@ -19,6 +19,7 @@ package models import ( "encoding/json" + "strings" ) type AppMember struct { @@ -26,13 +27,16 @@ type AppMember struct { AppId string `json:"appId"` } -func (s *AppMember) MarshalJSON() ([]byte, error) { +func (s AppMember) MarshalJSON() ([]byte, error) { var data map[string]any if err := json.Unmarshal(s.RawMessage, &data); err != nil { return nil, err } else { StripEmptyEntries(data) - data["appId"] = s.AppId + if id, ok := data["id"].(string); ok { + data["id"] = strings.ToUpper(id) + } + data["appId"] = strings.ToUpper(s.AppId) return json.Marshal(data) } } diff --git a/models/app-owner.go b/models/app-owner.go index ec3ac64f..0752de22 100644 --- a/models/app-owner.go +++ b/models/app-owner.go @@ -19,6 +19,7 @@ package models import ( "encoding/json" + "strings" ) type AppOwner struct { @@ -26,11 +27,11 @@ type AppOwner struct { AppId string `json:"appId"` } -func (s *AppOwner) MarshalJSON() ([]byte, error) { +func (s AppOwner) MarshalJSON() ([]byte, error) { output := make(map[string]any) - output["appId"] = s.AppId + output["appId"] = strings.ToUpper(s.AppId) - if owner, err := OmitEmpty(s.Owner); err != nil { + if owner, err := OmitEmptyUpper(s.Owner, "id"); err != nil { return nil, err } else { output["owner"] = owner diff --git a/models/app-role-assignments.go b/models/app-role-assignments.go index f00fd8d9..9695a244 100644 --- a/models/app-role-assignments.go +++ b/models/app-role-assignments.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,3 +29,26 @@ type AppRoleAssignment struct { AppId string `json:"appId"` TenantId string `json:"tenantId"` } + +func (s AppRoleAssignment) MarshalJSON() ([]byte, error) { + type Alias AppRoleAssignment + a := Alias(s) + a.ResourceId = strings.ToUpper(a.ResourceId) + a.TenantId = strings.ToUpper(a.TenantId) + + // PrincipalId is a uuid.UUID and cannot hold an uppercased string, so emit + // it through a map override alongside the aliased fields. + raw, err := json.Marshal(a) + if err != nil { + return nil, err + } + + var output map[string]any + if err := json.Unmarshal(raw, &output); err != nil { + return nil, err + } + if _, ok := output["principalId"]; ok { + output["principalId"] = strings.ToUpper(s.PrincipalId.String()) + } + return json.Marshal(output) +} diff --git a/models/app.go b/models/app.go index 238d2ad2..0b9fab21 100644 --- a/models/app.go +++ b/models/app.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,3 +29,11 @@ type App struct { TenantId string `json:"tenantId"` TenantName string `json:"tenantName"` } + +func (s App) MarshalJSON() ([]byte, error) { + type Alias App + a := Alias(s) + a.AppId = strings.ToUpper(a.AppId) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/automation-account.go b/models/automation-account.go index 0e3bfeab..133abd64 100644 --- a/models/automation-account.go +++ b/models/automation-account.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type AutomationAccount struct { azure.AutomationAccount @@ -26,3 +31,13 @@ type AutomationAccount struct { ResourceGroupName string `json:"resourceGroupName"` TenantId string `json:"tenantId"` } + +func (s AutomationAccount) MarshalJSON() ([]byte, error) { + type Alias AutomationAccount + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.TenantId = strings.ToUpper(a.TenantId) + a.Identity = UpperManagedIdentity(a.Identity) + return json.Marshal(a) +} diff --git a/models/azure/descendant-info.go b/models/azure/descendant-info.go index 7f2d4009..3d8cd766 100644 --- a/models/azure/descendant-info.go +++ b/models/azure/descendant-info.go @@ -17,6 +17,11 @@ package azure +import ( + "encoding/json" + "strings" +) + // The properties of the parent management group. type DescendantParentGroupInfo struct { // The fully qualified ID for the parent management group. @@ -60,3 +65,11 @@ type DescendantInfo struct { // - /subscriptions Type string `json:"type,omitempty"` } + +func (s DescendantInfo) MarshalJSON() ([]byte, error) { + type Alias DescendantInfo + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.Properties.Parent.Id = strings.ToUpper(a.Properties.Parent.Id) + return json.Marshal(a) +} diff --git a/models/container-registry.go b/models/container-registry.go index c88b8198..e64e5d5d 100644 --- a/models/container-registry.go +++ b/models/container-registry.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type ContainerRegistry struct { azure.ContainerRegistry @@ -26,3 +31,13 @@ type ContainerRegistry struct { ResourceGroupName string `json:"resourceGroupName"` TenantId string `json:"tenantId"` } + +func (s ContainerRegistry) MarshalJSON() ([]byte, error) { + type Alias ContainerRegistry + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.TenantId = strings.ToUpper(a.TenantId) + a.Identity = UpperManagedIdentity(a.Identity) + return json.Marshal(a) +} diff --git a/models/device-owner.go b/models/device-owner.go index 5183ce7e..0616a1f5 100644 --- a/models/device-owner.go +++ b/models/device-owner.go @@ -19,6 +19,7 @@ package models import ( "encoding/json" + "strings" ) type DeviceOwner struct { @@ -26,11 +27,11 @@ type DeviceOwner struct { DeviceId string `json:"deviceId"` } -func (s *DeviceOwner) MarshalJSON() ([]byte, error) { +func (s DeviceOwner) MarshalJSON() ([]byte, error) { output := make(map[string]any) - output["deviceId"] = s.DeviceId + output["deviceId"] = strings.ToUpper(s.DeviceId) - if owner, err := OmitEmpty(s.Owner); err != nil { + if owner, err := OmitEmptyUpper(s.Owner, "id"); err != nil { return nil, err } else { output["owner"] = owner diff --git a/models/device.go b/models/device.go index fb265843..0c91799d 100644 --- a/models/device.go +++ b/models/device.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,3 +29,11 @@ type Device struct { TenantId string `json:"tenantId"` TenantName string `json:"tenantName"` } + +func (s Device) MarshalJSON() ([]byte, error) { + type Alias Device + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/function-app.go b/models/function-app.go index 09a67f96..7ad8973b 100644 --- a/models/function-app.go +++ b/models/function-app.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type FunctionApp struct { azure.FunctionApp @@ -26,3 +31,13 @@ type FunctionApp struct { ResourceGroupName string `json:"resourceGroupName"` TenantId string `json:"tenantId"` } + +func (s FunctionApp) MarshalJSON() ([]byte, error) { + type Alias FunctionApp + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.TenantId = strings.ToUpper(a.TenantId) + a.Identity = UpperManagedIdentity(a.Identity) + return json.Marshal(a) +} diff --git a/models/group-member.go b/models/group-member.go index 2d08ebaf..f0c88bf3 100644 --- a/models/group-member.go +++ b/models/group-member.go @@ -19,6 +19,7 @@ package models import ( "encoding/json" + "strings" ) type GroupMember struct { @@ -26,11 +27,11 @@ type GroupMember struct { GroupId string `json:"groupId"` } -func (s *GroupMember) MarshalJSON() ([]byte, error) { +func (s GroupMember) MarshalJSON() ([]byte, error) { output := make(map[string]any) - output["groupId"] = s.GroupId + output["groupId"] = strings.ToUpper(s.GroupId) - if member, err := OmitEmpty(s.Member); err != nil { + if member, err := OmitEmptyUpper(s.Member, "id"); err != nil { return nil, err } else { output["member"] = member diff --git a/models/group-owner.go b/models/group-owner.go index a7a48fe2..1475564d 100644 --- a/models/group-owner.go +++ b/models/group-owner.go @@ -19,6 +19,7 @@ package models import ( "encoding/json" + "strings" ) type GroupOwner struct { @@ -26,11 +27,11 @@ type GroupOwner struct { GroupId string `json:"groupId"` } -func (s *GroupOwner) MarshalJSON() ([]byte, error) { +func (s GroupOwner) MarshalJSON() ([]byte, error) { output := make(map[string]any) - output["groupId"] = s.GroupId + output["groupId"] = strings.ToUpper(s.GroupId) - if owner, err := OmitEmpty(s.Owner); err != nil { + if owner, err := OmitEmptyUpper(s.Owner, "id"); err != nil { return nil, err } else { output["owner"] = owner diff --git a/models/group.go b/models/group.go index 48cef3af..0e7915e3 100644 --- a/models/group.go +++ b/models/group.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,3 +29,12 @@ type Group struct { TenantId string `json:"tenantId"` TenantName string `json:"tenantName"` } + +func (s Group) MarshalJSON() ([]byte, error) { + type Alias Group + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.TenantId = strings.ToUpper(a.TenantId) + a.OnPremisesSecurityIdentifier = strings.ToUpper(a.OnPremisesSecurityIdentifier) + return json.Marshal(a) +} diff --git a/models/key-vault-access-policy.go b/models/key-vault-access-policy.go index ff558eab..b2aa1d93 100644 --- a/models/key-vault-access-policy.go +++ b/models/key-vault-access-policy.go @@ -17,9 +17,22 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type KeyVaultAccessPolicy struct { azure.AccessPolicyEntry KeyVaultId string `json:"keyVaultId"` } + +func (s KeyVaultAccessPolicy) MarshalJSON() ([]byte, error) { + type Alias KeyVaultAccessPolicy + a := Alias(s) + a.ObjectId = strings.ToUpper(a.ObjectId) + a.KeyVaultId = strings.ToUpper(a.KeyVaultId) + return json.Marshal(a) +} diff --git a/models/key-vault-contributor.go b/models/key-vault-contributor.go index c952ff15..499d10fa 100644 --- a/models/key-vault-contributor.go +++ b/models/key-vault-contributor.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type KeyVaultContributor struct { Contributor azure.RoleAssignment `json:"contributor"` KeyVaultId string `json:"keyVaultId"` } +func (s KeyVaultContributor) MarshalJSON() ([]byte, error) { + type Alias KeyVaultContributor + a := Alias(s) + a.KeyVaultId = strings.ToUpper(a.KeyVaultId) + a.Contributor = UpperRoleAssignment(a.Contributor) + return json.Marshal(a) +} + type KeyVaultContributors struct { Contributors []KeyVaultContributor `json:"contributors"` KeyVaultId string `json:"keyVaultId"` } + +func (s KeyVaultContributors) MarshalJSON() ([]byte, error) { + type Alias KeyVaultContributors + a := Alias(s) + a.KeyVaultId = strings.ToUpper(a.KeyVaultId) + return json.Marshal(a) +} diff --git a/models/key-vault-kvcontributor.go b/models/key-vault-kvcontributor.go index 8cefbba8..1b8d4072 100644 --- a/models/key-vault-kvcontributor.go +++ b/models/key-vault-kvcontributor.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type KeyVaultKVContributor struct { KVContributor azure.RoleAssignment `json:"kvContributor"` KeyVaultId string `json:"keyVaultId"` } +func (s KeyVaultKVContributor) MarshalJSON() ([]byte, error) { + type Alias KeyVaultKVContributor + a := Alias(s) + a.KeyVaultId = strings.ToUpper(a.KeyVaultId) + a.KVContributor = UpperRoleAssignment(a.KVContributor) + return json.Marshal(a) +} + type KeyVaultKVContributors struct { KVContributors []KeyVaultKVContributor `json:"kvContributors"` KeyVaultId string `json:"keyVaultId"` } + +func (s KeyVaultKVContributors) MarshalJSON() ([]byte, error) { + type Alias KeyVaultKVContributors + a := Alias(s) + a.KeyVaultId = strings.ToUpper(a.KeyVaultId) + return json.Marshal(a) +} diff --git a/models/key-vault-owner.go b/models/key-vault-owner.go index 4fec73a2..af7671fb 100644 --- a/models/key-vault-owner.go +++ b/models/key-vault-owner.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type KeyVaultOwner struct { Owner azure.RoleAssignment `json:"owner"` KeyVaultId string `json:"keyVaultId"` } +func (s KeyVaultOwner) MarshalJSON() ([]byte, error) { + type Alias KeyVaultOwner + a := Alias(s) + a.KeyVaultId = strings.ToUpper(a.KeyVaultId) + a.Owner = UpperRoleAssignment(a.Owner) + return json.Marshal(a) +} + type KeyVaultOwners struct { Owners []KeyVaultOwner `json:"owners"` KeyVaultId string `json:"keyVaultId"` } + +func (s KeyVaultOwners) MarshalJSON() ([]byte, error) { + type Alias KeyVaultOwners + a := Alias(s) + a.KeyVaultId = strings.ToUpper(a.KeyVaultId) + return json.Marshal(a) +} diff --git a/models/key-vault-user-access-admin.go b/models/key-vault-user-access-admin.go index 5a413f9c..2c77cd9b 100644 --- a/models/key-vault-user-access-admin.go +++ b/models/key-vault-user-access-admin.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type KeyVaultUserAccessAdmin struct { UserAccessAdmin azure.RoleAssignment `json:"userAccessAdmin"` KeyVaultId string `json:"keyVaultId"` } +func (s KeyVaultUserAccessAdmin) MarshalJSON() ([]byte, error) { + type Alias KeyVaultUserAccessAdmin + a := Alias(s) + a.KeyVaultId = strings.ToUpper(a.KeyVaultId) + a.UserAccessAdmin = UpperRoleAssignment(a.UserAccessAdmin) + return json.Marshal(a) +} + type KeyVaultUserAccessAdmins struct { UserAccessAdmins []KeyVaultUserAccessAdmin `json:"userAccessAdmins"` KeyVaultId string `json:"keyVaultId"` } + +func (s KeyVaultUserAccessAdmins) MarshalJSON() ([]byte, error) { + type Alias KeyVaultUserAccessAdmins + a := Alias(s) + a.KeyVaultId = strings.ToUpper(a.KeyVaultId) + return json.Marshal(a) +} diff --git a/models/key-vault.go b/models/key-vault.go index cadefd19..688bbd16 100644 --- a/models/key-vault.go +++ b/models/key-vault.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type KeyVault struct { azure.KeyVault @@ -25,3 +30,12 @@ type KeyVault struct { ResourceGroup string `json:"resourceGroup"` TenantId string `json:"tenantId"` } + +func (s KeyVault) MarshalJSON() ([]byte, error) { + type Alias KeyVault + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.ResourceGroup = strings.ToUpper(a.ResourceGroup) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/logic-app.go b/models/logic-app.go index dbe83eb1..4e01f5d6 100644 --- a/models/logic-app.go +++ b/models/logic-app.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type LogicApp struct { azure.LogicApp @@ -26,3 +31,13 @@ type LogicApp struct { ResourceGroupName string `json:"resourceGroupName"` TenantId string `json:"tenantId"` } + +func (s LogicApp) MarshalJSON() ([]byte, error) { + type Alias LogicApp + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.TenantId = strings.ToUpper(a.TenantId) + a.Identity = UpperManagedIdentity(a.Identity) + return json.Marshal(a) +} diff --git a/models/managed-cluster.go b/models/managed-cluster.go index 62b9bee0..e9d88087 100644 --- a/models/managed-cluster.go +++ b/models/managed-cluster.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type ManagedCluster struct { azure.ManagedCluster @@ -25,3 +30,12 @@ type ManagedCluster struct { ResourceGroupId string `json:"resourceGroupId"` TenantId string `json:"tenantId"` } + +func (s ManagedCluster) MarshalJSON() ([]byte, error) { + type Alias ManagedCluster + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/management-group-contributor.go b/models/management-group-contributor.go index 74e53aac..f7284f31 100644 --- a/models/management-group-contributor.go +++ b/models/management-group-contributor.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,8 +29,23 @@ type ManagementGroupContributor struct { ManagementGroupId string `json:"managementGroupId"` } +func (s ManagementGroupContributor) MarshalJSON() ([]byte, error) { + type Alias ManagementGroupContributor + a := Alias(s) + a.ManagementGroupId = strings.ToUpper(a.ManagementGroupId) + a.Contributor = UpperRoleAssignment(a.Contributor) + return json.Marshal(a) +} + type ManagementGroupContributors struct { Contributors []ManagementGroupContributor `json:"contributors"` ManagementGroupId string `json:"managementGroupId"` } +func (s ManagementGroupContributors) MarshalJSON() ([]byte, error) { + type Alias ManagementGroupContributors + a := Alias(s) + a.ManagementGroupId = strings.ToUpper(a.ManagementGroupId) + return json.Marshal(a) +} + diff --git a/models/management-group-owner.go b/models/management-group-owner.go index 78aab80b..4cb496ac 100644 --- a/models/management-group-owner.go +++ b/models/management-group-owner.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,7 +29,22 @@ type ManagementGroupOwner struct { ManagementGroupId string `json:"managementGroupId"` } +func (s ManagementGroupOwner) MarshalJSON() ([]byte, error) { + type Alias ManagementGroupOwner + a := Alias(s) + a.ManagementGroupId = strings.ToUpper(a.ManagementGroupId) + a.Owner = UpperRoleAssignment(a.Owner) + return json.Marshal(a) +} + type ManagementGroupOwners struct { Owners []ManagementGroupOwner `json:"owners"` ManagementGroupId string `json:"managementGroupId"` } + +func (s ManagementGroupOwners) MarshalJSON() ([]byte, error) { + type Alias ManagementGroupOwners + a := Alias(s) + a.ManagementGroupId = strings.ToUpper(a.ManagementGroupId) + return json.Marshal(a) +} diff --git a/models/management-group-user-access-admin.go b/models/management-group-user-access-admin.go index 7b26964c..e6cc7df8 100644 --- a/models/management-group-user-access-admin.go +++ b/models/management-group-user-access-admin.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,7 +29,22 @@ type ManagementGroupUserAccessAdmin struct { ManagementGroupId string `json:"managementGroupId"` } +func (s ManagementGroupUserAccessAdmin) MarshalJSON() ([]byte, error) { + type Alias ManagementGroupUserAccessAdmin + a := Alias(s) + a.ManagementGroupId = strings.ToUpper(a.ManagementGroupId) + a.UserAccessAdmin = UpperRoleAssignment(a.UserAccessAdmin) + return json.Marshal(a) +} + type ManagementGroupUserAccessAdmins struct { UserAccessAdmins []ManagementGroupUserAccessAdmin `json:"userAccessAdmins"` ManagementGroupId string `json:"managementGroupId"` } + +func (s ManagementGroupUserAccessAdmins) MarshalJSON() ([]byte, error) { + type Alias ManagementGroupUserAccessAdmins + a := Alias(s) + a.ManagementGroupId = strings.ToUpper(a.ManagementGroupId) + return json.Marshal(a) +} diff --git a/models/marshal_test.go b/models/marshal_test.go new file mode 100644 index 00000000..94a2bfe6 --- /dev/null +++ b/models/marshal_test.go @@ -0,0 +1,158 @@ +package models_test + +import ( + "encoding/json" + "testing" + + "github.com/bloodhoundad/azurehound/v2/models" + "github.com/bloodhoundad/azurehound/v2/models/azure" + "github.com/gofrs/uuid" + "github.com/stretchr/testify/require" +) + +func marshalToMap(t *testing.T, v any) map[string]any { + t.Helper() + raw, err := json.Marshal(v) + require.NoError(t, err) + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + return out +} + +func TestUserMarshalJSONUppercasesIdentifiers(t *testing.T) { + user := models.User{TenantId: "tenant-abc"} + user.Id = "user-def" + user.OnPremisesSecurityIdentifier = "s-1-5-21-abc-def" + + out := marshalToMap(t, user) + + require.Equal(t, "USER-DEF", out["id"]) + require.Equal(t, "TENANT-ABC", out["tenantId"]) + // On-prem SID is the AZ->AD hybrid join key and must be uppercased. + require.Equal(t, "S-1-5-21-ABC-DEF", out["onPremisesSecurityIdentifier"]) + // Source is unchanged. + require.Equal(t, "user-def", user.Id) + require.Equal(t, "tenant-abc", user.TenantId) + require.Equal(t, "s-1-5-21-abc-def", user.OnPremisesSecurityIdentifier) +} + +func TestGroupMarshalJSONUppercasesOnPremSID(t *testing.T) { + group := models.Group{TenantId: "tenant-abc"} + group.Id = "group-def" + group.OnPremisesSecurityIdentifier = "s-1-5-21-ghi-jkl" + + out := marshalToMap(t, group) + + require.Equal(t, "GROUP-DEF", out["id"]) + require.Equal(t, "TENANT-ABC", out["tenantId"]) + require.Equal(t, "S-1-5-21-GHI-JKL", out["onPremisesSecurityIdentifier"]) + // Source is unchanged. + require.Equal(t, "group-def", group.Id) + require.Equal(t, "s-1-5-21-ghi-jkl", group.OnPremisesSecurityIdentifier) +} + +func TestVirtualMachineMarshalJSONUppercasesIdentityNonMutating(t *testing.T) { + vm := models.VirtualMachine{ + SubscriptionId: "sub-1", + ResourceGroupId: "/subscriptions/sub-1/resourcegroups/rg-1", + TenantId: "tenant-1", + } + vm.Id = "/subscriptions/sub-1/resourcegroups/rg-1/providers/vm-1" + vm.Identity.PrincipalId = "principal-sys" + vm.Identity.UserAssignedIdentities = map[string]azure.UserAssignedIdentity{ + "/uai/one": {ClientId: "client-1", PrincipalId: "principal-uai"}, + } + + out := marshalToMap(t, vm) + + require.Equal(t, "/SUBSCRIPTIONS/SUB-1/RESOURCEGROUPS/RG-1/PROVIDERS/VM-1", out["id"]) + require.Equal(t, "/SUBSCRIPTIONS/SUB-1/RESOURCEGROUPS/RG-1", out["resourceGroupId"]) + require.Equal(t, "TENANT-1", out["tenantId"]) + + identity := out["identity"].(map[string]any) + require.Equal(t, "PRINCIPAL-SYS", identity["principalId"]) + uais := identity["userAssignedIdentities"].(map[string]any) + uai := uais["/uai/one"].(map[string]any) + require.Equal(t, "PRINCIPAL-UAI", uai["principalId"]) + require.Equal(t, "client-1", uai["clientId"]) + + // Source identity map is unchanged. + require.Equal(t, "principal-sys", vm.Identity.PrincipalId) + require.Equal(t, "principal-uai", vm.Identity.UserAssignedIdentities["/uai/one"].PrincipalId) +} + +func TestGroupOwnerMarshalJSONUppercasesOwnerId(t *testing.T) { + owner := models.GroupOwner{ + GroupId: "group-1", + Owner: json.RawMessage(`{"id":"owner-1","@odata.type":"#microsoft.graph.user"}`), + } + + out := marshalToMap(t, owner) + + require.Equal(t, "GROUP-1", out["groupId"]) + ownerBlob := out["owner"].(map[string]any) + require.Equal(t, "OWNER-1", ownerBlob["id"]) +} + +func TestKeyVaultOwnersMarshalJSONUppercasesScopeAndPrincipal(t *testing.T) { + owners := models.KeyVaultOwners{ + KeyVaultId: "/subscriptions/s/kv-1", + Owners: []models.KeyVaultOwner{ + { + KeyVaultId: "/subscriptions/s/kv-1", + Owner: azure.RoleAssignment{ + Properties: azure.RoleAssignmentPropertiesWithScope{ + PrincipalId: "principal-1", + Scope: "/subscriptions/s/kv-1", + }, + }, + }, + }, + } + + out := marshalToMap(t, owners) + + require.Equal(t, "/SUBSCRIPTIONS/S/KV-1", out["keyVaultId"]) + entry := out["owners"].([]any)[0].(map[string]any) + require.Equal(t, "/SUBSCRIPTIONS/S/KV-1", entry["keyVaultId"]) + ownerAssignment := entry["owner"].(map[string]any) + props := ownerAssignment["properties"].(map[string]any) + // Scope and target id are uppercased consistently so ingest == still holds. + require.Equal(t, "/SUBSCRIPTIONS/S/KV-1", props["scope"]) + require.Equal(t, "PRINCIPAL-1", props["principalId"]) +} + +func TestAppRoleAssignmentMarshalJSONUppercasesUUIDFields(t *testing.T) { + principal := uuid.FromStringOrNil("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + appRole := uuid.FromStringOrNil("11111111-2222-3333-4444-555555555555") + + assignment := models.AppRoleAssignment{ + AppId: "app-1", + TenantId: "tenant-1", + } + assignment.PrincipalId = principal + assignment.ResourceId = "resource-1" + assignment.AppRoleId = appRole + + out := marshalToMap(t, assignment) + + require.Equal(t, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", out["principalId"]) + require.Equal(t, "RESOURCE-1", out["resourceId"]) + require.Equal(t, "TENANT-1", out["tenantId"]) + // AppRoleId is used for lowercase matching in ingest and must remain untouched. + require.Equal(t, "11111111-2222-3333-4444-555555555555", out["appRoleId"]) +} + +func TestDescendantInfoMarshalJSONUppercasesIds(t *testing.T) { + descendant := azure.DescendantInfo{ + Id: "/providers/managementgroups/mg-child", + } + descendant.Properties.Parent.Id = "/providers/managementgroups/mg-parent" + + out := marshalToMap(t, descendant) + + require.Equal(t, "/PROVIDERS/MANAGEMENTGROUPS/MG-CHILD", out["id"]) + props := out["properties"].(map[string]any) + parent := props["parent"].(map[string]any) + require.Equal(t, "/PROVIDERS/MANAGEMENTGROUPS/MG-PARENT", parent["id"]) +} diff --git a/models/mgmt-group.go b/models/mgmt-group.go index a0d21569..640b3362 100644 --- a/models/mgmt-group.go +++ b/models/mgmt-group.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -25,3 +28,11 @@ type ManagementGroup struct { azure.ManagementGroup TenantId string `json:"tenantId"` } + +func (s ManagementGroup) MarshalJSON() ([]byte, error) { + type Alias ManagementGroup + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/resource-group-contributor.go b/models/resource-group-contributor.go index 7532ee25..4e8e71cc 100644 --- a/models/resource-group-contributor.go +++ b/models/resource-group-contributor.go @@ -17,15 +17,35 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type ResourceGroupContributor struct { Contributor azure.RoleAssignment `json:"contributor"` ResourceGroupId string `json:"resourceGroupId"` } +func (s ResourceGroupContributor) MarshalJSON() ([]byte, error) { + type Alias ResourceGroupContributor + a := Alias(s) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.Contributor = UpperRoleAssignment(a.Contributor) + return json.Marshal(a) +} + type ResourceGroupContributors struct { Contributors []ResourceGroupContributor `json:"contributors"` ResourceGroupId string `json:"resourceGroupId"` } +func (s ResourceGroupContributors) MarshalJSON() ([]byte, error) { + type Alias ResourceGroupContributors + a := Alias(s) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + return json.Marshal(a) +} + diff --git a/models/resource-group-owner.go b/models/resource-group-owner.go index 31bfbb75..cf9aa68e 100644 --- a/models/resource-group-owner.go +++ b/models/resource-group-owner.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,7 +29,22 @@ type ResourceGroupOwner struct { ResourceGroupId string `json:"resourceGroupId"` } +func (s ResourceGroupOwner) MarshalJSON() ([]byte, error) { + type Alias ResourceGroupOwner + a := Alias(s) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.Owner = UpperRoleAssignment(a.Owner) + return json.Marshal(a) +} + type ResourceGroupOwners struct { Owners []ResourceGroupOwner `json:"owners"` ResourceGroupId string `json:"resourceGroupId"` } + +func (s ResourceGroupOwners) MarshalJSON() ([]byte, error) { + type Alias ResourceGroupOwners + a := Alias(s) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + return json.Marshal(a) +} diff --git a/models/resource-group-user-access-admin.go b/models/resource-group-user-access-admin.go index 3751f9fb..adccda80 100644 --- a/models/resource-group-user-access-admin.go +++ b/models/resource-group-user-access-admin.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,7 +29,22 @@ type ResourceGroupUserAccessAdmin struct { ResourceGroupId string `json:"resourceGroupId"` } +func (s ResourceGroupUserAccessAdmin) MarshalJSON() ([]byte, error) { + type Alias ResourceGroupUserAccessAdmin + a := Alias(s) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.UserAccessAdmin = UpperRoleAssignment(a.UserAccessAdmin) + return json.Marshal(a) +} + type ResourceGroupUserAccessAdmins struct { UserAccessAdmins []ResourceGroupUserAccessAdmin `json:"userAccessAdmins"` ResourceGroupId string `json:"resourceGroupId"` } + +func (s ResourceGroupUserAccessAdmins) MarshalJSON() ([]byte, error) { + type Alias ResourceGroupUserAccessAdmins + a := Alias(s) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + return json.Marshal(a) +} diff --git a/models/resource-group.go b/models/resource-group.go index fa2bb67f..1da14e11 100644 --- a/models/resource-group.go +++ b/models/resource-group.go @@ -17,10 +17,24 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type ResourceGroup struct { azure.ResourceGroup SubscriptionId string `json:"subscriptionId"` TenantId string `json:"tenantId"` } + +func (s ResourceGroup) MarshalJSON() ([]byte, error) { + type Alias ResourceGroup + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/role-assignments.go b/models/role-assignments.go index ad937998..2178a375 100644 --- a/models/role-assignments.go +++ b/models/role-assignments.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,3 +29,28 @@ type RoleAssignments struct { RoleDefinitionId string `json:"roleDefinitionId"` TenantId string `json:"tenantId"` } + +func (s RoleAssignments) MarshalJSON() ([]byte, error) { + type Alias RoleAssignments + a := Alias(s) + a.RoleDefinitionId = strings.ToUpper(a.RoleDefinitionId) + a.TenantId = strings.ToUpper(a.TenantId) + + if s.RoleAssignments != nil { + assignments := make([]azure.UnifiedRoleAssignment, len(s.RoleAssignments)) + for i, assignment := range s.RoleAssignments { + assignment.PrincipalId = strings.ToUpper(assignment.PrincipalId) + assignment.DirectoryScopeId = strings.ToUpper(assignment.DirectoryScopeId) + if len(assignment.Principal) > 0 { + if principal, err := OmitEmptyUpper(assignment.Principal, "id"); err != nil { + return nil, err + } else { + assignment.Principal = principal + } + } + assignments[i] = assignment + } + a.RoleAssignments = assignments + } + return json.Marshal(a) +} diff --git a/models/role-eligibility-schedule-instance.go b/models/role-eligibility-schedule-instance.go index c3dcfd78..67423ed7 100644 --- a/models/role-eligibility-schedule-instance.go +++ b/models/role-eligibility-schedule-instance.go @@ -17,6 +17,11 @@ package models +import ( + "encoding/json" + "strings" +) + type RoleEligibilityScheduleInstance struct { Id string `json:"id,omitempty"` RoleDefinitionId string `json:"roleDefinitionId,omitempty"` @@ -25,3 +30,12 @@ type RoleEligibilityScheduleInstance struct { StartDateTime string `json:"startDateTime,omitempty"` TenantId string `json:"tenantId,omitempty"` } + +func (s RoleEligibilityScheduleInstance) MarshalJSON() ([]byte, error) { + type Alias RoleEligibilityScheduleInstance + a := Alias(s) + a.RoleDefinitionId = strings.ToUpper(a.RoleDefinitionId) + a.PrincipalId = strings.ToUpper(a.PrincipalId) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/role-management-policy-assignment.go b/models/role-management-policy-assignment.go index f260599b..1158df5c 100644 --- a/models/role-management-policy-assignment.go +++ b/models/role-management-policy-assignment.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type RoleManagementPolicyAssignment struct { azure.UnifiedRoleManagementPolicyAssignment @@ -33,3 +38,13 @@ type RoleManagementPolicyAssignment struct { EndUserAssignmentRequiresTicketInformation bool `json:"endUserAssignmentRequiresTicketInformation,omitempty"` TenantId string `json:"tenantId,omitempty"` } + +func (s RoleManagementPolicyAssignment) MarshalJSON() ([]byte, error) { + type Alias RoleManagementPolicyAssignment + a := Alias(s) + a.RoleDefinitionId = strings.ToUpper(a.RoleDefinitionId) + a.TenantId = strings.ToUpper(a.TenantId) + a.EndUserAssignmentUserApprovers = upperStrings(a.EndUserAssignmentUserApprovers) + a.EndUserAssignmentGroupApprovers = upperStrings(a.EndUserAssignmentGroupApprovers) + return json.Marshal(a) +} diff --git a/models/role.go b/models/role.go index 71750e77..70394235 100644 --- a/models/role.go +++ b/models/role.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,3 +29,11 @@ type Role struct { TenantId string `json:"tenantId"` TenantName string `json:"tenantName"` } + +func (s Role) MarshalJSON() ([]byte, error) { + type Alias Role + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/service-principal-owner.go b/models/service-principal-owner.go index bb44eb03..319f0b48 100644 --- a/models/service-principal-owner.go +++ b/models/service-principal-owner.go @@ -19,6 +19,7 @@ package models import ( "encoding/json" + "strings" ) type ServicePrincipalOwner struct { @@ -26,11 +27,11 @@ type ServicePrincipalOwner struct { ServicePrincipalId string `json:"servicePrincipalId"` } -func (s *ServicePrincipalOwner) MarshalJSON() ([]byte, error) { +func (s ServicePrincipalOwner) MarshalJSON() ([]byte, error) { output := make(map[string]any) - output["servicePrincipalId"] = s.ServicePrincipalId + output["servicePrincipalId"] = strings.ToUpper(s.ServicePrincipalId) - if owner, err := OmitEmpty(s.Owner); err != nil { + if owner, err := OmitEmptyUpper(s.Owner, "id"); err != nil { return nil, err } else { output["owner"] = owner diff --git a/models/service-principal.go b/models/service-principal.go index 61a69779..ac788846 100644 --- a/models/service-principal.go +++ b/models/service-principal.go @@ -17,10 +17,25 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type ServicePrincipal struct { azure.ServicePrincipal TenantId string `json:"tenantId"` TenantName string `json:"tenantName"` } + +func (s ServicePrincipal) MarshalJSON() ([]byte, error) { + type Alias ServicePrincipal + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.AppId = strings.ToUpper(a.AppId) + a.AppOwnerOrganizationId = strings.ToUpper(a.AppOwnerOrganizationId) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/subscription-contributor.go b/models/subscription-contributor.go index 46a07427..96e25385 100644 --- a/models/subscription-contributor.go +++ b/models/subscription-contributor.go @@ -17,15 +17,35 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type SubscriptionContributor struct { Contributor azure.RoleAssignment `json:"contributor"` SubscriptionId string `json:"subscriptionId"` } +func (s SubscriptionContributor) MarshalJSON() ([]byte, error) { + type Alias SubscriptionContributor + a := Alias(s) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) + a.Contributor = UpperRoleAssignment(a.Contributor) + return json.Marshal(a) +} + type SubscriptionContributors struct { Contributors []SubscriptionContributor `json:"contributors"` SubscriptionId string `json:"subscriptionId"` } +func (s SubscriptionContributors) MarshalJSON() ([]byte, error) { + type Alias SubscriptionContributors + a := Alias(s) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) + return json.Marshal(a) +} + diff --git a/models/subscription-owner.go b/models/subscription-owner.go index 2e2f22bd..1292bc5a 100644 --- a/models/subscription-owner.go +++ b/models/subscription-owner.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type SubscriptionOwner struct { Owner azure.RoleAssignment `json:"owner"` SubscriptionId string `json:"subscriptionId"` } +func (s SubscriptionOwner) MarshalJSON() ([]byte, error) { + type Alias SubscriptionOwner + a := Alias(s) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) + a.Owner = UpperRoleAssignment(a.Owner) + return json.Marshal(a) +} + type SubscriptionOwners struct { Owners []SubscriptionOwner `json:"owners"` SubscriptionId string `json:"subscriptionId"` } + +func (s SubscriptionOwners) MarshalJSON() ([]byte, error) { + type Alias SubscriptionOwners + a := Alias(s) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) + return json.Marshal(a) +} diff --git a/models/subscription-user-access-admin.go b/models/subscription-user-access-admin.go index f4be7e8b..3108340d 100644 --- a/models/subscription-user-access-admin.go +++ b/models/subscription-user-access-admin.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type SubscriptionUserAccessAdmin struct { UserAccessAdmin azure.RoleAssignment `json:"userAccessAdmin"` SubscriptionId string `json:"subscriptionId"` } +func (s SubscriptionUserAccessAdmin) MarshalJSON() ([]byte, error) { + type Alias SubscriptionUserAccessAdmin + a := Alias(s) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) + a.UserAccessAdmin = UpperRoleAssignment(a.UserAccessAdmin) + return json.Marshal(a) +} + type SubscriptionUserAccessAdmins struct { UserAccessAdmins []SubscriptionUserAccessAdmin `json:"userAccessAdmins"` SubscriptionId string `json:"subscriptionId"` } + +func (s SubscriptionUserAccessAdmins) MarshalJSON() ([]byte, error) { + type Alias SubscriptionUserAccessAdmins + a := Alias(s) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) + return json.Marshal(a) +} diff --git a/models/subscription.go b/models/subscription.go index ac841a4f..681c9362 100644 --- a/models/subscription.go +++ b/models/subscription.go @@ -17,9 +17,22 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type Subscription struct { azure.Subscription TenantId string `json:"tenantId"` } + +func (s Subscription) MarshalJSON() ([]byte, error) { + type Alias Subscription + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/tenant.go b/models/tenant.go index 7fed704a..bb9daac2 100644 --- a/models/tenant.go +++ b/models/tenant.go @@ -17,9 +17,21 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type Tenant struct { azure.Tenant Collected bool `json:"collected,omitempty"` } + +func (s Tenant) MarshalJSON() ([]byte, error) { + type Alias Tenant + a := Alias(s) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/user.go b/models/user.go index 85821498..db8ee35c 100644 --- a/models/user.go +++ b/models/user.go @@ -18,6 +18,9 @@ package models import ( + "encoding/json" + "strings" + "github.com/bloodhoundad/azurehound/v2/models/azure" ) @@ -26,3 +29,12 @@ type User struct { TenantId string `json:"tenantId"` TenantName string `json:"tenantName"` } + +func (s User) MarshalJSON() ([]byte, error) { + type Alias User + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.TenantId = strings.ToUpper(a.TenantId) + a.OnPremisesSecurityIdentifier = strings.ToUpper(a.OnPremisesSecurityIdentifier) + return json.Marshal(a) +} diff --git a/models/utils.go b/models/utils.go index 0e0baf6e..ebad428a 100644 --- a/models/utils.go +++ b/models/utils.go @@ -3,8 +3,52 @@ package models import ( "encoding/json" "reflect" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" ) +// upperStrings returns a new slice with each element uppercased. A nil input +// returns nil so empty slices remain omitted by omitempty json tags. +func upperStrings(values []string) []string { + if values == nil { + return nil + } + upper := make([]string, len(values)) + for i, value := range values { + upper[i] = strings.ToUpper(value) + } + return upper +} + +// UpperRoleAssignment returns a copy of the provided RoleAssignment with the +// Properties.PrincipalId and Properties.Scope uppercased. BloodHound ingest +// uppercases the principal for the edge endpoint and, for scope-matched +// convertors, compares the (now uppercased) scope against the likewise +// uppercased target id. The input is not mutated. +func UpperRoleAssignment(assignment azure.RoleAssignment) azure.RoleAssignment { + assignment.Properties.PrincipalId = strings.ToUpper(assignment.Properties.PrincipalId) + assignment.Properties.Scope = strings.ToUpper(assignment.Properties.Scope) + return assignment +} + +// UpperManagedIdentity returns a copy of the provided ManagedIdentity with the +// system-assigned PrincipalId and each user-assigned identity PrincipalId +// uppercased. The input is not mutated: the UserAssignedIdentities map is +// rebuilt into a fresh map. +func UpperManagedIdentity(identity azure.ManagedIdentity) azure.ManagedIdentity { + identity.PrincipalId = strings.ToUpper(identity.PrincipalId) + if identity.UserAssignedIdentities != nil { + uais := make(map[string]azure.UserAssignedIdentity, len(identity.UserAssignedIdentities)) + for key, uai := range identity.UserAssignedIdentities { + uai.PrincipalId = strings.ToUpper(uai.PrincipalId) + uais[key] = uai + } + identity.UserAssignedIdentities = uais + } + return identity +} + func OmitEmpty(raw json.RawMessage) (json.RawMessage, error) { var data map[string]any if err := json.Unmarshal(raw, &data); err != nil { @@ -15,6 +59,25 @@ func OmitEmpty(raw json.RawMessage) (json.RawMessage, error) { } } +// OmitEmptyUpper behaves like OmitEmpty but also uppercases the string values of +// the provided top-level keys. It is non-mutating with respect to the input: +// the raw message is unmarshaled into a fresh map that is edited and re-marshaled. +// Missing keys and non-string values are left untouched. +func OmitEmptyUpper(raw json.RawMessage, keys ...string) (json.RawMessage, error) { + var data map[string]any + if err := json.Unmarshal(raw, &data); err != nil { + return nil, err + } else { + StripEmptyEntries(data) + for _, key := range keys { + if value, ok := data[key].(string); ok { + data[key] = strings.ToUpper(value) + } + } + return json.Marshal(data) + } +} + func StripEmptyEntries(data map[string]any) { for key, value := range data { if isEmpty(reflect.ValueOf(value)) { diff --git a/models/utils_test.go b/models/utils_test.go index ec3a7d1c..ba315cf6 100644 --- a/models/utils_test.go +++ b/models/utils_test.go @@ -226,3 +226,35 @@ func TestOmitEmpty(t *testing.T) { require.Error(t, err) }) } + +func TestOmitEmptyUpper(t *testing.T) { + t.Run("should uppercase the requested string keys", func(t *testing.T) { + data := json.RawMessage(`{"id":"abc-def","name":"keepMe"}`) + + filtered, err := models.OmitEmptyUpper(data, "id") + require.Nil(t, err) + require.Equal(t, `{"id":"ABC-DEF","name":"keepMe"}`, string(filtered)) + }) + + t.Run("should leave missing keys and non-string values untouched", func(t *testing.T) { + data := json.RawMessage(`{"id":42,"name":"keepMe"}`) + + filtered, err := models.OmitEmptyUpper(data, "id", "missing") + require.Nil(t, err) + require.Equal(t, `{"id":42,"name":"keepMe"}`, string(filtered)) + }) + + t.Run("should still strip empty entries", func(t *testing.T) { + data := json.RawMessage(`{"id":"abc","empty":""}`) + + filtered, err := models.OmitEmptyUpper(data, "id") + require.Nil(t, err) + require.Equal(t, `{"id":"ABC"}`, string(filtered)) + }) + + t.Run("should return an error on invalid json", func(t *testing.T) { + invalidJson := json.RawMessage(`{]}`) + _, err := models.OmitEmptyUpper(invalidJson, "id") + require.Error(t, err) + }) +} diff --git a/models/virtual-machine-admin-login.go b/models/virtual-machine-admin-login.go index 80168793..8b4bfdff 100644 --- a/models/virtual-machine-admin-login.go +++ b/models/virtual-machine-admin-login.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type VirtualMachineAdminLogin struct { AdminLogin azure.RoleAssignment `json:"adminLogin"` VirtualMachineId string `json:"virtualMachineId"` } +func (s VirtualMachineAdminLogin) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineAdminLogin + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + a.AdminLogin = UpperRoleAssignment(a.AdminLogin) + return json.Marshal(a) +} + type VirtualMachineAdminLogins struct { AdminLogins []VirtualMachineAdminLogin `json:"adminLogins"` VirtualMachineId string `json:"virtualMachineId"` } + +func (s VirtualMachineAdminLogins) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineAdminLogins + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + return json.Marshal(a) +} diff --git a/models/virtual-machine-avere-contributor.go b/models/virtual-machine-avere-contributor.go index 8898b2c3..d6f15a3c 100644 --- a/models/virtual-machine-avere-contributor.go +++ b/models/virtual-machine-avere-contributor.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type VirtualMachineAvereContributor struct { AvereContributor azure.RoleAssignment `json:"avereContributor"` VirtualMachineId string `json:"virtualMachineId"` } +func (s VirtualMachineAvereContributor) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineAvereContributor + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + a.AvereContributor = UpperRoleAssignment(a.AvereContributor) + return json.Marshal(a) +} + type VirtualMachineAvereContributors struct { AvereContributors []VirtualMachineAvereContributor `json:"avereContributors"` VirtualMachineId string `json:"virtualMachineId"` } + +func (s VirtualMachineAvereContributors) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineAvereContributors + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + return json.Marshal(a) +} diff --git a/models/virtual-machine-contributor.go b/models/virtual-machine-contributor.go index 79492ad5..09f84507 100644 --- a/models/virtual-machine-contributor.go +++ b/models/virtual-machine-contributor.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type VirtualMachineContributor struct { Contributor azure.RoleAssignment `json:"contributor"` VirtualMachineId string `json:"virtualMachineId"` } +func (s VirtualMachineContributor) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineContributor + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + a.Contributor = UpperRoleAssignment(a.Contributor) + return json.Marshal(a) +} + type VirtualMachineContributors struct { Contributors []VirtualMachineContributor `json:"contributors"` VirtualMachineId string `json:"virtualMachineId"` } + +func (s VirtualMachineContributors) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineContributors + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + return json.Marshal(a) +} diff --git a/models/virtual-machine-owner.go b/models/virtual-machine-owner.go index d2511889..2ec48e67 100644 --- a/models/virtual-machine-owner.go +++ b/models/virtual-machine-owner.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type VirtualMachineOwner struct { Owner azure.RoleAssignment `json:"owner"` VirtualMachineId string `json:"virtualMachineId"` } +func (s VirtualMachineOwner) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineOwner + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + a.Owner = UpperRoleAssignment(a.Owner) + return json.Marshal(a) +} + type VirtualMachineOwners struct { Owners []VirtualMachineOwner `json:"owners"` VirtualMachineId string `json:"virtualMachineId"` } + +func (s VirtualMachineOwners) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineOwners + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + return json.Marshal(a) +} diff --git a/models/virtual-machine-user-access-admin.go b/models/virtual-machine-user-access-admin.go index b2825997..ee0b6442 100644 --- a/models/virtual-machine-user-access-admin.go +++ b/models/virtual-machine-user-access-admin.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type VirtualMachineUserAccessAdmin struct { UserAccessAdmin azure.RoleAssignment `json:"userAccessAdmin"` VirtualMachineId string `json:"virtualMachineId"` } +func (s VirtualMachineUserAccessAdmin) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineUserAccessAdmin + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + a.UserAccessAdmin = UpperRoleAssignment(a.UserAccessAdmin) + return json.Marshal(a) +} + type VirtualMachineUserAccessAdmins struct { UserAccessAdmins []VirtualMachineUserAccessAdmin `json:"userAccessAdmins"` VirtualMachineId string `json:"virtualMachineId"` } + +func (s VirtualMachineUserAccessAdmins) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineUserAccessAdmins + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + return json.Marshal(a) +} diff --git a/models/virtual-machine-vmcontributor.go b/models/virtual-machine-vmcontributor.go index 56baca3a..4558373d 100644 --- a/models/virtual-machine-vmcontributor.go +++ b/models/virtual-machine-vmcontributor.go @@ -17,14 +17,34 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type VirtualMachineVMContributor struct { VMContributor azure.RoleAssignment `json:"vmContributor"` VirtualMachineId string `json:"virtualMachineId"` } +func (s VirtualMachineVMContributor) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineVMContributor + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + a.VMContributor = UpperRoleAssignment(a.VMContributor) + return json.Marshal(a) +} + type VirtualMachineVMContributors struct { VMContributors []VirtualMachineVMContributor `json:"vmContributors"` VirtualMachineId string `json:"virtualMachineId"` } + +func (s VirtualMachineVMContributors) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineVMContributors + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + return json.Marshal(a) +} diff --git a/models/virtual-machine.go b/models/virtual-machine.go index e2935612..dcafb8af 100644 --- a/models/virtual-machine.go +++ b/models/virtual-machine.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type VirtualMachine struct { azure.VirtualMachine @@ -25,3 +30,13 @@ type VirtualMachine struct { ResourceGroupId string `json:"resourceGroupId"` TenantId string `json:"tenantId"` } + +func (s VirtualMachine) MarshalJSON() ([]byte, error) { + type Alias VirtualMachine + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.TenantId = strings.ToUpper(a.TenantId) + a.Identity = UpperManagedIdentity(a.Identity) + return json.Marshal(a) +} diff --git a/models/vm-scale-set.go b/models/vm-scale-set.go index 4c392ba8..83d386f2 100644 --- a/models/vm-scale-set.go +++ b/models/vm-scale-set.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type VMScaleSet struct { azure.VMScaleSet @@ -25,3 +30,13 @@ type VMScaleSet struct { ResourceGroupId string `json:"resourceGroupId"` TenantId string `json:"tenantId"` } + +func (s VMScaleSet) MarshalJSON() ([]byte, error) { + type Alias VMScaleSet + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.TenantId = strings.ToUpper(a.TenantId) + a.Identity = UpperManagedIdentity(a.Identity) + return json.Marshal(a) +} diff --git a/models/web-app.go b/models/web-app.go index 46be3c75..93a7f399 100644 --- a/models/web-app.go +++ b/models/web-app.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type WebApp struct { azure.WebApp @@ -26,3 +31,13 @@ type WebApp struct { ResourceGroupName string `json:"resourceGroupName"` TenantId string `json:"tenantId"` } + +func (s WebApp) MarshalJSON() ([]byte, error) { + type Alias WebApp + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.TenantId = strings.ToUpper(a.TenantId) + a.Identity = UpperManagedIdentity(a.Identity) + return json.Marshal(a) +} From f1d3fbc9c3ad2a8fd25642e9f1275b20add368f0 Mon Sep 17 00:00:00 2001 From: Katie Strader Date: Tue, 14 Jul 2026 14:08:59 -0700 Subject: [PATCH 2/8] fix: upper case App id --- models/app.go | 1 + models/marshal_test.go | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/models/app.go b/models/app.go index 0b9fab21..e16805a5 100644 --- a/models/app.go +++ b/models/app.go @@ -33,6 +33,7 @@ type App struct { func (s App) MarshalJSON() ([]byte, error) { type Alias App a := Alias(s) + a.Id = strings.ToUpper(a.Id) a.AppId = strings.ToUpper(a.AppId) a.TenantId = strings.ToUpper(a.TenantId) return json.Marshal(a) diff --git a/models/marshal_test.go b/models/marshal_test.go index 94a2bfe6..73d93c29 100644 --- a/models/marshal_test.go +++ b/models/marshal_test.go @@ -51,6 +51,22 @@ func TestGroupMarshalJSONUppercasesOnPremSID(t *testing.T) { require.Equal(t, "s-1-5-21-ghi-jkl", group.OnPremisesSecurityIdentifier) } +func TestAppMarshalJSONUppercasesIdentifiers(t *testing.T) { + app := models.App{TenantId: "tenant-abc"} + app.Id = "app-def" + app.AppId = "appid-ghi" + + out := marshalToMap(t, app) + + require.Equal(t, "APP-DEF", out["id"]) + require.Equal(t, "APPID-GHI", out["appId"]) + require.Equal(t, "TENANT-ABC", out["tenantId"]) + // Source is unchanged. + require.Equal(t, "app-def", app.Id) + require.Equal(t, "appid-ghi", app.AppId) + require.Equal(t, "tenant-abc", app.TenantId) +} + func TestVirtualMachineMarshalJSONUppercasesIdentityNonMutating(t *testing.T) { vm := models.VirtualMachine{ SubscriptionId: "sub-1", From 6505bd71b61bd6c2a1c67865942ddde1a9f37c91 Mon Sep 17 00:00:00 2001 From: Katie Strader Date: Tue, 14 Jul 2026 16:36:20 -0700 Subject: [PATCH 3/8] fix: adding toUpper for app-owner, group-owner, and service-principal owner --- models/app-owner.go | 7 ++++ models/group-owner.go | 7 ++++ models/marshal_test.go | 61 +++++++++++++++++++++++++++++++ models/service-principal-owner.go | 7 ++++ 4 files changed, 82 insertions(+) diff --git a/models/app-owner.go b/models/app-owner.go index 0752de22..5061111a 100644 --- a/models/app-owner.go +++ b/models/app-owner.go @@ -43,3 +43,10 @@ type AppOwners struct { Owners []AppOwner `json:"owners"` AppId string `json:"appId"` } + +func (s AppOwners) MarshalJSON() ([]byte, error) { + type Alias AppOwners + a := Alias(s) + a.AppId = strings.ToUpper(a.AppId) + return json.Marshal(a) +} diff --git a/models/group-owner.go b/models/group-owner.go index 1475564d..77bb82bf 100644 --- a/models/group-owner.go +++ b/models/group-owner.go @@ -43,3 +43,10 @@ type GroupOwners struct { Owners []GroupOwner `json:"owners"` GroupId string `json:"groupId"` } + +func (s GroupOwners) MarshalJSON() ([]byte, error) { + type Alias GroupOwners + a := Alias(s) + a.GroupId = strings.ToUpper(a.GroupId) + return json.Marshal(a) +} diff --git a/models/marshal_test.go b/models/marshal_test.go index 73d93c29..58f973f7 100644 --- a/models/marshal_test.go +++ b/models/marshal_test.go @@ -110,6 +110,67 @@ func TestGroupOwnerMarshalJSONUppercasesOwnerId(t *testing.T) { require.Equal(t, "OWNER-1", ownerBlob["id"]) } +func TestServicePrincipalOwnersMarshalJSONUppercasesServicePrincipalId(t *testing.T) { + owners := models.ServicePrincipalOwners{ + ServicePrincipalId: "sp-1", + Owners: []models.ServicePrincipalOwner{ + { + ServicePrincipalId: "sp-1", + Owner: json.RawMessage(`{"id":"owner-1","@odata.type":"#microsoft.graph.user"}`), + }, + }, + } + + out := marshalToMap(t, owners) + + // Top-level id is the edge endpoint and must match the AZServicePrincipal node ObjectID. + require.Equal(t, "SP-1", out["servicePrincipalId"]) + entry := out["owners"].([]any)[0].(map[string]any) + require.Equal(t, "SP-1", entry["servicePrincipalId"]) + ownerBlob := entry["owner"].(map[string]any) + require.Equal(t, "OWNER-1", ownerBlob["id"]) +} + +func TestGroupOwnersMarshalJSONUppercasesGroupId(t *testing.T) { + owners := models.GroupOwners{ + GroupId: "group-1", + Owners: []models.GroupOwner{ + { + GroupId: "group-1", + Owner: json.RawMessage(`{"id":"owner-1","@odata.type":"#microsoft.graph.user"}`), + }, + }, + } + + out := marshalToMap(t, owners) + + require.Equal(t, "GROUP-1", out["groupId"]) + entry := out["owners"].([]any)[0].(map[string]any) + require.Equal(t, "GROUP-1", entry["groupId"]) + ownerBlob := entry["owner"].(map[string]any) + require.Equal(t, "OWNER-1", ownerBlob["id"]) +} + +func TestAppOwnersMarshalJSONUppercasesAppId(t *testing.T) { + owners := models.AppOwners{ + AppId: "app-1", + Owners: []models.AppOwner{ + { + AppId: "app-1", + Owner: json.RawMessage(`{"id":"owner-1","@odata.type":"#microsoft.graph.user"}`), + }, + }, + } + + out := marshalToMap(t, owners) + + require.Equal(t, "APP-1", out["appId"]) + entry := out["owners"].([]any)[0].(map[string]any) + require.Equal(t, "APP-1", entry["appId"]) + ownerBlob := entry["owner"].(map[string]any) + require.Equal(t, "OWNER-1", ownerBlob["id"]) +} + func TestKeyVaultOwnersMarshalJSONUppercasesScopeAndPrincipal(t *testing.T) { owners := models.KeyVaultOwners{ KeyVaultId: "/subscriptions/s/kv-1", diff --git a/models/service-principal-owner.go b/models/service-principal-owner.go index 319f0b48..b12b185e 100644 --- a/models/service-principal-owner.go +++ b/models/service-principal-owner.go @@ -43,3 +43,10 @@ type ServicePrincipalOwners struct { Owners []ServicePrincipalOwner `json:"owners"` ServicePrincipalId string `json:"servicePrincipalId"` } + +func (s ServicePrincipalOwners) MarshalJSON() ([]byte, error) { + type Alias ServicePrincipalOwners + a := Alias(s) + a.ServicePrincipalId = strings.ToUpper(a.ServicePrincipalId) + return json.Marshal(a) +} From f18ad7761bf51504988ece80c93a2d7cfefb4ab7 Mon Sep 17 00:00:00 2001 From: Katie Strader Date: Thu, 16 Jul 2026 09:01:40 -0700 Subject: [PATCH 4/8] fix: found some more missing toUpper --- models/azure-role-assignment.go | 19 ++- models/key-vault-role-assignment.go | 18 ++- models/management-group-role-assignment.go | 18 ++- models/marshal_test.go | 132 +++++++++++++++++++++ models/resource-group-role-assignment.go | 18 ++- models/subscription-role-assignment.go | 18 ++- models/virtual-machine-role-assignment.go | 18 ++- 7 files changed, 235 insertions(+), 6 deletions(-) diff --git a/models/azure-role-assignment.go b/models/azure-role-assignment.go index e8ba1a24..4a8ccf42 100644 --- a/models/azure-role-assignment.go +++ b/models/azure-role-assignment.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type AzureRoleAssignment struct { Assignee azure.RoleAssignment `json:"assignee"` @@ -25,6 +30,18 @@ type AzureRoleAssignment struct { RoleDefinitionId string `json:"roleDefinitionId"` } +// MarshalJSON uppercases the ObjectId and the Assignee endpoint identifiers so +// the raw (use_raw_object_id) ingest path matches the normalized node ObjectIDs. +// RoleDefinitionId is left untouched because ingest matches it against lowercase +// role-definition constants. The input is not mutated. +func (s AzureRoleAssignment) MarshalJSON() ([]byte, error) { + type Alias AzureRoleAssignment + a := Alias(s) + a.ObjectId = strings.ToUpper(a.ObjectId) + a.Assignee = UpperRoleAssignment(a.Assignee) + return json.Marshal(a) +} + type AzureRoleAssignments struct { RoleAssignments []AzureRoleAssignment `json:"assignees"` ObjectId string `json:"objectId"` diff --git a/models/key-vault-role-assignment.go b/models/key-vault-role-assignment.go index 2d9700ff..f46c26df 100644 --- a/models/key-vault-role-assignment.go +++ b/models/key-vault-role-assignment.go @@ -17,13 +17,29 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type KeyVaultRoleAssignment struct { RoleAssignment azure.RoleAssignment `json:"roleAssignment"` KeyVaultId string `json:"virtualMachineId"` } +// MarshalJSON uppercases the KeyVaultId and the RoleAssignment endpoint +// identifiers so the raw (use_raw_object_id) ingest path matches the normalized +// node ObjectIDs. The input is not mutated. +func (s KeyVaultRoleAssignment) MarshalJSON() ([]byte, error) { + type Alias KeyVaultRoleAssignment + a := Alias(s) + a.KeyVaultId = strings.ToUpper(a.KeyVaultId) + a.RoleAssignment = UpperRoleAssignment(a.RoleAssignment) + return json.Marshal(a) +} + type KeyVaultRoleAssignments struct { RoleAssignments []KeyVaultRoleAssignment `json:"roleAssignments"` KeyVaultId string `json:"virtualMachineId"` diff --git a/models/management-group-role-assignment.go b/models/management-group-role-assignment.go index b9bceec2..865f4f87 100644 --- a/models/management-group-role-assignment.go +++ b/models/management-group-role-assignment.go @@ -17,13 +17,29 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type ManagementGroupRoleAssignment struct { RoleAssignment azure.RoleAssignment `json:"roleAssignment"` ManagementGroupId string `json:"managementGroupId"` } +// MarshalJSON uppercases the ManagementGroupId and the RoleAssignment endpoint +// identifiers so the raw (use_raw_object_id) ingest path matches the normalized +// node ObjectIDs. The input is not mutated. +func (s ManagementGroupRoleAssignment) MarshalJSON() ([]byte, error) { + type Alias ManagementGroupRoleAssignment + a := Alias(s) + a.ManagementGroupId = strings.ToUpper(a.ManagementGroupId) + a.RoleAssignment = UpperRoleAssignment(a.RoleAssignment) + return json.Marshal(a) +} + type ManagementGroupRoleAssignments struct { RoleAssignments []ManagementGroupRoleAssignment `json:"roleAssignments"` ManagementGroupId string `json:"managementGroupId"` diff --git a/models/marshal_test.go b/models/marshal_test.go index 58f973f7..04c60f05 100644 --- a/models/marshal_test.go +++ b/models/marshal_test.go @@ -220,6 +220,138 @@ func TestAppRoleAssignmentMarshalJSONUppercasesUUIDFields(t *testing.T) { require.Equal(t, "11111111-2222-3333-4444-555555555555", out["appRoleId"]) } +func TestAzureRoleAssignmentsMarshalJSONUppercasesEndpointsInSlice(t *testing.T) { + assignments := models.AzureRoleAssignments{ + ObjectId: "/subscriptions/s/rg/cr-1", + RoleAssignments: []models.AzureRoleAssignment{ + { + ObjectId: "/subscriptions/s/rg/cr-1", + RoleDefinitionId: "b24988ac-6180-42a0-ab88-20f7382dd24c", + Assignee: azure.RoleAssignment{ + Name: "af7c7710-3443-40e9-be55-f7d8eefba417", + Properties: azure.RoleAssignmentPropertiesWithScope{ + PrincipalId: "principal-1", + Scope: "/subscriptions/s/rg/cr-1", + RoleDefinitionId: "b24988ac-6180-42a0-ab88-20f7382dd24c", + }, + }, + }, + }, + } + + out := marshalToMap(t, assignments) + + entry := out["assignees"].([]any)[0].(map[string]any) + require.Equal(t, "/SUBSCRIPTIONS/S/RG/CR-1", entry["objectId"]) + assignee := entry["assignee"].(map[string]any) + props := assignee["properties"].(map[string]any) + // Edge endpoints are uppercased so raw-path ingest matches node ObjectIDs. + require.Equal(t, "PRINCIPAL-1", props["principalId"]) + require.Equal(t, "/SUBSCRIPTIONS/S/RG/CR-1", props["scope"]) + // RoleDefinitionId is matched against lowercase constants and must be preserved. + require.Equal(t, "b24988ac-6180-42a0-ab88-20f7382dd24c", entry["roleDefinitionId"]) + require.Equal(t, "b24988ac-6180-42a0-ab88-20f7382dd24c", props["roleDefinitionId"]) + // Source is unchanged. + require.Equal(t, "/subscriptions/s/rg/cr-1", assignments.RoleAssignments[0].ObjectId) + require.Equal(t, "principal-1", assignments.RoleAssignments[0].Assignee.Properties.PrincipalId) +} + +func TestSubscriptionRoleAssignmentMarshalJSONUppercasesEndpoints(t *testing.T) { + ra := models.SubscriptionRoleAssignment{ + SubscriptionId: "sub-1", + RoleAssignment: azure.RoleAssignment{ + Properties: azure.RoleAssignmentPropertiesWithScope{ + PrincipalId: "principal-1", + Scope: "/subscriptions/sub-1", + }, + }, + } + + out := marshalToMap(t, ra) + + require.Equal(t, "SUB-1", out["subscriptionId"]) + props := out["roleAssignment"].(map[string]any)["properties"].(map[string]any) + require.Equal(t, "PRINCIPAL-1", props["principalId"]) + require.Equal(t, "/SUBSCRIPTIONS/SUB-1", props["scope"]) + require.Equal(t, "sub-1", ra.SubscriptionId) +} + +func TestResourceGroupRoleAssignmentMarshalJSONUppercasesEndpoints(t *testing.T) { + ra := models.ResourceGroupRoleAssignment{ + ResourceGroupId: "/subscriptions/s/resourcegroups/rg-1", + RoleAssignment: azure.RoleAssignment{ + Properties: azure.RoleAssignmentPropertiesWithScope{ + PrincipalId: "principal-1", + Scope: "/subscriptions/s/resourcegroups/rg-1", + }, + }, + } + + out := marshalToMap(t, ra) + + require.Equal(t, "/SUBSCRIPTIONS/S/RESOURCEGROUPS/RG-1", out["resourceGroupId"]) + props := out["roleAssignment"].(map[string]any)["properties"].(map[string]any) + require.Equal(t, "PRINCIPAL-1", props["principalId"]) + require.Equal(t, "/SUBSCRIPTIONS/S/RESOURCEGROUPS/RG-1", props["scope"]) +} + +func TestManagementGroupRoleAssignmentMarshalJSONUppercasesEndpoints(t *testing.T) { + ra := models.ManagementGroupRoleAssignment{ + ManagementGroupId: "/providers/managementgroups/mg-1", + RoleAssignment: azure.RoleAssignment{ + Properties: azure.RoleAssignmentPropertiesWithScope{ + PrincipalId: "principal-1", + Scope: "/providers/managementgroups/mg-1", + }, + }, + } + + out := marshalToMap(t, ra) + + require.Equal(t, "/PROVIDERS/MANAGEMENTGROUPS/MG-1", out["managementGroupId"]) + props := out["roleAssignment"].(map[string]any)["properties"].(map[string]any) + require.Equal(t, "PRINCIPAL-1", props["principalId"]) + require.Equal(t, "/PROVIDERS/MANAGEMENTGROUPS/MG-1", props["scope"]) +} + +func TestVirtualMachineRoleAssignmentMarshalJSONUppercasesEndpoints(t *testing.T) { + ra := models.VirtualMachineRoleAssignment{ + VirtualMachineId: "/subscriptions/s/resourcegroups/rg/providers/vm-1", + RoleAssignment: azure.RoleAssignment{ + Properties: azure.RoleAssignmentPropertiesWithScope{ + PrincipalId: "principal-1", + Scope: "/subscriptions/s/resourcegroups/rg/providers/vm-1", + }, + }, + } + + out := marshalToMap(t, ra) + + require.Equal(t, "/SUBSCRIPTIONS/S/RESOURCEGROUPS/RG/PROVIDERS/VM-1", out["virtualMachineId"]) + props := out["roleAssignment"].(map[string]any)["properties"].(map[string]any) + require.Equal(t, "PRINCIPAL-1", props["principalId"]) +} + +func TestKeyVaultRoleAssignmentMarshalJSONUppercasesEndpoints(t *testing.T) { + ra := models.KeyVaultRoleAssignment{ + KeyVaultId: "/subscriptions/s/kv-1", + RoleAssignment: azure.RoleAssignment{ + Properties: azure.RoleAssignmentPropertiesWithScope{ + PrincipalId: "principal-1", + Scope: "/subscriptions/s/kv-1", + }, + }, + } + + out := marshalToMap(t, ra) + + // KeyVaultId serializes under the legacy "virtualMachineId" json tag. + require.Equal(t, "/SUBSCRIPTIONS/S/KV-1", out["virtualMachineId"]) + props := out["roleAssignment"].(map[string]any)["properties"].(map[string]any) + require.Equal(t, "PRINCIPAL-1", props["principalId"]) + require.Equal(t, "/SUBSCRIPTIONS/S/KV-1", props["scope"]) +} + func TestDescendantInfoMarshalJSONUppercasesIds(t *testing.T) { descendant := azure.DescendantInfo{ Id: "/providers/managementgroups/mg-child", diff --git a/models/resource-group-role-assignment.go b/models/resource-group-role-assignment.go index 3db7b634..ba8218ac 100644 --- a/models/resource-group-role-assignment.go +++ b/models/resource-group-role-assignment.go @@ -17,13 +17,29 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type ResourceGroupRoleAssignment struct { RoleAssignment azure.RoleAssignment `json:"roleAssignment"` ResourceGroupId string `json:"resourceGroupId"` } +// MarshalJSON uppercases the ResourceGroupId and the RoleAssignment endpoint +// identifiers so the raw (use_raw_object_id) ingest path matches the normalized +// node ObjectIDs. The input is not mutated. +func (s ResourceGroupRoleAssignment) MarshalJSON() ([]byte, error) { + type Alias ResourceGroupRoleAssignment + a := Alias(s) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.RoleAssignment = UpperRoleAssignment(a.RoleAssignment) + return json.Marshal(a) +} + type ResourceGroupRoleAssignments struct { RoleAssignments []ResourceGroupRoleAssignment `json:"roleAssignments"` ResourceGroupId string `json:"resourceGroupId"` diff --git a/models/subscription-role-assignment.go b/models/subscription-role-assignment.go index 45052735..044ac437 100644 --- a/models/subscription-role-assignment.go +++ b/models/subscription-role-assignment.go @@ -17,13 +17,29 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type SubscriptionRoleAssignment struct { RoleAssignment azure.RoleAssignment `json:"roleAssignment"` SubscriptionId string `json:"subscriptionId"` } +// MarshalJSON uppercases the SubscriptionId and the RoleAssignment endpoint +// identifiers so the raw (use_raw_object_id) ingest path matches the normalized +// node ObjectIDs. The input is not mutated. +func (s SubscriptionRoleAssignment) MarshalJSON() ([]byte, error) { + type Alias SubscriptionRoleAssignment + a := Alias(s) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) + a.RoleAssignment = UpperRoleAssignment(a.RoleAssignment) + return json.Marshal(a) +} + type SubscriptionRoleAssignments struct { RoleAssignments []SubscriptionRoleAssignment `json:"roleAssignments"` SubscriptionId string `json:"subscriptionId"` diff --git a/models/virtual-machine-role-assignment.go b/models/virtual-machine-role-assignment.go index 51fdf6e1..aa7f961e 100644 --- a/models/virtual-machine-role-assignment.go +++ b/models/virtual-machine-role-assignment.go @@ -17,13 +17,29 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type VirtualMachineRoleAssignment struct { RoleAssignment azure.RoleAssignment `json:"roleAssignment"` VirtualMachineId string `json:"virtualMachineId"` } +// MarshalJSON uppercases the VirtualMachineId and the RoleAssignment endpoint +// identifiers so the raw (use_raw_object_id) ingest path matches the normalized +// node ObjectIDs. The input is not mutated. +func (s VirtualMachineRoleAssignment) MarshalJSON() ([]byte, error) { + type Alias VirtualMachineRoleAssignment + a := Alias(s) + a.VirtualMachineId = strings.ToUpper(a.VirtualMachineId) + a.RoleAssignment = UpperRoleAssignment(a.RoleAssignment) + return json.Marshal(a) +} + type VirtualMachineRoleAssignments struct { RoleAssignments []VirtualMachineRoleAssignment `json:"roleAssignments"` VirtualMachineId string `json:"virtualMachineId"` From 3d151ac5ae4a7d11004550846b1050fdd76866b1 Mon Sep 17 00:00:00 2001 From: Katie Strader Date: Thu, 16 Jul 2026 16:11:59 -0700 Subject: [PATCH 5/8] fix: needed to add toUpper to GroupMember, AzureRoleAssignments, and AppFIC --- models/app-fic.go | 11 +++++- models/azure-role-assignment.go | 12 ++++++ models/group-member.go | 7 ++++ models/marshal_test.go | 69 ++++++++++++++++++++++++++++++--- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/models/app-fic.go b/models/app-fic.go index bd6a7970..a8dda478 100644 --- a/models/app-fic.go +++ b/models/app-fic.go @@ -19,6 +19,7 @@ package models import ( "encoding/json" + "strings" ) type AppFIC struct { @@ -26,15 +27,21 @@ type AppFIC struct { AppId string `json:"appId"` } +// MarshalJSON uppercases the AppId and the embedded fic.id so the raw +// (use_raw_object_id) ingest path matches the normalized node ObjectIDs. +// BloodHound ingest reads fic.id as the AZFederatedIdentityCredential node +// ObjectID and AZAuthenticatesTo source, and AppId as the AZApp endpoint. The +// remaining fic fields (issuer, subject, name, audiences) are display-only and +// are left untouched. The input is not mutated. func (s *AppFIC) MarshalJSON() ([]byte, error) { output := make(map[string]any) - output["appId"] = s.AppId + output["appId"] = strings.ToUpper(s.AppId) if s.FIC == nil { return nil, nil } - if fic, err := OmitEmpty(s.FIC); err != nil { + if fic, err := OmitEmptyUpper(s.FIC, "id"); err != nil { return nil, err } else { output["fic"] = fic diff --git a/models/azure-role-assignment.go b/models/azure-role-assignment.go index 4a8ccf42..1c41d9ae 100644 --- a/models/azure-role-assignment.go +++ b/models/azure-role-assignment.go @@ -46,3 +46,15 @@ type AzureRoleAssignments struct { RoleAssignments []AzureRoleAssignment `json:"assignees"` ObjectId string `json:"objectId"` } + +// MarshalJSON uppercases the top-level ObjectId (the resource id BloodHound +// ingest reads as the RBAC edge target endpoint for the resource-scoped +// role-assignment convertors) so the raw (use_raw_object_id) ingest path matches +// the normalized resource node ObjectIDs. The nested assignees uppercase their +// own identifiers via AzureRoleAssignment.MarshalJSON. The input is not mutated. +func (s AzureRoleAssignments) MarshalJSON() ([]byte, error) { + type Alias AzureRoleAssignments + a := Alias(s) + a.ObjectId = strings.ToUpper(a.ObjectId) + return json.Marshal(a) +} diff --git a/models/group-member.go b/models/group-member.go index f0c88bf3..6c0836e5 100644 --- a/models/group-member.go +++ b/models/group-member.go @@ -43,3 +43,10 @@ type GroupMembers struct { Members []GroupMember `json:"members"` GroupId string `json:"groupId"` } + +func (s GroupMembers) MarshalJSON() ([]byte, error) { + type Alias GroupMembers + a := Alias(s) + a.GroupId = strings.ToUpper(a.GroupId) + return json.Marshal(a) +} diff --git a/models/marshal_test.go b/models/marshal_test.go index 04c60f05..d2afe305 100644 --- a/models/marshal_test.go +++ b/models/marshal_test.go @@ -151,6 +151,27 @@ func TestGroupOwnersMarshalJSONUppercasesGroupId(t *testing.T) { require.Equal(t, "OWNER-1", ownerBlob["id"]) } +func TestGroupMembersMarshalJSONUppercasesGroupId(t *testing.T) { + members := models.GroupMembers{ + GroupId: "group-1", + Members: []models.GroupMember{ + { + GroupId: "group-1", + Member: json.RawMessage(`{"id":"member-1","@odata.type":"#microsoft.graph.user"}`), + }, + }, + } + + out := marshalToMap(t, members) + + // Top-level groupId is the edge endpoint and must match the AZGroup node ObjectID. + require.Equal(t, "GROUP-1", out["groupId"]) + entry := out["members"].([]any)[0].(map[string]any) + require.Equal(t, "GROUP-1", entry["groupId"]) + memberBlob := entry["member"].(map[string]any) + require.Equal(t, "MEMBER-1", memberBlob["id"]) +} + func TestAppOwnersMarshalJSONUppercasesAppId(t *testing.T) { owners := models.AppOwners{ AppId: "app-1", @@ -171,6 +192,31 @@ func TestAppOwnersMarshalJSONUppercasesAppId(t *testing.T) { require.Equal(t, "OWNER-1", ownerBlob["id"]) } +func TestAppFICMarshalJSONUppercasesAppIdAndFicId(t *testing.T) { + fics := models.AppFICs{ + AppId: "app-1", + TenantId: "tenant-1", + TenantName: "SpecterOps Development", + FICs: []models.AppFIC{ + { + AppId: "app-1", + FIC: json.RawMessage(`{"id":"fic-1","issuer":"https://token.example/","subject":"repo:example:ref"}`), + }, + }, + } + + out := marshalToMap(t, fics) + + entry := out["fics"].([]any)[0].(map[string]any) + // appId is the AZApp edge endpoint; fic.id is the FIC node ObjectID / source. + require.Equal(t, "APP-1", entry["appId"]) + ficBlob := entry["fic"].(map[string]any) + require.Equal(t, "FIC-1", ficBlob["id"]) + // Display-only fields are left untouched. + require.Equal(t, "https://token.example/", ficBlob["issuer"]) + require.Equal(t, "repo:example:ref", ficBlob["subject"]) +} + func TestKeyVaultOwnersMarshalJSONUppercasesScopeAndPrincipal(t *testing.T) { owners := models.KeyVaultOwners{ KeyVaultId: "/subscriptions/s/kv-1", @@ -221,17 +267,22 @@ func TestAppRoleAssignmentMarshalJSONUppercasesUUIDFields(t *testing.T) { } func TestAzureRoleAssignmentsMarshalJSONUppercasesEndpointsInSlice(t *testing.T) { + // Use a mixed-case ARM resource id (as Azure returns it) to catch the raw + // original casing, not just pure-lowercase. + const mixedID = "/subscriptions/s/resourceGroups/BHE_RG/providers/Microsoft.ContainerRegistry/registries/specterDev" + const upperID = "/SUBSCRIPTIONS/S/RESOURCEGROUPS/BHE_RG/PROVIDERS/MICROSOFT.CONTAINERREGISTRY/REGISTRIES/SPECTERDEV" + assignments := models.AzureRoleAssignments{ - ObjectId: "/subscriptions/s/rg/cr-1", + ObjectId: mixedID, RoleAssignments: []models.AzureRoleAssignment{ { - ObjectId: "/subscriptions/s/rg/cr-1", + ObjectId: mixedID, RoleDefinitionId: "b24988ac-6180-42a0-ab88-20f7382dd24c", Assignee: azure.RoleAssignment{ Name: "af7c7710-3443-40e9-be55-f7d8eefba417", Properties: azure.RoleAssignmentPropertiesWithScope{ PrincipalId: "principal-1", - Scope: "/subscriptions/s/rg/cr-1", + Scope: mixedID, RoleDefinitionId: "b24988ac-6180-42a0-ab88-20f7382dd24c", }, }, @@ -241,18 +292,24 @@ func TestAzureRoleAssignmentsMarshalJSONUppercasesEndpointsInSlice(t *testing.T) out := marshalToMap(t, assignments) + // Top-level objectId is the resource id BHE reads as the RBAC edge target + // (data.ObjectId) for the resource-scoped role-assignment convertors; it must + // be uppercased so the raw-path ingest does not create a mixed-case stub node. + require.Equal(t, upperID, out["objectId"]) + entry := out["assignees"].([]any)[0].(map[string]any) - require.Equal(t, "/SUBSCRIPTIONS/S/RG/CR-1", entry["objectId"]) + require.Equal(t, upperID, entry["objectId"]) assignee := entry["assignee"].(map[string]any) props := assignee["properties"].(map[string]any) // Edge endpoints are uppercased so raw-path ingest matches node ObjectIDs. require.Equal(t, "PRINCIPAL-1", props["principalId"]) - require.Equal(t, "/SUBSCRIPTIONS/S/RG/CR-1", props["scope"]) + require.Equal(t, upperID, props["scope"]) // RoleDefinitionId is matched against lowercase constants and must be preserved. require.Equal(t, "b24988ac-6180-42a0-ab88-20f7382dd24c", entry["roleDefinitionId"]) require.Equal(t, "b24988ac-6180-42a0-ab88-20f7382dd24c", props["roleDefinitionId"]) // Source is unchanged. - require.Equal(t, "/subscriptions/s/rg/cr-1", assignments.RoleAssignments[0].ObjectId) + require.Equal(t, mixedID, assignments.ObjectId) + require.Equal(t, mixedID, assignments.RoleAssignments[0].ObjectId) require.Equal(t, "principal-1", assignments.RoleAssignments[0].Assignee.Properties.PrincipalId) } From 7f419e3116fff111c461ebcb8636ca8c011600ab Mon Sep 17 00:00:00 2001 From: Katie Strader Date: Fri, 17 Jul 2026 09:06:08 -0700 Subject: [PATCH 6/8] fix: coderabbit identified missing uppercase ids --- models/app-role-assignments.go | 5 +++-- models/automation-account.go | 1 + models/container-registry.go | 1 + models/function-app.go | 1 + models/key-vault-access-policy.go | 2 ++ models/key-vault.go | 1 + models/logic-app.go | 1 + models/managed-cluster.go | 1 + models/role-eligibility-schedule-instance.go | 2 ++ models/role-management-policy-assignment.go | 1 + models/virtual-machine.go | 1 + models/vm-scale-set.go | 1 + models/web-app.go | 1 + 13 files changed, 17 insertions(+), 2 deletions(-) diff --git a/models/app-role-assignments.go b/models/app-role-assignments.go index 9695a244..ea287780 100644 --- a/models/app-role-assignments.go +++ b/models/app-role-assignments.go @@ -43,12 +43,13 @@ func (s AppRoleAssignment) MarshalJSON() ([]byte, error) { return nil, err } - var output map[string]any + var output map[string]json.RawMessage if err := json.Unmarshal(raw, &output); err != nil { return nil, err } if _, ok := output["principalId"]; ok { - output["principalId"] = strings.ToUpper(s.PrincipalId.String()) + pid := strings.ToUpper(s.PrincipalId.String()) + output["principalId"], _ = json.Marshal(pid) } return json.Marshal(output) } diff --git a/models/automation-account.go b/models/automation-account.go index 133abd64..c6a1427c 100644 --- a/models/automation-account.go +++ b/models/automation-account.go @@ -36,6 +36,7 @@ func (s AutomationAccount) MarshalJSON() ([]byte, error) { type Alias AutomationAccount a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) diff --git a/models/container-registry.go b/models/container-registry.go index e64e5d5d..a6316bef 100644 --- a/models/container-registry.go +++ b/models/container-registry.go @@ -36,6 +36,7 @@ func (s ContainerRegistry) MarshalJSON() ([]byte, error) { type Alias ContainerRegistry a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) diff --git a/models/function-app.go b/models/function-app.go index 7ad8973b..374dd218 100644 --- a/models/function-app.go +++ b/models/function-app.go @@ -36,6 +36,7 @@ func (s FunctionApp) MarshalJSON() ([]byte, error) { type Alias FunctionApp a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) diff --git a/models/key-vault-access-policy.go b/models/key-vault-access-policy.go index b2aa1d93..543297a6 100644 --- a/models/key-vault-access-policy.go +++ b/models/key-vault-access-policy.go @@ -33,6 +33,8 @@ func (s KeyVaultAccessPolicy) MarshalJSON() ([]byte, error) { type Alias KeyVaultAccessPolicy a := Alias(s) a.ObjectId = strings.ToUpper(a.ObjectId) + a.ApplicationId = strings.ToUpper(a.ApplicationId) + a.TenantId = strings.ToUpper(a.TenantId) a.KeyVaultId = strings.ToUpper(a.KeyVaultId) return json.Marshal(a) } diff --git a/models/key-vault.go b/models/key-vault.go index 688bbd16..79a5491a 100644 --- a/models/key-vault.go +++ b/models/key-vault.go @@ -35,6 +35,7 @@ func (s KeyVault) MarshalJSON() ([]byte, error) { type Alias KeyVault a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroup = strings.ToUpper(a.ResourceGroup) a.TenantId = strings.ToUpper(a.TenantId) return json.Marshal(a) diff --git a/models/logic-app.go b/models/logic-app.go index 4e01f5d6..1b4f4193 100644 --- a/models/logic-app.go +++ b/models/logic-app.go @@ -36,6 +36,7 @@ func (s LogicApp) MarshalJSON() ([]byte, error) { type Alias LogicApp a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) diff --git a/models/managed-cluster.go b/models/managed-cluster.go index e9d88087..c7427501 100644 --- a/models/managed-cluster.go +++ b/models/managed-cluster.go @@ -35,6 +35,7 @@ func (s ManagedCluster) MarshalJSON() ([]byte, error) { type Alias ManagedCluster a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) a.TenantId = strings.ToUpper(a.TenantId) return json.Marshal(a) diff --git a/models/role-eligibility-schedule-instance.go b/models/role-eligibility-schedule-instance.go index 67423ed7..f96f299e 100644 --- a/models/role-eligibility-schedule-instance.go +++ b/models/role-eligibility-schedule-instance.go @@ -34,8 +34,10 @@ type RoleEligibilityScheduleInstance struct { func (s RoleEligibilityScheduleInstance) MarshalJSON() ([]byte, error) { type Alias RoleEligibilityScheduleInstance a := Alias(s) + a.Id = strings.ToUpper(a.Id) a.RoleDefinitionId = strings.ToUpper(a.RoleDefinitionId) a.PrincipalId = strings.ToUpper(a.PrincipalId) + a.DirectoryScopeId = strings.ToUpper(a.DirectoryScopeId) a.TenantId = strings.ToUpper(a.TenantId) return json.Marshal(a) } diff --git a/models/role-management-policy-assignment.go b/models/role-management-policy-assignment.go index 1158df5c..dfc66668 100644 --- a/models/role-management-policy-assignment.go +++ b/models/role-management-policy-assignment.go @@ -42,6 +42,7 @@ type RoleManagementPolicyAssignment struct { func (s RoleManagementPolicyAssignment) MarshalJSON() ([]byte, error) { type Alias RoleManagementPolicyAssignment a := Alias(s) + a.Id = strings.ToUpper(a.Id) a.RoleDefinitionId = strings.ToUpper(a.RoleDefinitionId) a.TenantId = strings.ToUpper(a.TenantId) a.EndUserAssignmentUserApprovers = upperStrings(a.EndUserAssignmentUserApprovers) diff --git a/models/virtual-machine.go b/models/virtual-machine.go index dcafb8af..9e3e7dc4 100644 --- a/models/virtual-machine.go +++ b/models/virtual-machine.go @@ -35,6 +35,7 @@ func (s VirtualMachine) MarshalJSON() ([]byte, error) { type Alias VirtualMachine a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) diff --git a/models/vm-scale-set.go b/models/vm-scale-set.go index 83d386f2..9e13c1ec 100644 --- a/models/vm-scale-set.go +++ b/models/vm-scale-set.go @@ -35,6 +35,7 @@ func (s VMScaleSet) MarshalJSON() ([]byte, error) { type Alias VMScaleSet a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) diff --git a/models/web-app.go b/models/web-app.go index 93a7f399..003b79c1 100644 --- a/models/web-app.go +++ b/models/web-app.go @@ -36,6 +36,7 @@ func (s WebApp) MarshalJSON() ([]byte, error) { type Alias WebApp a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) From e48f4c198fc9374fb9d79515ffc97fecc64a8af9 Mon Sep 17 00:00:00 2001 From: Katie Strader Date: Fri, 17 Jul 2026 09:11:41 -0700 Subject: [PATCH 7/8] fix: MarshalJSON check length of the json.RawMessage before processing --- models/app-fic.go | 25 ++++++++++++++----------- models/device-owner.go | 22 ++++++++++++++++------ models/group-member.go | 22 ++++++++++++++++------ 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/models/app-fic.go b/models/app-fic.go index a8dda478..195b9eee 100644 --- a/models/app-fic.go +++ b/models/app-fic.go @@ -32,21 +32,24 @@ type AppFIC struct { // BloodHound ingest reads fic.id as the AZFederatedIdentityCredential node // ObjectID and AZAuthenticatesTo source, and AppId as the AZApp endpoint. The // remaining fic fields (issuer, subject, name, audiences) are display-only and -// are left untouched. The input is not mutated. +// are left untouched. The input is not mutated. When fic is empty or nil it is +// emitted as null rather than passed to OmitEmptyUpper, which would fail to +// unmarshal an empty raw message. func (s *AppFIC) MarshalJSON() ([]byte, error) { - output := make(map[string]any) - output["appId"] = strings.ToUpper(s.AppId) + type Alias AppFIC + a := Alias(*s) + a.AppId = strings.ToUpper(a.AppId) - if s.FIC == nil { - return nil, nil - } - - if fic, err := OmitEmptyUpper(s.FIC, "id"); err != nil { - return nil, err + if len(a.FIC) > 0 { + fic, err := OmitEmptyUpper(a.FIC, "id") + if err != nil { + return nil, err + } + a.FIC = fic } else { - output["fic"] = fic - return json.Marshal(output) + a.FIC = nil } + return json.Marshal(a) } type AppFICs struct { diff --git a/models/device-owner.go b/models/device-owner.go index 0616a1f5..073038ad 100644 --- a/models/device-owner.go +++ b/models/device-owner.go @@ -27,16 +27,26 @@ type DeviceOwner struct { DeviceId string `json:"deviceId"` } +// MarshalJSON uppercases the DeviceId and the embedded owner.id so the raw +// (use_raw_object_id) ingest path matches the normalized node ObjectIDs. When +// owner is empty or nil it is emitted as null rather than passed to +// OmitEmptyUpper, which would fail to unmarshal an empty raw message. The input +// is not mutated. func (s DeviceOwner) MarshalJSON() ([]byte, error) { - output := make(map[string]any) - output["deviceId"] = strings.ToUpper(s.DeviceId) + type Alias DeviceOwner + a := Alias(s) + a.DeviceId = strings.ToUpper(a.DeviceId) - if owner, err := OmitEmptyUpper(s.Owner, "id"); err != nil { - return nil, err + if len(a.Owner) > 0 { + owner, err := OmitEmptyUpper(a.Owner, "id") + if err != nil { + return nil, err + } + a.Owner = owner } else { - output["owner"] = owner - return json.Marshal(output) + a.Owner = nil } + return json.Marshal(a) } type DeviceOwners struct { diff --git a/models/group-member.go b/models/group-member.go index 6c0836e5..2a7e5106 100644 --- a/models/group-member.go +++ b/models/group-member.go @@ -27,16 +27,26 @@ type GroupMember struct { GroupId string `json:"groupId"` } +// MarshalJSON uppercases the GroupId and the embedded member.id so the raw +// (use_raw_object_id) ingest path matches the normalized node ObjectIDs. When +// member is empty or nil it is emitted as null rather than passed to +// OmitEmptyUpper, which would fail to unmarshal an empty raw message. The input +// is not mutated. func (s GroupMember) MarshalJSON() ([]byte, error) { - output := make(map[string]any) - output["groupId"] = strings.ToUpper(s.GroupId) + type Alias GroupMember + a := Alias(s) + a.GroupId = strings.ToUpper(a.GroupId) - if member, err := OmitEmptyUpper(s.Member, "id"); err != nil { - return nil, err + if len(a.Member) > 0 { + member, err := OmitEmptyUpper(a.Member, "id") + if err != nil { + return nil, err + } + a.Member = member } else { - output["member"] = member - return json.Marshal(output) + a.Member = nil } + return json.Marshal(a) } type GroupMembers struct { From e0ce998714756816b84bbcb3c3deddeb18187016 Mon Sep 17 00:00:00 2001 From: Katie Strader Date: Fri, 17 Jul 2026 09:17:35 -0700 Subject: [PATCH 8/8] chore: updated comments --- models/app-fic.go | 11 +++-------- models/device-owner.go | 8 +++----- models/group-member.go | 8 +++----- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/models/app-fic.go b/models/app-fic.go index 195b9eee..00cc841f 100644 --- a/models/app-fic.go +++ b/models/app-fic.go @@ -27,14 +27,9 @@ type AppFIC struct { AppId string `json:"appId"` } -// MarshalJSON uppercases the AppId and the embedded fic.id so the raw -// (use_raw_object_id) ingest path matches the normalized node ObjectIDs. -// BloodHound ingest reads fic.id as the AZFederatedIdentityCredential node -// ObjectID and AZAuthenticatesTo source, and AppId as the AZApp endpoint. The -// remaining fic fields (issuer, subject, name, audiences) are display-only and -// are left untouched. The input is not mutated. When fic is empty or nil it is -// emitted as null rather than passed to OmitEmptyUpper, which would fail to -// unmarshal an empty raw message. +// MarshalJSON uppercases AppId and the embedded fic.id for raw +// (use_raw_object_id) ingest; display-only fic fields are untouched. An empty +// or nil fic is emitted as null to avoid unmarshaling it. Non-mutating. func (s *AppFIC) MarshalJSON() ([]byte, error) { type Alias AppFIC a := Alias(*s) diff --git a/models/device-owner.go b/models/device-owner.go index 073038ad..b1f35ba7 100644 --- a/models/device-owner.go +++ b/models/device-owner.go @@ -27,11 +27,9 @@ type DeviceOwner struct { DeviceId string `json:"deviceId"` } -// MarshalJSON uppercases the DeviceId and the embedded owner.id so the raw -// (use_raw_object_id) ingest path matches the normalized node ObjectIDs. When -// owner is empty or nil it is emitted as null rather than passed to -// OmitEmptyUpper, which would fail to unmarshal an empty raw message. The input -// is not mutated. +// MarshalJSON uppercases DeviceId and the embedded owner.id for raw +// (use_raw_object_id) ingest. An empty or nil owner is emitted as null to +// avoid unmarshaling it. Non-mutating. func (s DeviceOwner) MarshalJSON() ([]byte, error) { type Alias DeviceOwner a := Alias(s) diff --git a/models/group-member.go b/models/group-member.go index 2a7e5106..fec59b0c 100644 --- a/models/group-member.go +++ b/models/group-member.go @@ -27,11 +27,9 @@ type GroupMember struct { GroupId string `json:"groupId"` } -// MarshalJSON uppercases the GroupId and the embedded member.id so the raw -// (use_raw_object_id) ingest path matches the normalized node ObjectIDs. When -// member is empty or nil it is emitted as null rather than passed to -// OmitEmptyUpper, which would fail to unmarshal an empty raw message. The input -// is not mutated. +// MarshalJSON uppercases GroupId and the embedded member.id for raw +// (use_raw_object_id) ingest. An empty or nil member is emitted as null to +// avoid unmarshaling it. Non-mutating. func (s GroupMember) MarshalJSON() ([]byte, error) { type Alias GroupMember a := Alias(s)