Skip to content

Commit 5f8807a

Browse files
authored
Merge pull request #163 from Mayyhem/BP-491
BED-7248: Resolves BP-491 Request for Entra ID User Last Logon Timestamp AZUser Node Property
2 parents 0f1536d + 6485c2e commit 5f8807a

File tree

5 files changed

+146
-20
lines changed

5 files changed

+146
-20
lines changed

cmd/list-users.go

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"context"
2222
"os"
2323
"os/signal"
24+
"strings"
2425
"time"
2526

2627
"github.com/bloodhoundad/azurehound/v2/client"
@@ -61,29 +62,36 @@ func listUsersCmdImpl(cmd *cobra.Command, _ []string) {
6162
func listUsers(ctx context.Context, client client.AzureClient) <-chan interface{} {
6263
out := make(chan interface{})
6364

64-
params := query.GraphParams{Select: []string{
65-
"accountEnabled",
66-
"createdDateTime",
67-
"displayName",
68-
"jobTitle",
69-
"lastPasswordChangeDateTime",
70-
"mail",
71-
"onPremisesSecurityIdentifier",
72-
"onPremisesSyncEnabled",
73-
"userPrincipalName",
74-
"userType",
75-
"id",
76-
}}
65+
makeParams := func(includeSignInActivity bool) query.GraphParams {
66+
selectCols := []string{
67+
"accountEnabled",
68+
"createdDateTime",
69+
"displayName",
70+
"jobTitle",
71+
"lastPasswordChangeDateTime",
72+
"mail",
73+
"onPremisesSecurityIdentifier",
74+
"onPremisesSyncEnabled",
75+
"userPrincipalName",
76+
"userType",
77+
"id",
78+
}
79+
if includeSignInActivity {
80+
selectCols = append(selectCols, "signInActivity")
81+
}
82+
return query.GraphParams{Select: selectCols}
83+
}
7784

7885
go func() {
7986
defer panicrecovery.PanicRecovery()
8087
defer close(out)
81-
count := 0
82-
for item := range client.ListAzureADUsers(ctx, params) {
83-
if item.Error != nil {
84-
log.Error(item.Error, "unable to continue processing users")
85-
return
86-
} else {
88+
89+
streamOnce := func(params query.GraphParams) (int, error) {
90+
count := 0
91+
for item := range client.ListAzureADUsers(ctx, params) {
92+
if item.Error != nil {
93+
return count, item.Error
94+
}
8795
log.V(2).Info("found user", "id", item.Ok.Id)
8896
count++
8997
user := models.User{
@@ -95,12 +103,36 @@ func listUsers(ctx context.Context, client client.AzureClient) <-chan interface{
95103
Kind: enums.KindAZUser,
96104
Data: user,
97105
}); !ok {
98-
return
106+
return count, nil
99107
}
100108
}
109+
return count, nil
110+
}
111+
112+
includeSignInActivity := true
113+
params := makeParams(true)
114+
count, err := streamOnce(params)
115+
if err != nil && includeSignInActivity && count == 0 && isGraphAuthorizationDenied(err) {
116+
log.Info("warning: authorization denied when requesting signInActivity for users (missing AuditLog.Read.All API permission); retrying without signInActivity")
117+
includeSignInActivity = false
118+
params = makeParams(false)
119+
count, err = streamOnce(params)
120+
}
121+
if err != nil {
122+
log.Error(err, "unable to continue processing users")
123+
return
101124
}
102125
log.Info("finished listing all users", "count", count)
103126
}()
104127

105128
return out
106129
}
130+
131+
func isGraphAuthorizationDenied(err error) bool {
132+
if err == nil {
133+
return false
134+
}
135+
msg := err.Error()
136+
msgLower := strings.ToLower(msg)
137+
return strings.Contains(msg, "Authentication_MSGraphPermissionMissing") && strings.Contains(msgLower, "auditlog.read.all")
138+
}

cmd/list-users_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,10 @@ func TestListUsers(t *testing.T) {
6868
t.Error("expected channel to close from an error result but it did not")
6969
}
7070
}
71+
72+
func TestIsGraphAuthorizationDenied(t *testing.T) {
73+
err := fmt.Errorf("map[error:map[code:Authentication_MSGraphPermissionMissing innerError:map[client-request-id:fac52490-ea06-48f1-941e-5f5bba8e35fc date:2026-01-28T15:29:16 request-id:fac52490-ea06-48f1-941e-5f5bba8e35fc] message:The principal does not have required Microsoft Graph permission(s): AuditLog.Read.All to call this API. For more information about Microsoft Graph permissions, please visit https://learn.microsoft.com/graph/permissions-overview.]]")
74+
if !isGraphAuthorizationDenied(err) {
75+
t.Errorf("expected true")
76+
}
77+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package azure
2+
3+
import "encoding/json"
4+
5+
type userAlias User
6+
7+
type userUnmarshalJSON struct {
8+
*userAlias
9+
SignInActivity *SignInActivity `json:"signInActivity,omitempty"`
10+
}
11+
12+
func (s *User) UnmarshalJSON(data []byte) error {
13+
aux := userUnmarshalJSON{userAlias: (*userAlias)(s)}
14+
15+
if err := json.Unmarshal(data, &aux); err != nil {
16+
return err
17+
}
18+
19+
if s.LastSuccessfulSignInDateTime == "" && aux.SignInActivity != nil && aux.SignInActivity.LastSuccessfulSignInDateTime != nil {
20+
s.LastSuccessfulSignInDateTime = *aux.SignInActivity.LastSuccessfulSignInDateTime
21+
}
22+
23+
return nil
24+
}
25+
26+
// SignInActivity represents Microsoft Graph's `signInActivity` object returned on the user entity.
27+
type SignInActivity struct {
28+
LastSignInDateTime *string `json:"lastSignInDateTime,omitempty"`
29+
LastSignInRequestId *string `json:"lastSignInRequestId,omitempty"`
30+
LastNonInteractiveSignInDateTime *string `json:"lastNonInteractiveSignInDateTime,omitempty"`
31+
LastNonInteractiveSignInRequestId *string `json:"lastNonInteractiveSignInRequestId,omitempty"`
32+
LastSuccessfulSignInDateTime *string `json:"lastSuccessfulSignInDateTime,omitempty"`
33+
LastSuccessfulSignInRequestId *string `json:"lastSuccessfulSignInRequestId,omitempty"`
34+
}

models/azure/user.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,12 @@ type User struct {
215215
// Returned only on `$select`
216216
LastPasswordChangeDateTime string `json:"lastPasswordChangeDateTime,omitempty"`
217217

218+
// The last time the user successfully signed in, in ISO 8601 format (UTC time).
219+
//
220+
// This value is sourced from the Microsoft Graph `signInActivity` dictionary.
221+
// To populate it, the client must include `signInActivity` in `$select`.
222+
LastSuccessfulSignInDateTime string `json:"lastSuccessfulSignInDateTime,omitempty"`
223+
218224
// Used by enterprise applications to determine the legal age group of the user.
219225
//
220226
// Returned only on `$select`

models/azure/user_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (C) 2022 Specter Ops, Inc.
2+
//
3+
// This file is part of AzureHound.
4+
//
5+
// AzureHound is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU General Public License as published by
7+
// the Free Software Foundation, either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// AzureHound is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU General Public License
16+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
18+
package azure
19+
20+
import (
21+
"encoding/json"
22+
"testing"
23+
)
24+
25+
func TestUserUnmarshal_PopulatesLastSuccessfulSignInDateTimeFromSignInActivity(t *testing.T) {
26+
payload := []byte(`{
27+
"id":"3fb2a5fc-3a42-4c11-8200-85302657dc1a",
28+
"displayName":"test-user",
29+
"signInActivity":{
30+
"lastSignInDateTime":"2025-01-27T22:20:22Z",
31+
"lastSignInRequestId":"af4c2c83-9463-434d-a8e5-fbce099b2600",
32+
"lastNonInteractiveSignInDateTime":null,
33+
"lastNonInteractiveSignInRequestId":null,
34+
"lastSuccessfulSignInDateTime":"2025-01-27T22:20:22Z",
35+
"lastSuccessfulSignInRequestId":"af4c2c83-9463-434d-a8e5-fbce099b2600"
36+
}
37+
}`)
38+
39+
var u User
40+
if err := json.Unmarshal(payload, &u); err != nil {
41+
t.Fatalf("unexpected unmarshal error: %v", err)
42+
}
43+
44+
if u.LastSuccessfulSignInDateTime != "2025-01-27T22:20:22Z" {
45+
t.Fatalf("expected LastSuccessfulSignInDateTime to be populated, got %q", u.LastSuccessfulSignInDateTime)
46+
}
47+
}

0 commit comments

Comments
 (0)