-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathInvoke-AzBench.ps1
More file actions
4261 lines (3967 loc) · 235 KB
/
Copy pathInvoke-AzBench.ps1
File metadata and controls
4261 lines (3967 loc) · 235 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#Requires -Version 5.1
<#
.SYNOPSIS
AzBench -- Azure tenant security audit anchored to CIS Microsoft Azure Foundations Benchmark v2.1.0.
.DESCRIPTION
AzBench is a single-file PowerShell script that authenticates to an Azure tenant, enumerates
Entra ID + every readable subscription, and runs ~127 CIS checks plus governance
extras (PIM standing assignments, stale SP credentials, classic resources, resource
locks, Defender Secure Score, sub-Owner counts, diagnostic-settings sweep).
Designed to run from a locked-down Azure Virtual Desktop session where the user
has no local admin but CAN install PowerShell modules to CurrentUser scope.
Outputs three artifacts in $OutputPath:
findings.csv Flat one-row-per-check report.
findings.json Structured findings + collected inventory.
report.html Self-contained interactive HTML (no external CDN/JS/CSS).
Every check resolves to exactly one Status:
Pass Compliant.
Fail Non-compliant.
Manual Requires human judgement; evidence captured.
NoAccess Caller lacks required role/scope. Informational, not red.
NotApplicable Resource type absent or scope disabled.
Error Unexpected exception; investigate.
.PARAMETER OutputPath
Folder to write findings.csv, findings.json, and report.html into. Created if missing.
.PARAMETER TenantId
Optional Entra tenant GUID. Required for multi-tenant accounts; otherwise prompts.
.PARAMETER SubscriptionIds
Optional allow-list of subscription GUIDs. If omitted, every subscription the caller
can read is audited.
.PARAMETER UseDeviceCode
Force device-code authentication for both Az and Microsoft.Graph. Use when the AVD
blocks the interactive browser pop-up.
.PARAMETER SkipModuleInstall
Skip the CurrentUser-scope module install step. Modules must already be present.
.PARAMETER ChecksFilter
Optional regex matched against CheckID. Example: '^CIS-3\.' runs only Storage checks.
.PARAMETER MaxParallelism
Per-subscription parallelism cap. Only effective on PowerShell 7+. Defaults to 8.
.PARAMETER IncludeExtras
Run the non-CIS governance extras (PIM, stale SP creds, classic resources, locks,
Secure Score, sub-Owner counts, Log Analytics retention, diagnostic sweep). Default: $true.
.PARAMETER AlreadyAuthenticated
Skip Connect-AzAccount and Connect-MgGraph. Use when the caller has already
established Az and (optionally) Microsoft Graph contexts -- e.g. when running in
Cloud Shell, or when authenticating up-front as a Service Principal via
Connect-AzAccount -ServicePrincipal + Connect-MgGraph -ClientSecretCredential.
The script will reuse the existing contexts. If a Graph context is absent, Section 1
(IAM) checks will report NoAccess gracefully.
.PARAMETER SkipGraph
Skip every Microsoft Graph operation: skips Connect-MgGraph, the Graph permission
probe in Build-Inventory, and every CIS Section 1 (IAM) check plus EXT-002 and
EXT-003. Use when Graph auth is blocked (Conditional Access without a compliant
device, no admin consent, etc.) and you still want the ~80% of checks that need
only Az. Produces a "Microsoft Graph operations skipped" coverage row in section 0
so reviewers know what was deliberately not evaluated.
.PARAMETER CloudShell
Run in Azure Cloud Shell "one-shot" mode. When set (or auto-detected), the script:
1. Reuses the existing Az context (or falls back to Connect-AzAccount -Identity),
so no interactive sign-in is attempted.
2. Imports / installs the six Microsoft.Graph submodules at the exact version of
the Microsoft.Graph.Authentication assembly Cloud Shell has already loaded,
which avoids the "assembly with same name is already loaded" version conflict.
3. Bridges the current Az access token to Microsoft Graph via Get-AzAccessToken +
Connect-MgGraph -AccessToken -- no device code, no Conditional Access prompt.
Cloud Shell is detected automatically (POWERSHELL_DISTRIBUTION_CHANNEL, ACC_CLOUD,
or AZUREPS_HOST_ENVIRONMENT); pass -CloudShell explicitly to force the behavior.
Note: if your tenant's Conditional Access requires a compliant device, Cloud Shell
sign-in itself will fail -- use the Service Principal parameters instead.
.PARAMETER StopOnMissingPermissions
After authenticating, run a permission preflight that functionally probes every
Azure role and Microsoft Graph scope the audit relies on. If any required or
recommended permission is missing, abort BEFORE building inventory or running
checks, and exit with code 2. Without this switch the preflight still runs and
prints its report, but the audit proceeds with warnings (missing permissions just
surface as NoAccess rows). On an interactive host you are also prompted to continue
or stop when gaps are found.
.PARAMETER PreflightOnly
Authenticate, run the permission preflight, print the report, then exit without
building inventory or running any checks. Exit code is 0 when all probed
permissions are present, 2 when any are missing. Use it as a fast pre-flight in
CI/CD or before a long run to confirm the principal is correctly entitled.
.PARAMETER SpAppId
Service Principal application (client) ID. Provide together with -SpTenantId and
-SpSecret for native SP auth: the script calls Connect-AzAccount -ServicePrincipal
and Connect-MgGraph -ClientSecretCredential itself, inside its own scope. This is
the most reliable path in environments where Conditional Access blocks interactive
and device-code flows.
.PARAMETER SpTenantId
Entra tenant (directory) ID for the Service Principal auth path.
.PARAMETER SpSecret
Service Principal client secret as a SecureString, e.g.
(Read-Host 'Client secret' -AsSecureString). Never pass the secret as plaintext.
.EXAMPLE
PS> .\Invoke-AzBench.ps1
Audit every subscription in the default tenant, write outputs to a timestamped folder
under the current directory.
.EXAMPLE
PS> .\Invoke-AzBench.ps1 -TenantId 11111111-1111-1111-1111-111111111111 -UseDeviceCode
Audit a specific tenant using device-code auth (recommended inside AVD).
.EXAMPLE
PS> .\Invoke-AzBench.ps1 -ChecksFilter '^CIS-(1|8)\.'
Run only Section 1 (IAM) and Section 8 (Key Vault) checks.
.EXAMPLE
PS> # Service Principal flow (works around user-CA restrictions).
PS> $cred = New-Object PSCredential($appId, (ConvertTo-SecureString $secret -AsPlainText -Force))
PS> Connect-AzAccount -ServicePrincipal -TenantId $tenantId -Credential $cred
PS> Connect-MgGraph -TenantId $tenantId -ClientSecretCredential $cred -NoWelcome
PS> .\Invoke-AzBench.ps1 -SkipModuleInstall -AlreadyAuthenticated
.EXAMPLE
PS> # Az-only run when no Graph context is available.
PS> .\Invoke-AzBench.ps1 -AlreadyAuthenticated -SkipGraph
.EXAMPLE
PS> # Check entitlements only -- no audit. Exit code 0 = all present, 2 = gaps.
PS> .\Invoke-AzBench.ps1 -PreflightOnly
.EXAMPLE
PS> # Refuse to run unless every required/recommended permission is present.
PS> .\Invoke-AzBench.ps1 -StopOnMissingPermissions
.EXAMPLE
PS> # Azure Cloud Shell: one shot, no separate launcher needed.
PS> ./Invoke-AzBench.ps1 -CloudShell
Version-aligns the Graph submodules, bridges the Az token to Microsoft Graph, and
runs the full audit. -CloudShell is auto-detected, so plain ./Invoke-AzBench.ps1
inside Cloud Shell does the same thing.
.NOTES
Anchored to: CIS Microsoft Azure Foundations Benchmark v2.1.0
Compatible with: Windows PowerShell 5.1 and PowerShell 7.x
Requires modules: Az.* and Microsoft.Graph.* (auto-installed to CurrentUser scope
unless -SkipModuleInstall is set)
Minimum roles: Reader at the scope of each subscription. Security Reader and
Global Reader strongly recommended for full coverage. Without
them many checks correctly report NoAccess rather than a
misleading Pass. See the companion README for the proven
Service Principal permission set.
#>
[CmdletBinding()]
param(
[string] $OutputPath = (Join-Path -Path $PWD -ChildPath ("AzureCISAudit_" + (Get-Date -Format 'yyyyMMdd_HHmmss'))),
[string] $TenantId,
[string[]] $SubscriptionIds,
[switch] $UseDeviceCode,
[switch] $SkipModuleInstall,
[string] $ChecksFilter,
[int] $MaxParallelism = 8,
[bool] $IncludeExtras = $true,
[switch] $AlreadyAuthenticated,
[switch] $SkipGraph,
# Azure Cloud Shell one-shot mode: reuse/attach the Az context, version-align the
# Graph submodules to Cloud Shell's pre-loaded Microsoft.Graph.Authentication, and
# bridge the Az token to Microsoft Graph. Auto-detected; -CloudShell forces it.
[switch] $CloudShell,
# Permission preflight: verify the caller actually has every role/scope the audit
# needs before doing any real work.
[switch] $StopOnMissingPermissions,
[switch] $PreflightOnly,
# Native Service Principal auth (alternative to -AlreadyAuthenticated).
# When all three are provided the script calls Connect-AzAccount -ServicePrincipal
# AND Connect-MgGraph -ClientSecretCredential itself, inside its own scope.
# This is the most reliable path for SP auth because both contexts are established
# in the same scope that the check scriptblocks run in, avoiding any context handoff.
[string] $SpAppId,
[string] $SpTenantId,
[System.Security.SecureString] $SpSecret
)
# --------------------------------------------------------------------------- #
# Globals and constants
# --------------------------------------------------------------------------- #
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$script:ProjectName = 'AzBench'
$script:CISVersion = '2.1.0'
$script:ScriptVersion = '1.0.0'
$script:StartedAt = Get-Date
$script:IsPS7 = $PSVersionTable.PSVersion.Major -ge 7
# Suppress Az noise (Az 12+ prints a breaking-change banner per invocation)
$env:SuppressAzurePowerShellBreakingChangeWarnings = 'true'
# Required modules with minimum versions. Order matters for dependency resolution.
$script:RequiredModules = @(
@{ Name = 'Az.Accounts'; MinVersion = '2.15.0' }
@{ Name = 'Az.ResourceGraph'; MinVersion = '0.13.0' }
@{ Name = 'Az.Resources'; MinVersion = '6.0.0' }
@{ Name = 'Az.Storage'; MinVersion = '6.0.0' }
@{ Name = 'Az.KeyVault'; MinVersion = '4.0.0' }
@{ Name = 'Az.Network'; MinVersion = '6.0.0' }
@{ Name = 'Az.Compute'; MinVersion = '6.0.0' }
@{ Name = 'Az.Sql'; MinVersion = '4.0.0' }
@{ Name = 'Az.PostgreSql'; MinVersion = '1.0.0' }
@{ Name = 'Az.MySql'; MinVersion = '1.0.0' }
@{ Name = 'Az.CosmosDB'; MinVersion = '1.0.0' }
@{ Name = 'Az.Monitor'; MinVersion = '4.0.0' }
@{ Name = 'Az.Security'; MinVersion = '1.5.0' }
@{ Name = 'Az.Websites'; MinVersion = '3.0.0' }
@{ Name = 'Az.PolicyInsights'; MinVersion = '1.6.0' }
@{ Name = 'Microsoft.Graph.Authentication'; MinVersion = '2.20.0' }
@{ Name = 'Microsoft.Graph.Identity.SignIns'; MinVersion = '2.20.0' }
@{ Name = 'Microsoft.Graph.Identity.DirectoryManagement'; MinVersion = '2.20.0' }
@{ Name = 'Microsoft.Graph.Users'; MinVersion = '2.20.0' }
@{ Name = 'Microsoft.Graph.Groups'; MinVersion = '2.20.0' }
@{ Name = 'Microsoft.Graph.Applications'; MinVersion = '2.20.0' }
)
$script:GraphScopes = @(
'Directory.Read.All'
'Policy.Read.All'
'UserAuthenticationMethod.Read.All'
'RoleManagement.Read.Directory'
'Application.Read.All'
'AuditLog.Read.All'
'Group.Read.All'
)
# Inventory cache populated once and read by every check scriptblock
$script:Inventory = [ordered]@{
Tenant = $null
CallerUpn = $null
Subscriptions = @()
Resources = @{} # keyed by lowercase resource type
Coverage = @{} # keyed by subscriptionId -> @{ Reader; SecurityReader; GraphPolicy }
Cloud = $null
}
$script:Results = New-Object 'System.Collections.Generic.List[object]'
# --------------------------------------------------------------------------- #
# Logging helpers
# --------------------------------------------------------------------------- #
function Write-Step {
param([string]$Message, [ValidateSet('Info','Ok','Warn','Err','Step')]$Level = 'Info')
$ts = (Get-Date).ToString('HH:mm:ss')
$prefix = switch ($Level) {
'Info' { '[*]' }
'Ok' { '[+]' }
'Warn' { '[!]' }
'Err' { '[X]' }
'Step' { '[=]' }
}
$color = switch ($Level) {
'Info' { 'Gray' }
'Ok' { 'Green' }
'Warn' { 'Yellow' }
'Err' { 'Red' }
'Step' { 'Cyan' }
}
Write-Host ("{0} {1} {2}" -f $ts, $prefix, $Message) -ForegroundColor $color
}
# --------------------------------------------------------------------------- #
# Module bootstrap
# --------------------------------------------------------------------------- #
function Ensure-Modules {
if ($SkipModuleInstall) {
Write-Step 'Skipping module install per -SkipModuleInstall' 'Info'
} else {
Write-Step 'Verifying required PowerShell modules' 'Step'
# PSGallery may not be trusted on a hardened image; trust it for this session only.
try {
$psg = Get-PSRepository -Name PSGallery -ErrorAction Stop
if ($psg.InstallationPolicy -ne 'Trusted') {
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop
}
} catch {
Write-Step ("PSGallery not registered or could not be trusted: {0}" -f $_.Exception.Message) 'Warn'
}
foreach ($m in $script:RequiredModules) {
$installed = Get-Module -ListAvailable -Name $m.Name | Sort-Object Version -Descending | Select-Object -First 1
if ($installed -and [version]$installed.Version -ge [version]$m.MinVersion) {
continue
}
Write-Step ("Installing {0} >= {1} (CurrentUser)" -f $m.Name, $m.MinVersion) 'Info'
try {
Install-Module -Name $m.Name -MinimumVersion $m.MinVersion -Scope CurrentUser -AllowClobber -Force -ErrorAction Stop
} catch {
Write-Step ("Failed to install {0}: {1}" -f $m.Name, $_.Exception.Message) 'Err'
throw
}
}
}
Write-Step 'Importing modules' 'Info'
foreach ($m in $script:RequiredModules) {
try { Import-Module -Name $m.Name -ErrorAction Stop -WarningAction SilentlyContinue } catch {
Write-Step ("Failed to import {0}: {1}" -f $m.Name, $_.Exception.Message) 'Warn'
}
}
# Mute Az breaking-change banners now that Az.Accounts is loaded.
try { Update-AzConfig -DisplayBreakingChangeWarning $false -Scope Process -ErrorAction SilentlyContinue | Out-Null } catch {}
try { Disable-AzContextAutosave -Scope Process -ErrorAction SilentlyContinue | Out-Null } catch {}
Write-Step 'Modules ready' 'Ok'
}
# --------------------------------------------------------------------------- #
# Azure Cloud Shell bootstrap (folded in from the former start.ps1 launcher)
# --------------------------------------------------------------------------- #
function Test-IsCloudShell {
# Azure Cloud Shell sets these; any one is a reliable signal.
if ($env:POWERSHELL_DISTRIBUTION_CHANNEL -like '*CloudShell*') { return $true }
if ($env:ACC_CLOUD) { return $true }
if ($env:AZUREPS_HOST_ENVIRONMENT -like '*cloud-shell*') { return $true }
return $false
}
function Initialize-CloudShellEnvironment {
# Mirrors the proven start.ps1 sequence, but inside the audit's own scope so the
# Az and Graph contexts survive every per-check cmdlet.
Write-Step 'Cloud Shell mode: bootstrapping Az context and Graph submodules' 'Step'
# 1. Ensure an Az context (managed identity fallback for automation scenarios).
$ctx = Get-AzContext -ErrorAction SilentlyContinue
if (-not $ctx) {
Write-Step 'No Az context found; attempting Connect-AzAccount -Identity' 'Info'
$azParams = @{ Identity = $true; ErrorAction = 'Stop' }
if ($TenantId) { $azParams.TenantId = $TenantId }
$null = Connect-AzAccount @azParams
$ctx = Get-AzContext
}
if (-not $ctx) { throw 'Cloud Shell: could not establish an Az context.' }
Write-Step ("Az context: {0} on tenant {1}" -f $ctx.Account.Id, $ctx.Tenant.Id) 'Ok'
if ($SkipGraph) {
Write-Step '-SkipGraph set: skipping Cloud Shell Graph submodule alignment and token bridge.' 'Info'
return
}
# 2. Determine the Microsoft.Graph.Authentication version to align siblings to.
# Cloud Shell pre-loads a specific version; installing siblings at a different
# version triggers "assembly with same name is already loaded".
$auth = Get-Module -Name Microsoft.Graph.Authentication
if (-not $auth) {
$auth = Get-Module -ListAvailable -Name Microsoft.Graph.Authentication |
Sort-Object Version -Descending | Select-Object -First 1
if ($auth) { Import-Module $auth -Force -ErrorAction SilentlyContinue }
}
if (-not $auth) {
Write-Step 'Installing Microsoft.Graph.Authentication (first time only)' 'Info'
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
Import-Module Microsoft.Graph.Authentication -Force
$auth = Get-Module Microsoft.Graph.Authentication
}
$targetVersion = $auth.Version
Write-Step ("Microsoft.Graph.Authentication loaded: v{0}" -f $targetVersion) 'Ok'
# 3. Ensure the five sibling submodules are importable AT THE SAME VERSION.
$siblings = @(
'Microsoft.Graph.Identity.SignIns'
'Microsoft.Graph.Identity.DirectoryManagement'
'Microsoft.Graph.Users'
'Microsoft.Graph.Groups'
'Microsoft.Graph.Applications'
)
foreach ($name in $siblings) {
if (Get-Module -Name $name) { continue } # already loaded -> leave alone
$matched = Get-Module -ListAvailable -Name $name |
Where-Object { $_.Version -eq $targetVersion } | Select-Object -First 1
if ($matched) {
Import-Module $matched -Force -ErrorAction SilentlyContinue
continue
}
Write-Step ("Installing {0} v{1}" -f $name, $targetVersion) 'Info'
try {
Install-Module -Name $name -RequiredVersion $targetVersion -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
Import-Module -Name $name -RequiredVersion $targetVersion -Force -ErrorAction Stop
} catch {
Write-Step ("Could not install {0} at v{1}: {2}. Section 1 checks needing it will report NoAccess." -f $name, $targetVersion, $_.Exception.Message) 'Warn'
}
}
Write-Step 'Graph submodules ready' 'Ok'
# 4. Bridge the Az access token to Microsoft Graph (no device code, no CA prompt).
try {
$tk = Get-AzAccessToken -ResourceUrl 'https://graph.microsoft.com' -ErrorAction Stop
if ($tk.Token -is [System.Security.SecureString]) {
$sec = $tk.Token
} else {
$sec = ConvertTo-SecureString $tk.Token -AsPlainText -Force
}
Connect-MgGraph -AccessToken $sec -NoWelcome -ErrorAction Stop
$mg = Get-MgContext
Write-Step ("Graph context bridged: {0}" -f $mg.Account) 'Ok'
} catch {
Write-Step ("Graph token bridge failed: {0}. Section 1 (IAM) checks will report NoAccess; other sections run normally." -f $_.Exception.Message) 'Warn'
}
}
# --------------------------------------------------------------------------- #
# Authentication
# --------------------------------------------------------------------------- #
function Initialize-AuditAzContext {
# SP auth path (takes precedence; native auth inside the audit's own scope)
if ($SpAppId -and $SpTenantId -and $SpSecret) {
Write-Step 'Authenticating to Azure as Service Principal' 'Step'
$script:SpCredential = New-Object System.Management.Automation.PSCredential($SpAppId, $SpSecret)
$null = Connect-AzAccount -ServicePrincipal -TenantId $SpTenantId -Credential $script:SpCredential -ErrorAction Stop
$ctx = Get-AzContext
$script:Inventory.Tenant = $ctx.Tenant.Id
$script:Inventory.CallerUpn = $ctx.Account.Id
$script:Inventory.Cloud = $ctx.Environment.Name
Write-Step ("Az SP context: {0} on tenant {1} ({2})" -f $ctx.Account.Id, $ctx.Tenant.Id, $ctx.Environment.Name) 'Ok'
return
}
if ($AlreadyAuthenticated) {
$ctx = Get-AzContext
if (-not $ctx) {
throw "-AlreadyAuthenticated was passed but Get-AzContext returned nothing. Run Connect-AzAccount manually, then re-run with -AlreadyAuthenticated."
}
$script:Inventory.Tenant = $ctx.Tenant.Id
$script:Inventory.CallerUpn = $ctx.Account.Id
$script:Inventory.Cloud = $ctx.Environment.Name
Write-Step ("Reusing existing Az context: {0} on tenant {1} ({2})" -f $ctx.Account.Id, $ctx.Tenant.Id, $ctx.Environment.Name) 'Ok'
return
}
Write-Step 'Connecting to Azure (Az)' 'Step'
$azParams = @{}
if ($TenantId) { $azParams.TenantId = $TenantId }
if ($UseDeviceCode) { $azParams.UseDeviceAuthentication = $true }
try {
$null = Connect-AzAccount @azParams -ErrorAction Stop
} catch {
Write-Step ("Interactive Connect-AzAccount failed ({0}); retrying with device code" -f $_.Exception.Message) 'Warn'
$azParams.UseDeviceAuthentication = $true
$null = Connect-AzAccount @azParams -ErrorAction Stop
}
$ctx = Get-AzContext
if (-not $ctx) { throw "No Azure context after Connect-AzAccount." }
$script:Inventory.Tenant = $ctx.Tenant.Id
$script:Inventory.CallerUpn = $ctx.Account.Id
$script:Inventory.Cloud = $ctx.Environment.Name
Write-Step ("Authenticated as {0} on tenant {1} ({2})" -f $ctx.Account.Id, $ctx.Tenant.Id, $ctx.Environment.Name) 'Ok'
}
function Initialize-AuditGraphContext {
if ($SkipGraph) {
Write-Step '-SkipGraph passed: every Microsoft Graph operation will be bypassed. Section 1 (IAM) and Graph-dependent extras will be skipped.' 'Warn'
return
}
# SP auth path (takes precedence; native auth inside the audit's own scope so the
# context survives every per-check Get-Mg* call). Construct credential from the
# script-level params directly -- don't rely on inter-function variable handoff.
if ($SpAppId -and $SpTenantId -and $SpSecret) {
Write-Step 'Authenticating to Microsoft Graph as Service Principal' 'Step'
try {
$graphCred = New-Object System.Management.Automation.PSCredential($SpAppId, $SpSecret)
Connect-MgGraph -TenantId $SpTenantId -ClientSecretCredential $graphCred -NoWelcome -ErrorAction Stop
$mg = Get-MgContext
if ($mg) {
Write-Step ("Graph SP context: AppName={0} AuthType={1}" -f $mg.AppName, $mg.AuthType) 'Ok'
} else {
Write-Step 'Connect-MgGraph returned but Get-MgContext is null -- Graph operations will likely fail' 'Warn'
}
return
} catch {
Write-Step ("Microsoft Graph SP auth failed: {0}. Section 1 (IAM) checks will report NoAccess." -f $_.Exception.Message) 'Warn'
return
}
}
if ($AlreadyAuthenticated) {
try {
$mgCtx = Get-MgContext -ErrorAction Stop
if ($mgCtx) {
Write-Step ("Reusing existing Graph context (scopes: {0})" -f ($mgCtx.Scopes -join ',')) 'Ok'
return
}
} catch {}
Write-Step "-AlreadyAuthenticated was passed but no Microsoft Graph context found. Section 1 (IAM) checks will mostly be NoAccess. In Cloud Shell, use -CloudShell instead (it bridges the Az token to Graph automatically)." 'Warn'
return
}
Write-Step 'Connecting to Microsoft Graph' 'Step'
$mgParams = @{ Scopes = $script:GraphScopes; NoWelcome = $true }
if ($TenantId) { $mgParams.TenantId = $TenantId }
if ($UseDeviceCode) { $mgParams.UseDeviceCode = $true }
# Sovereign clouds need an Environment switch
if ($script:Inventory.Cloud -and $script:Inventory.Cloud -ne 'AzureCloud') {
$envMap = @{ AzureUSGovernment = 'USGov'; AzureChinaCloud = 'China'; AzureGermanCloud = 'Germany' }
if ($envMap.ContainsKey($script:Inventory.Cloud)) { $mgParams.Environment = $envMap[$script:Inventory.Cloud] }
}
try {
Connect-MgGraph @mgParams -ErrorAction Stop | Out-Null
} catch {
Write-Step ("Interactive Connect-MgGraph failed ({0}); retrying with device code" -f $_.Exception.Message) 'Warn'
$mgParams.UseDeviceCode = $true
try { Connect-MgGraph @mgParams -ErrorAction Stop | Out-Null }
catch {
Write-Step ("Microsoft Graph auth failed; Section 1 (IAM) will largely be NoAccess: {0}" -f $_.Exception.Message) 'Warn'
return
}
}
Write-Step 'Graph connected' 'Ok'
}
# --------------------------------------------------------------------------- #
# Inventory pass (Resource Graph)
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
# Permission preflight
# --------------------------------------------------------------------------- #
function Test-CmdletAccess {
# Runs a cheap probe cmdlet and classifies the outcome:
# Granted - the call succeeded, so the permission is present.
# Missing - failed with an auth/permission error (403/401/RequestDenied).
# Unverified - failed for another reason (throttle, transient, licensing). We
# cannot prove the permission is missing, so we do not hard-fail.
param([scriptblock]$Probe)
try {
$null = & $Probe
return 'Granted'
} catch {
if (Test-ExceptionIsAuth $_.Exception) { return 'Missing' }
return 'Unverified'
}
}
function Invoke-PermissionPreflight {
# Functionally probes every Azure role and Microsoft Graph scope the audit relies
# on, up front, so a caller can see (and optionally stop on) missing entitlements
# before committing to a full run. Returns a summary object consumed by Main.
Write-Step 'Permission preflight: probing required roles and Graph scopes' 'Step'
$items = New-Object 'System.Collections.Generic.List[object]'
$addItem = {
param($Name, $Category, $Plane, $Status, $Detail)
$items.Add([pscustomobject]@{ Name = $Name; Category = $Category; Plane = $Plane; Status = $Status; Detail = $Detail })
}
# --- Azure resource-manager plane -------------------------------------
$subs = @()
try {
$subs = @(Get-AzSubscription -TenantId $script:Inventory.Tenant -ErrorAction Stop)
if ($SubscriptionIds -and $SubscriptionIds.Count -gt 0) {
$subs = @($subs | Where-Object { $SubscriptionIds -contains $_.Id })
}
$subs = @($subs | Where-Object { $_.State -eq 'Enabled' })
} catch {}
if ($subs.Count -gt 0) {
& $addItem 'Azure subscriptions visible' 'Required' 'Azure' 'Granted' ("{0} enabled subscription(s) in scope" -f $subs.Count)
$probeSub = $subs[0]
try { $null = Set-AzContext -SubscriptionId $probeSub.Id -Tenant $script:Inventory.Tenant -ErrorAction Stop } catch {}
$reader = Test-CmdletAccess { Get-AzResourceGroup -ErrorAction Stop | Select-Object -First 1 }
& $addItem 'Azure Reader (resource read)' 'Required' 'Azure' $reader ("Get-AzResourceGroup on '{0}'" -f $probeSub.Name)
$rg = Test-CmdletAccess { Search-AzGraph -Query 'Resources | project id | limit 1' -Subscription $probeSub.Id -First 1 -ErrorAction Stop }
& $addItem 'Azure Resource Graph' 'Required' 'Azure' $rg 'Search-AzGraph (inventory backbone)'
$secRead = Test-CmdletAccess { Get-AzSecurityPricing -ErrorAction Stop | Select-Object -First 1 }
& $addItem 'Security Reader (Defender)' 'Recommended' 'Azure' $secRead 'Get-AzSecurityPricing; unlocks Section 2'
} else {
& $addItem 'Azure subscriptions visible' 'Required' 'Azure' 'Missing' 'Get-AzSubscription returned no enabled subscriptions in scope'
& $addItem 'Azure Reader (resource read)' 'Required' 'Azure' 'Missing' 'no subscription available to probe'
& $addItem 'Azure Resource Graph' 'Required' 'Azure' 'Missing' 'no subscription available to probe'
& $addItem 'Security Reader (Defender)' 'Recommended' 'Azure' 'Missing' 'no subscription available to probe'
}
# --- Microsoft Graph plane --------------------------------------------
if ($SkipGraph) {
& $addItem 'Microsoft Graph (Section 1)' 'Recommended' 'Graph' 'Skipped' '-SkipGraph set; Section 1 (IAM) will not run'
} elseif (-not (Get-MgContext -ErrorAction SilentlyContinue)) {
& $addItem 'Microsoft Graph connection' 'Recommended' 'Graph' 'Missing' 'no Graph context; Section 1 (IAM) will report NoAccess'
} else {
$granted = @()
try { $granted = @((Get-MgContext).Scopes) } catch {}
$graphProbes = @(
@{ Name = 'Directory.Read.All'; Probe = { Get-MgOrganization -ErrorAction Stop | Select-Object -First 1 } }
@{ Name = 'Policy.Read.All'; Probe = { Get-MgPolicyAuthorizationPolicy -ErrorAction Stop } }
@{ Name = 'RoleManagement.Read.Directory'; Probe = { Get-MgRoleManagementDirectoryRoleDefinition -Top 1 -ErrorAction Stop } }
@{ Name = 'Application.Read.All'; Probe = { Get-MgApplication -Top 1 -ErrorAction Stop } }
@{ Name = 'Group.Read.All'; Probe = { Get-MgGroup -Top 1 -ErrorAction Stop } }
@{ Name = 'AuditLog.Read.All'; Probe = { Get-MgAuditLogDirectoryAudit -Top 1 -ErrorAction Stop } }
)
foreach ($gp in $graphProbes) {
$status = Test-CmdletAccess $gp.Probe
& $addItem $gp.Name 'Recommended' 'Graph' $status 'Graph application/delegated scope'
}
# UserAuthenticationMethod.Read.All has no cheap functional probe (it needs a
# specific user target), so fall back to the token's consented-scope list.
$uam = if ($granted -contains 'UserAuthenticationMethod.Read.All') { 'Granted' }
elseif ($granted.Count -eq 0) { 'Unverified' }
else { 'Missing' }
& $addItem 'UserAuthenticationMethod.Read.All' 'Recommended' 'Graph' $uam 'from consented scopes (no functional probe)'
}
# --- Report ------------------------------------------------------------
Write-Host ''
Write-Host (' {0,-34} {1,-12} {2,-6} {3}' -f 'Permission', 'Category', 'Plane', 'Status') -ForegroundColor Cyan
Write-Host (' ' + ('-' * 70)) -ForegroundColor DarkGray
foreach ($it in $items) {
$c = switch ($it.Status) {
'Granted' { 'Green' }
'Missing' { 'Red' }
'Skipped' { 'DarkGray' }
default { 'Yellow' } # Unverified
}
Write-Host (' {0,-34} {1,-12} {2,-6} {3}' -f $it.Name, $it.Category, $it.Plane, $it.Status) -ForegroundColor $c
}
Write-Host ''
$gaps = @($items | Where-Object { $_.Status -eq 'Missing' -and ($_.Category -eq 'Required' -or $_.Category -eq 'Recommended') })
$reqGaps = @($gaps | Where-Object { $_.Category -eq 'Required' })
foreach ($g in $gaps) {
Write-Step ("Missing [{0}] {1} -- {2}" -f $g.Category, $g.Name, $g.Detail) 'Warn'
}
if ($gaps.Count -eq 0) {
Write-Step 'Preflight: all probed permissions present.' 'Ok'
} else {
Write-Step ("Preflight: {0} permission gap(s) found ({1} required, {2} recommended)." -f $gaps.Count, $reqGaps.Count, ($gaps.Count - $reqGaps.Count)) 'Warn'
}
return [pscustomobject]@{
Items = $items
HasGaps = ($gaps.Count -gt 0)
HasRequiredGap = ($reqGaps.Count -gt 0)
MissingCount = $gaps.Count
}
}
function Build-Inventory {
Write-Step 'Enumerating subscriptions' 'Step'
$allSubs = Get-AzSubscription -TenantId $script:Inventory.Tenant -ErrorAction SilentlyContinue
if ($SubscriptionIds -and $SubscriptionIds.Count -gt 0) {
$allSubs = $allSubs | Where-Object { $SubscriptionIds -contains $_.Id }
}
$allSubs = @($allSubs | Where-Object { $_.State -eq 'Enabled' })
$script:Inventory.Subscriptions = $allSubs
Write-Step ("Found {0} enabled subscription(s) in scope" -f $allSubs.Count) 'Ok'
if ($allSubs.Count -eq 0) {
Write-Step 'No subscriptions in scope - only tenant-level (Entra) checks will produce results' 'Warn'
return
}
# Resource Graph: one pass to enumerate every resource of interest across all subs.
# Beats 15+ per-sub Get-Az* cmdlets by an order of magnitude on large tenants.
Write-Step 'Building resource inventory via Resource Graph' 'Step'
$rgTypes = @(
'microsoft.storage/storageaccounts'
'microsoft.keyvault/vaults'
'microsoft.keyvault/managedhsms'
'microsoft.network/networksecuritygroups'
'microsoft.network/networkwatchers'
'microsoft.network/publicipaddresses'
'microsoft.network/bastionhosts'
'microsoft.network/virtualnetworks'
'microsoft.compute/virtualmachines'
'microsoft.compute/disks'
'microsoft.sql/servers'
'microsoft.dbforpostgresql/servers'
'microsoft.dbforpostgresql/flexibleservers'
'microsoft.dbformysql/servers'
'microsoft.dbformysql/flexibleservers'
'microsoft.documentdb/databaseaccounts'
'microsoft.web/sites'
'microsoft.web/serverfarms'
'microsoft.insights/components'
'microsoft.operationalinsights/workspaces'
'microsoft.classiccompute/virtualmachines'
'microsoft.classicstorage/storageaccounts'
'microsoft.classicnetwork/virtualnetworks'
)
$subIdList = $allSubs | ForEach-Object { $_.Id }
foreach ($rt in $rgTypes) {
try {
$rows = @()
$skip = 0
$batch = 1000
$query = "Resources | where type =~ '$rt' | project id, name, type, location, subscriptionId, resourceGroup, tags, properties, sku, kind"
while ($true) {
# Newer Az.ResourceGraph rejects -Skip 0 (must be >= 1). Omit on the first page.
$qParams = @{ Query = $query; Subscription = $subIdList; First = $batch; ErrorAction = 'Stop' }
if ($skip -gt 0) { $qParams.Skip = $skip }
$page = Search-AzGraph @qParams
if (-not $page -or $page.Count -eq 0) { break }
$rows += $page
if ($page.Count -lt $batch) { break }
$skip += $batch
}
$script:Inventory.Resources[$rt] = $rows
} catch {
$script:Inventory.Resources[$rt] = @()
Write-Step ("Resource Graph query for {0} failed: {1}" -f $rt, $_.Exception.Message) 'Warn'
}
}
$inventoryCount = ($script:Inventory.Resources.Values | ForEach-Object { $_.Count } | Measure-Object -Sum).Sum
Write-Step ("Inventory cached: $($script:Inventory.Resources.Keys.Count) resource types, $inventoryCount total resources") 'Ok'
# Per-subscription permission probe
Write-Step 'Probing per-subscription effective permissions' 'Step'
foreach ($sub in $allSubs) {
$cov = @{ Reader = $false; SecurityReader = $false; GraphPolicy = $false }
try {
$null = Set-AzContext -SubscriptionId $sub.Id -Tenant $script:Inventory.Tenant -ErrorAction Stop
try { $null = Get-AzResourceGroup -ErrorAction Stop; $cov.Reader = $true } catch {}
try { $null = Get-AzSecurityPricing -ErrorAction Stop; $cov.SecurityReader = $true } catch {}
} catch {}
$script:Inventory.Coverage[$sub.Id] = $cov
}
if (-not $SkipGraph) {
try {
$null = Get-MgPolicyAuthorizationPolicy -ErrorAction Stop
foreach ($k in @($script:Inventory.Coverage.Keys)) { $script:Inventory.Coverage[$k].GraphPolicy = $true }
} catch {}
}
Write-Step 'Permission probe complete' 'Ok'
}
# --------------------------------------------------------------------------- #
# Check runner / wrapper
# --------------------------------------------------------------------------- #
function Test-ExceptionIsAuth {
param([Exception]$Ex)
if (-not $Ex) { return $false }
$msg = $Ex.Message
if ($msg -match 'AuthorizationFailed|does not have authorization|Authorization_RequestDenied|Insufficient privileges|Forbidden|\(403\)|\(401\)|Unauthorized') { return $true }
if ($Ex.PSObject.Properties.Match('Response').Count -gt 0) {
try {
$code = $Ex.Response.StatusCode.value__
if ($code -in 401, 403) { return $true }
} catch {}
}
return $false
}
function Test-ExceptionIsNotFound {
param([Exception]$Ex)
if (-not $Ex) { return $false }
$msg = $Ex.Message
if ($msg -match 'ResourceNotFound|NotFound|Request_ResourceNotFound|\(404\)|could not be found') { return $true }
if ($Ex.PSObject.Properties.Match('Response').Count -gt 0) {
try {
$code = $Ex.Response.StatusCode.value__
if ($code -eq 404) { return $true }
} catch {}
}
return $false
}
function Test-ExceptionIsThrottle {
param([Exception]$Ex)
if (-not $Ex) { return $false }
if ($Ex.Message -match '\(429\)|TooManyRequests|throttl') { return $true }
return $false
}
function Invoke-CISCheck {
param(
[Parameter(Mandatory)] [hashtable] $Check,
[Parameter()] [object] $Scope
)
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$row = [ordered]@{
CheckID = $Check.CheckID
CISControlID = $Check.CISControlID
Section = $Check.Section
Title = $Check.Title
Severity = $Check.Severity
Level = $Check.Level
Description = $Check.Description
BestPractice = $Check.BestPractice
Remediation = $Check.Remediation
RequiresPerms = ($Check.RequiresPerms -join '; ')
ScopeType = $Check.ScopeType
ScopeId = if ($Scope) { $Scope.Id } else { $null }
ScopeName = if ($Scope) { $Scope.Name } else { 'Tenant' }
Status = $null
ActualResult = $null
Evidence = $null
ExceptionType = $null
ExceptionMessage = $null
DurationMs = 0
}
try {
# Set context for subscription-scoped checks
if ($Check.ScopeType -eq 'Subscription' -and $Scope) {
$null = Set-AzContext -SubscriptionId $Scope.Id -Tenant $script:Inventory.Tenant -ErrorAction Stop
}
$attempts = 0
$maxAttempts = 3
while ($true) {
$attempts++
try {
$r = & $Check.Run $Scope
if ($null -eq $r) { $r = @{ Status = 'Error'; Actual = 'Check returned $null'; Evidence = $null } }
$row.Status = $r.Status
$row.ActualResult = $r.Actual
$row.Evidence = $r.Evidence
break
} catch {
if ((Test-ExceptionIsThrottle $_.Exception) -and $attempts -lt $maxAttempts) {
Start-Sleep -Seconds ([math]::Min(30, [math]::Pow(2, $attempts)))
continue
}
throw
}
}
} catch {
$ex = $_.Exception
$row.ExceptionType = $ex.GetType().FullName
$row.ExceptionMessage = $ex.Message
if (Test-ExceptionIsAuth $ex) {
$row.Status = 'NoAccess'
$row.ActualResult = "Caller lacks required role/scope. Needed: $($Check.RequiresPerms -join ', ')"
} elseif (Test-ExceptionIsNotFound $ex) {
$row.Status = 'NotApplicable'
$row.ActualResult = 'Target resource or scope not present'
} else {
$row.Status = 'Error'
$row.ActualResult = $ex.Message
}
} finally {
$sw.Stop()
$row.DurationMs = $sw.ElapsedMilliseconds
}
[pscustomobject]$row
}
function Invoke-AllChecks {
Write-Step 'Running checks' 'Step'
$reg = $script:CheckRegistry
if ($ChecksFilter) {
$reg = $reg | Where-Object { $_.CheckID -match $ChecksFilter }
Write-Step ("ChecksFilter applied: {0} of {1} checks selected" -f $reg.Count, $script:CheckRegistry.Count) 'Info'
}
# If -SkipGraph: filter out every Section 1 check + Graph-dependent extras,
# and emit one summary row so the report explains the gap.
if ($SkipGraph) {
$graphDependentIds = @('EXT-002','EXT-003') # Extras that need Graph; others stay
$before = $reg.Count
$reg = $reg | Where-Object {
$_.Section -notlike '1.*' -and $_.CheckID -notin $graphDependentIds
}
$skipped = $before - $reg.Count
Write-Step ("-SkipGraph: dropped {0} Graph-dependent check(s) from the run" -f $skipped) 'Warn'
$script:Results.Add( [pscustomobject]@{
CheckID='SKIPGRAPH'; CISControlID='-'; Section='0. Coverage'
Title='Microsoft Graph operations skipped (-SkipGraph)'; Severity='Info'; Level=0
Description='The audit was invoked with -SkipGraph. Section 1 (IAM) and Graph-dependent extras were not evaluated.'
BestPractice='Run without -SkipGraph after a valid Graph context is available.'
Remediation='Provide Microsoft.Graph application-permission consent and a working Graph context.'
RequiresPerms='-'; ScopeType='Tenant'; ScopeId=$null; ScopeName='Tenant'
Status='Manual'; ActualResult=("Skipped {0} Graph-dependent check(s)." -f $skipped)
Evidence=$null; ExceptionType=$null; ExceptionMessage=$null; DurationMs=0
}) | Out-Null
}
$tenantChecks = $reg | Where-Object { $_.ScopeType -eq 'Tenant' }
$subChecks = $reg | Where-Object { $_.ScopeType -eq 'Subscription' }
# Tenant-scoped checks: run once each
foreach ($c in $tenantChecks) {
Write-Step ("[{0}] {1}" -f $c.CheckID, $c.Title) 'Info'
$script:Results.Add( (Invoke-CISCheck -Check $c -Scope $null) ) | Out-Null
}
# Subscription-scoped checks: iterate enabled subs
foreach ($sub in $script:Inventory.Subscriptions) {
Write-Step ("--- Subscription: {0} ({1}) ---" -f $sub.Name, $sub.Id) 'Step'
$cov = $script:Inventory.Coverage[$sub.Id]
if (-not $cov.Reader) {
$script:Results.Add( [pscustomobject]@{
CheckID = 'PROBE-READER'; CISControlID = '-'; Section = '0. Coverage'
Title = 'Subscription Reader access probe'; Severity = 'High'; Level = 0
Description = 'Caller could not enumerate resource groups in this subscription. All subscription-scoped checks below will report NoAccess.'
BestPractice = 'Caller has Reader (or higher) at subscription scope'
Remediation = 'Assign Reader at subscription scope'
RequiresPerms = 'Reader'; ScopeType = 'Subscription'
ScopeId = $sub.Id; ScopeName = $sub.Name
Status = 'Fail'; ActualResult = 'No Reader access on this subscription'
Evidence = $null; ExceptionType = $null; ExceptionMessage = $null; DurationMs = 0
}) | Out-Null
continue
}
foreach ($c in $subChecks) {
Write-Step (" [{0}] {1}" -f $c.CheckID, $c.Title) 'Info'
$script:Results.Add( (Invoke-CISCheck -Check $c -Scope $sub) ) | Out-Null
}
}
Write-Step ("{0} result rows produced" -f $script:Results.Count) 'Ok'
}
# --------------------------------------------------------------------------- #
# Inventory-cache lookup helpers used by check scriptblocks
# --------------------------------------------------------------------------- #
function Get-CachedResources {
param([string]$Type, [string]$SubscriptionId)
$t = $Type.ToLowerInvariant()
if (-not $script:Inventory.Resources.ContainsKey($t)) { return @() }
$rows = $script:Inventory.Resources[$t]
if ($SubscriptionId) { $rows = $rows | Where-Object { $_.subscriptionId -eq $SubscriptionId } }
return ,@($rows)
}
# --------------------------------------------------------------------------- #
# Check registry (built up by the per-section regions below)
# --------------------------------------------------------------------------- #
$script:CheckRegistry = New-Object 'System.Collections.Generic.List[hashtable]'
function Register-Check { param([hashtable]$Check) $script:CheckRegistry.Add($Check) | Out-Null }
# ////////////////////////////////////////////////////////////////////////////
# /////// CHECK DEFINITIONS -- populated by per-section regions below /////////
# ////////////////////////////////////////////////////////////////////////////
#region Section1_IAM
# CIS Section 1 -- Identity and Access Management (Entra ID + AAD-related ARM checks)
# All tenant-scoped unless explicitly per-subscription.
Register-Check @{
CheckID='CIS-1.1.1'; CISControlID='1.1.1'; Section='1. Identity and Access Management'
Title="Ensure Security Defaults is enabled on Microsoft Entra ID (if no CA policies)"
Severity='High'; Level=1
Description='Security Defaults provide free, Microsoft-curated baseline MFA + legacy auth blocking for tenants without Entra P1.'
BestPractice='Either Security Defaults enabled, OR equivalent Conditional Access policies in place.'
Remediation='Entra admin center > Properties > Manage Security defaults > Enabled, OR build equivalent CA policies.'
RequiresPerms=@('Policy.Read.All (Graph)'); ScopeType='Tenant'
Run = {
$sd = Get-MgPolicyIdentitySecurityDefaultEnforcementPolicy -ErrorAction Stop
$caCount = (Get-MgIdentityConditionalAccessPolicy -All -ErrorAction SilentlyContinue | Measure-Object).Count
if ($sd.IsEnabled) {
@{ Status='Pass'; Actual='Security Defaults enabled'; Evidence=@{ SecurityDefaultsEnabled=$true; ConditionalAccessPolicies=$caCount } }
} elseif ($caCount -gt 0) {
@{ Status='Manual'; Actual="Security Defaults disabled but $caCount CA policies present; verify they enforce equivalent baseline"; Evidence=@{ SecurityDefaultsEnabled=$false; ConditionalAccessPolicies=$caCount } }
} else {
@{ Status='Fail'; Actual='Security Defaults disabled AND no Conditional Access policies'; Evidence=@{ SecurityDefaultsEnabled=$false; ConditionalAccessPolicies=0 } }
}
}
}
Register-Check @{
CheckID='CIS-1.1.2'; CISControlID='1.1.2'; Section='1. Identity and Access Management'
Title='Ensure MFA is enabled for all privileged users'
Severity='High'; Level=1
Description='Every account in a privileged Entra role should require MFA via CA policy or Security Defaults.'
BestPractice='CA policy requiring MFA targets all privileged role members, or Security Defaults enabled.'
Remediation='Create CA policy: Assignments > Users > Directory roles (privileged) > Grant > Require MFA.'
RequiresPerms=@('Policy.Read.All','RoleManagement.Read.Directory'); ScopeType='Tenant'
Run = {
$sd = Get-MgPolicyIdentitySecurityDefaultEnforcementPolicy -ErrorAction Stop
if ($sd.IsEnabled) { return @{ Status='Pass'; Actual='Security Defaults covers MFA for privileged roles'; Evidence=@{ SecurityDefaultsEnabled=$true } } }
$policies = Get-MgIdentityConditionalAccessPolicy -All -ErrorAction Stop | Where-Object { $_.State -eq 'enabled' }
$privPolicies = $policies | Where-Object {
($_.Conditions.Users.IncludeRoles -and $_.Conditions.Users.IncludeRoles.Count -gt 0) -and
($_.GrantControls.BuiltInControls -contains 'mfa')
}
if ($privPolicies.Count -gt 0) {
@{ Status='Pass'; Actual="$($privPolicies.Count) CA policy(s) enforce MFA on privileged role members"; Evidence=($privPolicies | Select-Object DisplayName,Id) }
} else {
@{ Status='Fail'; Actual='No enabled CA policy enforces MFA for privileged role members'; Evidence=$null }
}
}
}
Register-Check @{
CheckID='CIS-1.1.3'; CISControlID='1.1.3'; Section='1. Identity and Access Management'