Skip to content
Draft
70 changes: 41 additions & 29 deletions cli/cmd/preflight_azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,22 @@ import (
"github.com/lacework/go-sdk/v2/lwpreflight/azure"
)

type azurePreflightOptions struct {
agentless bool
config bool
activityLog bool
existingAdApplication bool
configExistingAdApplication bool
activityLogExistingAdApplication bool
subscriptionID string
tenantID string
clientID string
clientSecret string
region string
}

var (
preflightAzureState struct {
agentless bool
config bool
activityLog bool
subscriptionID string
tenantID string
clientID string
clientSecret string
region string
}
preflightAzureState azurePreflightOptions

preflightAzureCmd = &cobra.Command{
Use: "azure",
Expand All @@ -51,6 +56,12 @@ func init() {
"check permissions for the Config integration")
flags.BoolVar(&preflightAzureState.activityLog, "activity-log", false,
"check permissions for the Activity Log integration")
flags.BoolVar(&preflightAzureState.existingAdApplication, "existing-ad-application", false,
"reuse existing Entra ID applications for both Config and Activity Log")
flags.BoolVar(&preflightAzureState.configExistingAdApplication, "config-existing-ad-application", false,
"reuse an existing Entra ID application for Config")
flags.BoolVar(&preflightAzureState.activityLogExistingAdApplication, "activity-log-existing-ad-application", false,
"reuse an existing Entra ID application for Activity Log")
flags.StringVar(&preflightAzureState.subscriptionID, "subscription-id", "",
"Azure subscription ID (required)")
flags.StringVar(&preflightAzureState.tenantID, "tenant-id", "",
Expand All @@ -75,14 +86,15 @@ func runPreflightAzure(_ *cobra.Command, _ []string) error {
}

params := azure.Params{
Agentless: s.agentless,
Config: s.config,
ActivityLog: s.activityLog,
SubscriptionID: s.subscriptionID,
TenantID: s.tenantID,
ClientID: s.clientID,
ClientSecret: s.clientSecret,
Region: s.region,
Agentless: s.agentless,
Config: s.config,
ActivityLog: s.activityLog,
UseExistingAdApplication: existingAdApplicationsAzure(s),
SubscriptionID: s.subscriptionID,
TenantID: s.tenantID,
ClientID: s.clientID,
ClientSecret: s.clientSecret,
Region: s.region,
}

pf, err := azure.New(params)
Expand Down Expand Up @@ -117,7 +129,9 @@ func renderAzureHumanResult(result *azure.Result, integrations []string) {
cli.OutputHuman(" Object ID: %s\n", result.Caller.ObjectID)
cli.OutputHuman(" Display name: %s\n", result.Caller.DisplayName)
cli.OutputHuman(" Tenant ID: %s\n", result.Caller.TenantID)
cli.OutputHuman(" Admin: %t\n", result.Caller.IsAdmin)
cli.OutputHuman(" Subscription Owner/Contributor: %t\n", result.Caller.IsAdmin)
cli.OutputHuman(" Directory roles: %d\n", len(result.Caller.DirectoryRoles))
cli.OutputHuman(" Graph application permissions: %d\n", len(result.Caller.GraphPermissions))

if len(integrations) > 0 {
cli.OutputHuman("\nIntegrations checked: %s\n", strings.Join(integrations, ", "))
Expand All @@ -131,16 +145,14 @@ func renderAzureHumanResult(result *azure.Result, integrations []string) {
}
}

func integrationsRequestedAzure(s struct {
agentless bool
config bool
activityLog bool
subscriptionID string
tenantID string
clientID string
clientSecret string
region string
}) []string {
func existingAdApplicationsAzure(s azurePreflightOptions) map[azure.IntegrationType]bool {
return map[azure.IntegrationType]bool{
azure.Config: s.existingAdApplication || s.configExistingAdApplication,
azure.ActivityLog: s.existingAdApplication || s.activityLogExistingAdApplication,
}
}

func integrationsRequestedAzure(s azurePreflightOptions) []string {
out := []string{}
if s.agentless {
out = append(out, string(azure.Agentless))
Expand Down
47 changes: 37 additions & 10 deletions cli/cmd/preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,46 @@ func TestIntegrationsRequestedAws(t *testing.T) {
}

func TestIntegrationsRequestedAzure(t *testing.T) {
got := integrationsRequestedAzure(struct {
agentless bool
config bool
activityLog bool
subscriptionID string
tenantID string
clientID string
clientSecret string
region string
}{config: true, activityLog: true})
got := integrationsRequestedAzure(azurePreflightOptions{config: true, activityLog: true})
assert.Equal(t, []string{"azure_config", "azure_activity_log"}, got)
}

func TestExistingAdApplicationsAzure(t *testing.T) {
tests := []struct {
name string
options azurePreflightOptions
expected map[azure.IntegrationType]bool
}{
{
name: "both integrations shorthand",
options: azurePreflightOptions{existingAdApplication: true},
expected: map[azure.IntegrationType]bool{
azure.Config: true, azure.ActivityLog: true,
},
},
{
name: "config only",
options: azurePreflightOptions{configExistingAdApplication: true},
expected: map[azure.IntegrationType]bool{
azure.Config: true, azure.ActivityLog: false,
},
},
{
name: "activity log only",
options: azurePreflightOptions{activityLogExistingAdApplication: true},
expected: map[azure.IntegrationType]bool{
azure.Config: false, azure.ActivityLog: true,
},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.expected, existingAdApplicationsAzure(test.options))
})
}
}

func TestIntegrationsRequestedGcp(t *testing.T) {
got := integrationsRequestedGcp(struct {
agentless bool
Expand Down
21 changes: 12 additions & 9 deletions integration/test_resources/help/preflight_azure
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ Usage:
lacework preflight azure [flags]

Flags:
--activity-log check permissions for the Activity Log integration
--agentless check permissions for the Agentless integration
--client-id string Azure service principal client ID
--client-secret string Azure service principal client secret
--config check permissions for the Config integration
-h, --help help for azure
--region string Azure region to use for region-scoped checks
--subscription-id string Azure subscription ID (required)
--tenant-id string Azure tenant ID (required when using --client-id/--client-secret)
--activity-log check permissions for the Activity Log integration
--activity-log-existing-ad-application reuse an existing Entra ID application for Activity Log
--agentless check permissions for the Agentless integration
--client-id string Azure service principal client ID
--client-secret string Azure service principal client secret
--config check permissions for the Config integration
--config-existing-ad-application reuse an existing Entra ID application for Config
--existing-ad-application reuse existing Entra ID applications for both Config and Activity Log
-h, --help help for azure
--region string Azure region to use for region-scoped checks
--subscription-id string Azure subscription ID (required)
--tenant-id string Azure tenant ID (required when using --client-id/--client-secret)

Global Flags:
-a, --account string account subdomain of URL (i.e. <ACCOUNT>.lacework.net)
Expand Down
37 changes: 24 additions & 13 deletions lwpreflight/azure/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package azure

import (
"errors"
"maps"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
Expand All @@ -16,15 +17,19 @@ type azureConfig struct {
}

type Preflight struct {
azureConfig azureConfig
integrationTypes []IntegrationType
tasks []func(p *Preflight) error
permissions map[string]bool
permissionsWithWildcard []string
azureConfig azureConfig
integrationTypes []IntegrationType
tasks []func(p *Preflight) error
permissions map[string]bool
permissionsWithWildcard []string
useExistingAdApplication map[IntegrationType]bool

caller Caller
details Details
errors map[IntegrationType][]string
// set when the caller's Microsoft Graph application permissions could not
// be read, so a directory-role error can say the second path was not seen
graphPermissionsErr error

verboseWriter verbosewriter.WriteCloser
}
Expand All @@ -44,12 +49,17 @@ type Params struct {
ClientID string
ClientSecret string
Region string
// UseExistingAdApplication identifies Config and Activity Log integrations
// that reuse an existing Entra ID application instead of creating one,
// which waives their directory-role requirements.
UseExistingAdApplication map[IntegrationType]bool
}

func New(params Params) (*Preflight, error) {
integrationTypes := []IntegrationType{}
tasks := []func(p *Preflight) error{
FetchCaller,
CheckDirectoryRoles,
FetchPolicies,
CheckPermissions,
FetchDetails,
Expand Down Expand Up @@ -95,14 +105,15 @@ func New(params Params) (*Preflight, error) {
}

preflight := &Preflight{
azureConfig: cfg,
integrationTypes: integrationTypes,
permissions: map[string]bool{},
permissionsWithWildcard: []string{},
tasks: tasks,
details: Details{},
errors: map[IntegrationType][]string{},
verboseWriter: verbosewriter.New(),
azureConfig: cfg,
integrationTypes: integrationTypes,
permissions: map[string]bool{},
permissionsWithWildcard: []string{},
useExistingAdApplication: maps.Clone(params.UseExistingAdApplication),
tasks: tasks,
details: Details{},
errors: map[IntegrationType][]string{},
verboseWriter: verbosewriter.New(),
}

return preflight, nil
Expand Down
67 changes: 57 additions & 10 deletions lwpreflight/azure/caller.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@ type Caller struct {
DisplayName string
PrincipalID string
TenantID string
IsAdmin bool // true if the caller has Owner or Contributor role
// true if the caller has the Owner or Contributor RBAC role on the
// subscription; says nothing about Entra ID directory roles (see
// DirectoryRoles)
IsAdmin bool
// Entra ID directory role template IDs actively assigned to the caller,
// from the token's wids claim
DirectoryRoles []string
// Microsoft Graph application permissions granted to the caller, from the
// roles claim of a Graph-audience token. Empty for a delegated (user)
// credential, whose effective permission is bounded by its own directory
// roles anyway.
GraphPermissions []string
}

func FetchCaller(p *Preflight) error {
Expand All @@ -45,17 +56,51 @@ func FetchCaller(p *Preflight) error {
return err
}

// Best effort: a caller can hold Graph application permissions instead of a
// directory role. Failing to read them only costs the checks that fall back
// to directory roles alone, so it must not fail preflight.
graphPermissions, err := fetchGraphPermissions(p.azureConfig.cred)
if err != nil {
p.graphPermissionsErr = err
p.verboseWriter.Write(fmt.Sprintf(
"Could not read Microsoft Graph application permissions, "+
"checking Entra ID directory roles only: %v", err))
}

p.caller = Caller{
ObjectID: claims.ObjectID,
DisplayName: claims.DisplayName,
PrincipalID: claims.PrincipalID,
TenantID: claims.TenantID,
IsAdmin: isAdmin,
ObjectID: claims.ObjectID,
DisplayName: claims.DisplayName,
PrincipalID: claims.PrincipalID,
TenantID: claims.TenantID,
IsAdmin: isAdmin,
DirectoryRoles: claims.Wids,
GraphPermissions: graphPermissions,
}

return nil
}

// fetchGraphPermissions reads the caller's Microsoft Graph application
// permissions from the roles claim of a Graph-audience token. The ARM token
// cannot carry them: app roles are scoped to the resource the token is for.
// Only the token is requested, never a Graph API call, so this needs no
// permission of its own.
func fetchGraphPermissions(cred azcore.TokenCredential) ([]string, error) {
token, err := cred.GetToken(context.Background(), policy.TokenRequestOptions{
Scopes: []string{"https://graph.microsoft.com/.default"},
})
if err != nil {
return nil, fmt.Errorf("failed to get token: %v", err)
}

claims, err := parseJWTClaims(token.Token)
if err != nil {
return nil, err
}

return claims.Roles, nil
}

func checkAdminRole(cred azcore.TokenCredential, objectID, subscriptionID string) (bool, error) {
client, err := armauthorization.NewRoleAssignmentsClient(subscriptionID, cred, nil)
if err != nil {
Expand Down Expand Up @@ -97,10 +142,12 @@ func checkAdminRole(cred azcore.TokenCredential, objectID, subscriptionID string
}

type JWTClaims struct {
ObjectID string `json:"oid"`
DisplayName string `json:"name"`
PrincipalID string `json:"sub"`
TenantID string `json:"tid"`
ObjectID string `json:"oid"`
DisplayName string `json:"name"`
PrincipalID string `json:"sub"`
TenantID string `json:"tid"`
Wids []string `json:"wids"`
Roles []string `json:"roles"`
}

func parseJWTClaims(token string) (*JWTClaims, error) {
Expand Down
39 changes: 39 additions & 0 deletions lwpreflight/azure/caller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package azure

import (
"encoding/base64"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseJWTClaimsWids(t *testing.T) {
payload := base64.RawURLEncoding.EncodeToString([]byte(
`{"oid":"o","tid":"t","wids":["` + PrivilegedRoleAdministratorRoleID + `"]}`))
claims, err := parseJWTClaims("h." + payload + ".s")
require.NoError(t, err)
assert.Equal(t, []string{PrivilegedRoleAdministratorRoleID}, claims.Wids)

// no wids claim: nil slice, so every directory role check fails closed
payload = base64.RawURLEncoding.EncodeToString([]byte(`{"oid":"o"}`))
claims, err = parseJWTClaims("h." + payload + ".s")
require.NoError(t, err)
assert.Nil(t, claims.Wids)
}

func TestParseJWTClaimsRoles(t *testing.T) {
// Graph application permissions arrive in the roles claim of a
// Graph-audience token, never in the ARM token the caller check decodes
payload := base64.RawURLEncoding.EncodeToString([]byte(
`{"oid":"o","roles":["` + GraphApplicationReadWriteAllPermission + `"]}`))
claims, err := parseJWTClaims("h." + payload + ".s")
require.NoError(t, err)
assert.Equal(t, []string{GraphApplicationReadWriteAllPermission}, claims.Roles)

// no roles claim: nil slice, so the check falls back to directory roles
payload = base64.RawURLEncoding.EncodeToString([]byte(`{"oid":"o"}`))
claims, err = parseJWTClaims("h." + payload + ".s")
require.NoError(t, err)
assert.Nil(t, claims.Roles)
}
Loading
Loading