diff --git a/.gitignore b/.gitignore index 458aa000..da39783b 100644 --- a/.gitignore +++ b/.gitignore @@ -232,3 +232,7 @@ tags # Built Visual Studio Code Extensions *.vsix +.github/workflows/cla.yml +.github/workflows/vuln-scan.yml +/.github +get_token.ps1 diff --git a/client/client.go b/client/client.go index 47a777bd..105fea97 100644 --- a/client/client.go +++ b/client/client.go @@ -32,6 +32,7 @@ import ( "github.com/bloodhoundad/azurehound/v2/models/azure" "github.com/bloodhoundad/azurehound/v2/panicrecovery" "github.com/bloodhoundad/azurehound/v2/pipeline" + "github.com/bloodhoundad/azurehound/v2/models/intune" ) func NewClient(config config.Config) (AzureClient, error) { @@ -221,6 +222,12 @@ type AzureClient interface { TenantInfo() azure.Tenant CloseIdleConnections() + + // Add Intune methods + ListIntuneManagedDevices(ctx context.Context, params query.GraphParams) <-chan AzureResult[intune.ManagedDevice] + GetIntuneDeviceCompliance(ctx context.Context, deviceId string, params query.GraphParams) <-chan AzureResult[intune.ComplianceState] + GetIntuneDeviceConfiguration(ctx context.Context, deviceId string, params query.GraphParams) <-chan AzureResult[intune.ConfigurationState] + } func (s azureClient) TenantInfo() azure.Tenant { diff --git a/client/intune_devices.go b/client/intune_devices.go new file mode 100644 index 00000000..c74eba84 --- /dev/null +++ b/client/intune_devices.go @@ -0,0 +1,81 @@ +// File: client/intune_devices.go +// Copyright (C) 2022 SpecterOps +// Implementation of Intune device management API calls + +package client + +import ( + "context" + "fmt" + + "github.com/bloodhoundad/azurehound/v2/client/query" + "github.com/bloodhoundad/azurehound/v2/constants" + "github.com/bloodhoundad/azurehound/v2/models/intune" +) + +func setDefaultParams(params query.GraphParams) query.GraphParams { + if params.Top == 0 { + params.Top = 999 + } + return params +} + +// ListIntuneManagedDevices retrieves all managed devices from Intune +// GET /deviceManagement/managedDevices +func (s *azureClient) ListIntuneManagedDevices(ctx context.Context, params query.GraphParams) <-chan AzureResult[intune.ManagedDevice] { + var ( + out = make(chan AzureResult[intune.ManagedDevice]) + path = fmt.Sprintf("/%s/deviceManagement/managedDevices", constants.GraphApiVersion) + ) + + params = setDefaultParams(params) + + go getAzureObjectList[intune.ManagedDevice](s.msgraph, ctx, path, params, out) + return out +} + +// GetIntuneDeviceCompliance retrieves compliance information for a specific device +// GET /deviceManagement/managedDevices/{id}/deviceCompliancePolicyStates +func (s *azureClient) GetIntuneDeviceCompliance(ctx context.Context, deviceId string, params query.GraphParams) <-chan AzureResult[intune.ComplianceState] { + if deviceId == "" { + out := make(chan AzureResult[intune.ComplianceState]) + go func() { + defer close(out) + out <- AzureResult[intune.ComplianceState]{Error: fmt.Errorf("deviceId cannot be empty")} + }() + return out + } + + var ( + out = make(chan AzureResult[intune.ComplianceState]) + path = fmt.Sprintf("/%s/deviceManagement/managedDevices/%s/deviceCompliancePolicyStates", constants.GraphApiVersion, deviceId) + ) + + params = setDefaultParams(params) + + go getAzureObjectList[intune.ComplianceState](s.msgraph, ctx, path, params, out) + return out +} + +// GetIntuneDeviceConfiguration retrieves configuration information for a specific device +// GET /deviceManagement/managedDevices/{id}/deviceConfigurationStates +func (s *azureClient) GetIntuneDeviceConfiguration(ctx context.Context, deviceId string, params query.GraphParams) <-chan AzureResult[intune.ConfigurationState] { + if deviceId == "" { + out := make(chan AzureResult[intune.ConfigurationState]) + go func() { + defer close(out) + out <- AzureResult[intune.ConfigurationState]{Error: fmt.Errorf("deviceId cannot be empty")} + }() + return out + } + + var ( + out = make(chan AzureResult[intune.ConfigurationState]) + path = fmt.Sprintf("/%s/deviceManagement/managedDevices/%s/deviceConfigurationStates", constants.GraphApiVersion, deviceId) + ) + + params = setDefaultParams(params) + + go getAzureObjectList[intune.ConfigurationState](s.msgraph, ctx, path, params, out) + return out +} diff --git a/cmd/list-intune-compliance.go b/cmd/list-intune-compliance.go new file mode 100644 index 00000000..46020794 --- /dev/null +++ b/cmd/list-intune-compliance.go @@ -0,0 +1,234 @@ +// File: cmd/list-intune-compliance.go +// Command for listing Intune device compliance information + +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "sync" + "time" + + "github.com/bloodhoundad/azurehound/v2/client" + "github.com/bloodhoundad/azurehound/v2/client/query" + "github.com/bloodhoundad/azurehound/v2/config" + "github.com/bloodhoundad/azurehound/v2/enums" + "github.com/bloodhoundad/azurehound/v2/models/intune" + "github.com/bloodhoundad/azurehound/v2/panicrecovery" + "github.com/bloodhoundad/azurehound/v2/pipeline" + "github.com/spf13/cobra" +) + +func createBasicComplianceState(device intune.ManagedDevice, suffix string) intune.ComplianceState { + return intune.ComplianceState{ + Id: fmt.Sprintf("%s-%s-%d", device.Id, suffix, time.Now().UnixNano()), + DeviceId: device.Id, + DeviceName: device.DeviceName, + State: device.ComplianceState, + Version: 1, + } +} + +var ( + complianceState string + includeDetails bool +) + +func init() { + listRootCmd.AddCommand(listIntuneComplianceCmd) + + listIntuneComplianceCmd.Flags().StringVar(&complianceState, "state", "", "Filter by compliance state: compliant, noncompliant, conflict, error, unknown, inGracePeriod") + listIntuneComplianceCmd.Flags().BoolVar(&includeDetails, "details", false, "Include detailed compliance settings") + + // Add validation for compliance state + listIntuneComplianceCmd.PreRunE = func(cmd *cobra.Command, args []string) error { + if complianceState != "" { + validStates := []string{"compliant", "noncompliant", "conflict", "error", "unknown", "inGracePeriod"} + for _, state := range validStates { + if complianceState == state { + return nil + } + } + return fmt.Errorf("invalid compliance state: %s. Valid values: %s", complianceState, strings.Join(validStates, ", ")) + } + return nil + } +} + +var listIntuneComplianceCmd = &cobra.Command{ + Use: "intune-compliance", + Short: "List Intune device compliance information", + Long: `List compliance information for Intune managed devices. + +Examples: + # List all device compliance + azurehound list intune-compliance --jwt $JWT + + # List only non-compliant devices + azurehound list intune-compliance --state noncompliant --jwt $JWT + + # Include detailed compliance settings + azurehound list intune-compliance --details --jwt $JWT`, + Run: listIntuneComplianceCmdImpl, + SilenceUsage: true, +} + +func listIntuneComplianceCmdImpl(cmd *cobra.Command, args []string) { + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, os.Kill) + defer gracefulShutdown(stop) + + log.V(1).Info("testing connections") + azClient := connectAndCreateClient() + log.Info("collecting intune device compliance...") + start := time.Now() + stream := listIntuneCompliance(ctx, azClient) + panicrecovery.HandleBubbledPanic(ctx, stop, log) + outputStream(ctx, stream) + duration := time.Since(start) + log.Info("collection completed", "duration", duration.String()) +} + +func listIntuneCompliance(ctx context.Context, client client.AzureClient) <-chan interface{} { + var ( + out = make(chan interface{}) + ) + + go func() { + defer panicrecovery.PanicRecovery() + defer close(out) + + // First get all managed devices + devices := getComplianceTargetDevices(ctx, client) + + // Then collect compliance data for each device + collectDeviceCompliance(ctx, client, devices, out) + }() + + return out +} + +func getComplianceTargetDevices(ctx context.Context, client client.AzureClient) <-chan intune.ManagedDevice { + var ( + out = make(chan intune.ManagedDevice) + params = query.GraphParams{ + Filter: "operatingSystem eq 'Windows'", + } + ) + + // Apply compliance state filter if specified + if complianceState != "" { + // Validate complianceState to prevent injection + validStates := map[string]bool{ + "compliant": true, "noncompliant": true, "conflict": true, + "error": true, "unknown": true, "inGracePeriod": true, + } + if !validStates[complianceState] { + log.Error(fmt.Errorf("invalid compliance state"), "invalid state provided", "state", complianceState) + close(out) + return out + } + + if params.Filter != "" { + params.Filter += " and " + } + params.Filter += fmt.Sprintf("complianceState eq '%s'", complianceState) + } + + go func() { + defer panicrecovery.PanicRecovery() + defer close(out) + + count := 0 + for item := range client.ListIntuneManagedDevices(ctx, params) { + if item.Error != nil { + log.Error(item.Error, "unable to continue processing devices") + } else { + log.V(2).Info("found device for compliance check", "device", item.Ok.DeviceName) + count++ + select { + case out <- item.Ok: + case <-ctx.Done(): + return + } + } + } + log.V(1).Info("finished collecting target devices", "count", count) + }() + + return out +} + +func collectDeviceCompliance(ctx context.Context, client client.AzureClient, devices <-chan intune.ManagedDevice, out chan<- interface{}) { + var ( + streams = pipeline.Demux(ctx.Done(), devices, config.ColStreamCount.Value().(int)) + wg sync.WaitGroup + ) + + wg.Add(len(streams)) + for i := range streams { + stream := streams[i] + go func() { + defer panicrecovery.PanicRecovery() + defer wg.Done() + + for device := range stream { + if includeDetails { + collectDetailedCompliance(ctx, client, device, out) + } else { + basicCompliance := createBasicComplianceState(device, "basic") + select { + case out <- NewAzureWrapper(enums.KindAZIntuneCompliance, basicCompliance): + case <-ctx.Done(): + return + } + } + } + }() + } + wg.Wait() +} + +func collectDetailedCompliance(ctx context.Context, client client.AzureClient, device intune.ManagedDevice, out chan<- interface{}) { + log.V(2).Info("collecting detailed compliance", "device", device.DeviceName) + + params := query.GraphParams{} + count := 0 + hasError := false + + for complianceResult := range client.GetIntuneDeviceCompliance(ctx, device.Id, params) { + if complianceResult.Error != nil { + log.Error(complianceResult.Error, "failed to get detailed compliance", "device", device.DeviceName) + hasError = true + break // Stop processing this device on first error + } + + log.V(2).Info("found detailed compliance state", + "device", device.DeviceName, + "state", complianceResult.Ok.State, + "settingsCount", len(complianceResult.Ok.SettingStates)) + + count++ + select { + case out <- NewAzureWrapper(enums.KindAZIntuneCompliance, complianceResult.Ok): + case <-ctx.Done(): + return + } + } + + // Handle fallback after the loop + if hasError && count == 0 { + basicCompliance := createBasicComplianceState(device, "fallback") + select { + case out <- NewAzureWrapper(enums.KindAZIntuneCompliance, basicCompliance): + case <-ctx.Done(): + return + } + } + + if count > 0 { + log.V(1).Info("finished detailed compliance collection", "device", device.DeviceName, "policies", count) + } +} diff --git a/cmd/list-intune-devices.go b/cmd/list-intune-devices.go new file mode 100644 index 00000000..6acfddd1 --- /dev/null +++ b/cmd/list-intune-devices.go @@ -0,0 +1,76 @@ +// File: cmd/list-intune-devices.go +// Copyright (C) 2022 SpecterOps +// Command implementation for listing Intune managed devices + +package cmd + +import ( + "context" + "os" + "os/signal" + "time" + + "github.com/bloodhoundad/azurehound/v2/client" + "github.com/bloodhoundad/azurehound/v2/client/query" + "github.com/bloodhoundad/azurehound/v2/enums" + "github.com/bloodhoundad/azurehound/v2/panicrecovery" + "github.com/spf13/cobra" +) + +func init() { + listRootCmd.AddCommand(listIntuneDevicesCmd) +} + +var listIntuneDevicesCmd = &cobra.Command{ + Use: "intune-devices", + Long: "Lists Intune Managed Devices", + Run: listIntuneDevicesCmdImpl, + SilenceUsage: true, +} + +func listIntuneDevicesCmdImpl(cmd *cobra.Command, args []string) { + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, os.Kill) + defer gracefulShutdown(stop) + + log.V(1).Info("testing connections") + azClient := connectAndCreateClient() + log.Info("collecting intune managed devices...") + start := time.Now() + stream := listIntuneDevices(ctx, azClient) + panicrecovery.HandleBubbledPanic(ctx, stop, log) + outputStream(ctx, stream) + duration := time.Since(start) + log.Info("collection completed", "duration", duration.String()) +} + +func listIntuneDevices(ctx context.Context, client client.AzureClient) <-chan interface{} { + var ( + out = make(chan interface{}) + params = query.GraphParams{ + Filter: "operatingSystem eq 'Windows'", // Focus on Windows devices for BloodHound + } + ) + + go func() { + defer panicrecovery.PanicRecovery() + defer close(out) + + count := 0 + for item := range client.ListIntuneManagedDevices(ctx, params) { + if item.Error != nil { + log.Error(item.Error, "unable to continue processing intune devices") + } else { + log.V(2).Info("found intune device", "device", item.Ok) + count++ + select { + case out <- NewAzureWrapper(enums.KindAZIntuneDevice, item.Ok): + case <-ctx.Done(): + return + } + } + } + log.V(1).Info("finished listing intune devices", "count", count) + }() + + return out +} \ No newline at end of file diff --git a/enums/intune.go b/enums/intune.go new file mode 100644 index 00000000..27449cd0 --- /dev/null +++ b/enums/intune.go @@ -0,0 +1,52 @@ +package enums + +// Intune-specific Kind enumerations for data types +const ( + KindAZIntuneDevice Kind = "AZIntuneDevice" + KindAZIntuneDeviceCompliance Kind = "AZIntuneDeviceCompliance" + KindAZIntuneDeviceConfiguration Kind = "AZIntuneDeviceConfiguration" + KindAZIntuneCompliance Kind = "AZIntuneCompliance" +) + +// Device compliance states +type ComplianceState string + +const ( + ComplianceStateCompliant ComplianceState = "compliant" + ComplianceStateNoncompliant ComplianceState = "noncompliant" + ComplianceStateConflict ComplianceState = "conflict" + ComplianceStateError ComplianceState = "error" + ComplianceStateUnknown ComplianceState = "unknown" + ComplianceStateInGracePeriod ComplianceState = "inGracePeriod" +) + +// Device enrollment types +type EnrollmentType string + +const ( + EnrollmentTypeUserEnrollment EnrollmentType = "userEnrollment" + EnrollmentTypeDeviceEnrollmentManager EnrollmentType = "deviceEnrollmentManager" + EnrollmentTypeWindowsAzureADJoin EnrollmentType = "windowsAzureADJoin" + EnrollmentTypeWindowsAutoEnrollment EnrollmentType = "windowsAutoEnrollment" + EnrollmentTypeWindowsCoManagement EnrollmentType = "windowsCoManagement" +) + +// Management agent types +type ManagementAgent string + +const ( + ManagementAgentMDM ManagementAgent = "mdm" + ManagementAgentIntuneClient ManagementAgent = "intuneClient" + ManagementAgentConfigurationManagerClient ManagementAgent = "configurationManagerClient" + ManagementAgentUnknown ManagementAgent = "unknown" +) + +// Operating system types +type OperatingSystem string + +const ( + OperatingSystemWindows OperatingSystem = "windows" + OperatingSystemAndroid OperatingSystem = "android" + OperatingSystemIOS OperatingSystem = "iOS" + OperatingSystemMacOS OperatingSystem = "macOS" +) \ No newline at end of file diff --git a/models/intune/models.go b/models/intune/models.go new file mode 100644 index 00000000..73045800 --- /dev/null +++ b/models/intune/models.go @@ -0,0 +1,61 @@ +// File: models/intune/models.go +// Copyright (C) 2022 SpecterOps +// Data models for Intune integration + +package intune + +import ( + "time" +) + +// ManagedDevice represents an Intune managed device +type ManagedDevice struct { + Id string `json:"id"` + DeviceName string `json:"deviceName"` + OperatingSystem string `json:"operatingSystem"` + OSVersion string `json:"osVersion"` + ComplianceState string `json:"complianceState"` + LastSyncDateTime time.Time `json:"lastSyncDateTime"` + EnrollmentType string `json:"enrollmentType"` + ManagementAgent string `json:"managementAgent"` + AzureADDeviceId string `json:"azureADDeviceId"` + UserPrincipalName string `json:"userPrincipalName"` + DeviceEnrollmentType string `json:"deviceEnrollmentType"` + JoinType string `json:"joinType"` +} + +// ComplianceState represents device compliance information +type ComplianceState struct { + Id string `json:"id"` + DeviceId string `json:"deviceId"` + DeviceName string `json:"deviceName"` + ComplianceGracePeriodExpirationDateTime time.Time `json:"complianceGracePeriodExpirationDateTime"` + State string `json:"state"` + Version int `json:"version"` + SettingStates []ComplianceSettingState `json:"settingStates"` +} + +// ComplianceSettingState represents individual compliance setting state +type ComplianceSettingState struct { + Setting string `json:"setting"` + State string `json:"state"` + CurrentValue string `json:"currentValue"` +} + +// ConfigurationState represents device configuration state +type ConfigurationState struct { + Id string `json:"id"` + DeviceId string `json:"deviceId"` + DeviceName string `json:"deviceName"` + State string `json:"state"` + Version int `json:"version"` + SettingStates []ConfigurationSettingState `json:"settingStates"` + PlatformType string `json:"platformType"` +} + +// ConfigurationSettingState represents individual configuration setting state +type ConfigurationSettingState struct { + Setting string `json:"setting"` + State string `json:"state"` + CurrentValue string `json:"currentValue"` +} \ No newline at end of file