Skip to content

Instantly share code, notes, and snippets.

@rcabr
Last active June 22, 2022 21:44
Show Gist options
  • Save rcabr/a66209ecd6a27747e6ea93260c082bff to your computer and use it in GitHub Desktop.
Save rcabr/a66209ecd6a27747e6ea93260c082bff to your computer and use it in GitHub Desktop.
Azure Automation script to report Azure Policy violations
<#
.Synopsis
Gets all current policy violations for the specified policies,
gets the owning e-mail group by examining the managedBy tag (walks up from resource to resource group to subscription),
gets the last people who touched the resource (by examining audit events in the past 90 days),
and either:
- sends an e-mail to each user with their resources and violations
- sends the full list to a default e-mail address.
.Description
For resources created more than 89 days ago, no LastTouchedBy information will be available.
Whenever no user information is available, a default user/group will be used.
.NOTES
AUTHOR: rcabr
LASTEDIT: 2019-05-21
#>
Param(
[Parameter(Mandatory=$true, HelpMessage="ResourceIds of the policy assignments to query for compliance state.")]
[String[]] $PolicyAssignmentIds,
[Parameter(Mandatory=$true, HelpMessage="Send to this address when we can't identify a user to notify.")]
[String] $DefaultEmailAddress,
[Parameter(Mandatory=$true, HelpMessage="When true, sends separate reports to each suspect. When false, sends a single report to the DefaultEmailAddress.")]
[Boolean] $SendSeparateReports,
[Parameter(HelpMessage="The NAME of the Azure Automation Account Credential for logging in to Office 365 to send e-mail.")]
[String] $O365CredentialName = "O365Credential",
[Parameter(HelpMessage="When set, no e-mail will be sent to any other address but this one. Useful for debugging and testing.")]
[String] $OverrideEmailAddress = $null,
[Parameter(HelpMessage="The e-mail subject line.")]
[String] $EmailSubject = "Azure resources failing Policy check"
)
# Constants
$attachmentFileName = "AzurePolicyViolations.csv"; # name of the e-mail's file attachment
$smtpServerName = "[myorganization].protection.outlook.com";
$smtpServerPort = 25;
$fromEmailAddress = "[myorgname] Azure Automation <no.reply@[mydomain]>";
$emailBodyTitle = "<h2>Azure Policy Warning &#x2601;&#x1F6A9;</h2>";
$emailBodyInstructions = "<p></p>";
$upnSuffix = "[mydomainusually]";
$scopeManagementGroupName = "[mymanagementgroup]-mg"
$managedByTagName = "managedBy";
$emailBodyNextSteps = "<h2>Suggestions for remediation</h2>"; # etc
function Get-DestinationEmailAddress {
param($User)
if (($null -ne $OverrideEmailAddress) -and ($OverrideEmailAddress -ne "")) {
Write-Warning "OVERRIDE: Sending results for user '$User' to '$OverrideEmailAddress' instead"
return $OverrideEmailAddress;
}
if (($null -eq $User) -or ($User -eq ""))
{
Write-Warning "Sending results for unknown users to '$DefaultEmailAddress' instead"
return $DefaultEmailAddress;
}
return $User;
}
# Try to find a managedBy tag on a resource, its resource group, or its subscription,
# and return the value (or $null).
# Throws an exception if the resource doesn't exist.
function Get-ResourceManagedBy {
param([string] $ResourceType, [string] $ResourceId)
$rt = $ResourceType;
$rid = $ResourceId;
# if not a resource group
if ($rt -ne "/Microsoft.Resources/subscriptions/resourceGroups") {
$r = Get-AzureRMResource -ResourceId $rid -ErrorAction Stop;
if ($null -ne $r.Tags -and $r.Tags.ContainsKey($managedByTagName) -eq $True) {
return $r.Tags[$managedByTagName];
}
# move up to the resource group level
$rg = Get-AzureRMResourceGroup -Name $r.ResourceGroupName;
$rt = "/Microsoft.Resources/subscriptions/resourceGroups";
}
# if it's a resource group
if ($rt -eq "/Microsoft.Resources/subscriptions/resourceGroups") {
if ($null -eq $rg) {
$rg = Get-AzureRMResourceGroup -Id $ResourceId -ErrorAction Stop;
}
if ($null -ne $rg.Tags -and $rg.Tags.ContainsKey($managedByTagName) -eq $True) {
return $rg.Tags[$managedByTagName];
}
# move up to the subscription level
$sb = Get-AzureRmSubscription;
$rt = "/Microsoft.Resources/subscriptions";
}
# if it's a subscription
if ($rt -eq "/Microsoft.Resources/subscriptions") {
if ($null -eq $sb) {
$sb = Get-AzureRmSubscription;
}
if ($null -ne $sb.Tags -and $sb.Tags.ContainsKey($managedByTagName) -eq $True) {
return $sb.Tags[$managedByTagName];
}
}
return $null;
}
$connectionName = "AzureRunAsConnection"
try
{
# Get the connection "AzureRunAsConnection "
$servicePrincipalConnection=Get-AutomationConnection -Name $connectionName
"Logging in to Azure..."
Add-AzureRMAccount `
-ServicePrincipal `
-TenantId $servicePrincipalConnection.TenantId `
-ApplicationId $servicePrincipalConnection.ApplicationId `
-CertificateThumbprint $servicePrincipalConnection.CertificateThumbprint
}
catch {
if (!$servicePrincipalConnection)
{
$ErrorMessage = "Connection $connectionName not found."
throw $ErrorMessage
} else{
Write-Error -Message $_.Exception
throw $_.Exception
}
}
$allSubscriptions = Get-AzureRMSubscription
$policyEventToDate = Get-Date;
$policyEventFromDate = $policyEventToDate.AddDays(-89);
$outputRecords = New-Object System.Collections.ArrayList;
foreach ($policyId in $PolicyAssignmentIds) { # query each policy's compliance state
# get current violations for the chosen policies
$states = Get-AzureRMPolicyState `
-ManagementGroupName $scopeManagementGroupName `
-Filter "(IsCompliant eq false) and (PolicyAssignmentId eq '$policyId')" `
-Select "ResourceId,PolicySetDefinitionName,PolicyAssignmentName,PolicyDefinitionName,ResourceType,ResourceGroup,SubscriptionId"
# for each non-compliant resource
foreach ($state in $states)
{
Set-AzureRMContext -Subscription $state.SubscriptionId # we have to change the context for some of these commands to work
try {
# look for a managedBy tag
$managedBy = Get-ResourceManagedBy -ResourceType $state.ResourceType -ResourceId $state.ResourceId;
$lastCaller = $null;
# get latest event to find the responsible user
$lastCaller = Get-AzureRMLog -ResourceId $state.ResourceId -Status Succeeded -StartTime (Get-Date).AddDays(-90) | `
Sort-Object EventTimestamp -Descending | `
Where-Object -Property Caller -Like -Value ("*"+$upnSuffix) | `
Select-Object Caller -Unique
$lastCaller = $lastCaller.Caller -join "; "
}
catch {
if ($PSItem.Exception.Message -like "*ResourceNotFound*" -or $PSItem.Exception.Message -like "*was not found*" -or $PSItem.Exception.Message -like "*could not be found*" -or $PSItem.Exception.Message -like "*does not exist*")
{
Write-Warning " Skipping resource that no longer exists: $($state.ResourceId)"
continue; # skip resources that no longer exist
}
}
# create report record
$record = New-Object -TypeName PSObject;
$record | Add-Member "Initiative" $state.PolicySetDefinitionName.Replace("-", "_");
$record | Add-Member "Policy" $state.PolicyDefinitionName.Replace("-", "_");
$record | Add-Member "ResourceId" $state.ResourceId;
# $record | Add-Member "ResourceName" ($state.ResourceId.Split("/")[-1]);
# $record | Add-Member "ResourceType" $state.ResourceType;
# $record | Add-Member "ResourceGroup" $state.ResourceGroup;
$record | Add-Member "Subscription" ($allSubscriptions | Where-Object Id -EQ $state.SubscriptionId).Name;
$record | Add-Member "LastCaller" $lastCaller;
$record | Add-Member "ManagedBy" $managedBy;
Write-Output $record
$newsize = $outputRecords.Add($record);
}
Write-Output " Completed querying compliance for policy $policyId"
}
Write-Output " Found $($outputRecords.Count) violations.";
# get O365 credentials to send e-mails with
$o365Cred = Get-AutomationPSCredential -Name $O365CredentialName;
if ($null -eq $o365Cred) {
Write-Error "Credential $O365CredentialName does not exist in this Automation Account. Please create one.";
exit
}
# send full report by e-mail
if ($SendSeparateReports -eq $false)
{
$outputRecords | ForEach-Object -Process {
$_ | Add-Member "LastTouchedBy" $_.LastCaller;
};
$records = $outputRecords | Sort-Object ManagedBy, WhoTouchedItLast, Subscription, ResourceId -Descending `
| Select-Object ManagedBy, LastTouchedBy, Initiative, Policy, Subscription, ResourceId;
$toEmail = Get-DestinationEmailAddress $null
$records | Export-Csv -Path $attachmentFileName -NoTypeInformation; # export to CSV so we can attach the file to the e-mail
$emailBody = $emailBodyTitle `
+ "<p>There are $($records.Count) Azure resources that are failing <em>Azure Policy</em> checks. See the attached file and below.</p>" `
+ ($records | ConvertTo-Html -Fragment) `
+ $emailBodyInstructions `
+ "<p><em>This message is intended for: $toEmail</em></p>";
Send-MailMessage `
-To $toEmail `
-From $fromEmailAddress `
-Subject "Report: $EmailSubject" `
-Body $emailBody `
-Attachments $attachmentFileName `
-UseSsl `
-Port $smtpServerPort `
-BodyAsHtml `
-SmtpServer $smtpServerName `
-Credential $o365Cred;
Remove-Item -Path $attachmentFileName; # remove attachment file from filesystem
Write-Output "Sent full report e-mail to $toEmail with $($records.Count) violations"
}
else # send out the e-mails to each user
{
# group violations by user
$outputRecords | ForEach-Object -Process {
$u = $_.ManagedBy;
if ($null -eq $u) { $u = $_.LastCaller; }
$_ | Add-Member "NotifyWho" $u;
};
$violationsByUser = $outputRecords | Group-Object NotifyWho;
foreach ($violations in $violationsByUser | Where-Object -Property Count -NE -Value 0) {
$user = $violations.Name;
$violations.Group | ForEach-Object -Process { $_ | Add-Member "LastTouchedBy" $_.LastCaller; };
$records = $violations.Group | Sort-Object Initiative, Policy, Subscription, ResourceId `
| Select-Object Initiative, Policy, ManagedBy, LastTouchedBy, Subscription, ResourceId;
$toEmail = Get-DestinationEmailAddress $user
$records | Export-Csv -Path $attachmentFileName -NoTypeInformation; # export to CSV so we can attach the file to the e-mail
$emailBody = $emailBodyTitle `
+ "<p>There are $($records.Count) Azure resources that are failing <em>Azure Policy</em> checks. Please see below (or the attached file) and remediate them.</p>" `
+ ($records | ConvertTo-Html -Fragment) `
+ $emailBodyInstructions `
+ $emailBodyNextSteps `
+ "<p><em>This message is intended for: $user </em></p>";
Send-MailMessage `
-To $toEmail `
-From $fromEmailAddress `
-Subject "Warning: $EmailSubject" `
-Body $emailBody `
-Attachments $attachmentFileName `
-UseSsl `
-Port $smtpServerPort `
-BodyAsHtml `
-SmtpServer $smtpServerName `
-Credential $o365Cred;
Remove-Item -Path $attachmentFileName; # remove attachment file from filesystem
Write-Output "Sent e-mail to $toEmail with $($records.Count) violations"
}
}
exit
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment