Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ps-rule/Rule.Rule.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Rule 'Rule.Release' -Type 'PSRule.Rules.Rule' {
# Synopsis: Rules must be added to a rule set.
Rule 'Rule.RuleSet' -Type 'PSRule.Rules.Rule' {
Recommend 'Add a ruleSet the to the rule.'
$Assert.Match($TargetObject, 'Tag.ruleSet', '^(2020|2021|2022|2023|2024|2025)_(03|06|09|12)$')
$Assert.Match($TargetObject, 'Tag.ruleSet', '^(2020|2021|2022|2023|2024|2025|2026)_(03|06|09|12)$')
}

# Synopsis: Annotate rules with a valid Well-Architected Framework pillar.
Expand Down
7 changes: 7 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ See [upgrade notes][1] for helpful information when upgrading from previous vers

## Unreleased

- New rules:
- Automation Account:
- Added `Azure.Automation.RunbookPinned` to check runbook external scripts use pinned commit hash URLs to prevent supply chain attacks.
[#3710](https://github.com/Azure/PSRule.Rules.Azure/issues/3710)
- Deployment Script:
- Added `Azure.DeploymentScript.Pinned` to check deployment script external script URIs use pinned commit hash URLs to prevent supply chain attacks.
[#3710](https://github.com/Azure/PSRule.Rules.Azure/issues/3710)
- Updated rules:
- Azure Kubernetes Service:
- Updated `Azure.AKS.Version` to use `1.33.7` as the minimum version by @BernieWhite.
Expand Down
83 changes: 83 additions & 0 deletions docs/en/rules/Azure.Automation.RunbookPinned.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
reviewed: 2026-03-25
severity: Important
pillar: Security
category: SE:02 Secured development lifecycle
resource: Automation Account
resourceType: Microsoft.Automation/automationAccounts/runbooks
online version: https://azure.github.io/PSRule.Rules.Azure/en/rules/Azure.Automation.RunbookPinned/
---

# Automation runbook is not pinned

## SYNOPSIS

Runbooks that use external scripts from an unpinned URL may be modified to execute malicious code.

## DESCRIPTION

When an Azure Automation runbook uses an external script from a URL, the script content could change between runs.
If the URL is not pinned to a specific commit, a supply chain attack could modify the script and execute malicious code with elevated privileges.

When using scripts from GitHub, a URL should be pinned to a specific commit hash rather than a branch or tag.
A branch or tag can be modified to point to a different commit, allowing a malicious actor to modify the script.
A commit hash is unique and cannot be changed without creating a new commit.

## RECOMMENDATION

Consider updating the runbook to use a URL pinned to a specific commit hash.

## EXAMPLES

### Configure with Bicep

To deploy automation runbooks that pass this rule:

- Set the `properties.publishContentLink.uri` property to a URL that is pinned to a specific commit hash.
- For GitHub hosted scripts, use `https://raw.githubusercontent.com/{owner}/{repo}/{commit-sha}/{path}`.

For example:

```bicep
resource runbook 'Microsoft.Automation/automationAccounts/runbooks@2023-11-01' = {
parent: automationAccount
name: 'runbook-001'
location: location
properties: {
runbookType: 'PowerShell'
publishContentLink: {
uri: 'https://raw.githubusercontent.com/Azure/PSRule.Rules.Azure/8dc395b739a8be00571d039c0af9df88d85c1e2a/scripts/pipeline-deps.ps1'
}
}
}
```

### Configure with Azure template

To deploy automation runbooks that pass this rule:

- Set the `properties.publishContentLink.uri` property to a URL that is pinned to a specific commit hash.
- For GitHub hosted scripts, use `https://raw.githubusercontent.com/{owner}/{repo}/{commit-sha}/{path}`.

For example:

```json
{
"type": "Microsoft.Automation/automationAccounts/runbooks",
"apiVersion": "2023-11-01",
"name": "[format('{0}/{1}', parameters('automationAccountName'), 'runbook-001')]",
"location": "[parameters('location')]",
"properties": {
"runbookType": "PowerShell",
"publishContentLink": {
"uri": "https://raw.githubusercontent.com/Azure/PSRule.Rules.Azure/8dc395b739a8be00571d039c0af9df88d85c1e2a/scripts/pipeline-deps.ps1"
}
}
}
```

## LINKS

- [SE:02 Secured development lifecycle](https://learn.microsoft.com/azure/well-architected/security/secure-development-lifecycle)
- [Manage runbooks in Azure Automation](https://learn.microsoft.com/azure/automation/manage-runbooks)
- [Azure deployment reference](https://learn.microsoft.com/azure/templates/microsoft.automation/automationaccounts/runbooks)
84 changes: 84 additions & 0 deletions docs/en/rules/Azure.DeploymentScript.Pinned.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
reviewed: 2026-03-25
severity: Important
pillar: Security
category: SE:02 Secured development lifecycle
resource: Deployment Script
resourceType: Microsoft.Resources/deploymentScripts
online version: https://azure.github.io/PSRule.Rules.Azure/en/rules/Azure.DeploymentScript.Pinned/
---

# Deployment script is not pinned

## SYNOPSIS

Deployment scripts that use external scripts from an unpinned URL may be modified to execute malicious code.

## DESCRIPTION

When an Azure Deployment Script uses an external script from a URL, the script content could change between runs.
If the URL is not pinned to a specific commit, a supply chain attack could modify the script and execute malicious code with elevated privileges.

When using scripts from GitHub, a URL should be pinned to a specific commit hash rather than a branch or tag.
A branch or tag can be modified to point to a different commit, allowing a malicious actor to modify the script.
A commit hash is unique and cannot be changed without creating a new commit.

## RECOMMENDATION

Consider updating the deployment script to use a URL pinned to a specific commit hash.

## EXAMPLES

### Configure with Bicep

To deploy deployment scripts that pass this rule:

- Set the `properties.primaryScriptUri` property to a URL that is pinned to a specific commit hash.
- For GitHub hosted scripts, use `https://raw.githubusercontent.com/{owner}/{repo}/{commit-sha}/{path}`.
- For each item in `properties.supportingScriptUris`, use a URL that is pinned to a specific commit hash.

For example:

```bicep
resource script 'Microsoft.Resources/deploymentScripts@2023-08-01' = {
name: 'script-001'
location: location
kind: 'AzurePowerShell'
properties: {
azPowerShellVersion: '9.7'
retentionInterval: 'P1D'
primaryScriptUri: 'https://raw.githubusercontent.com/Azure/PSRule.Rules.Azure/8dc395b739a8be00571d039c0af9df88d85c1e2a/scripts/pipeline-deps.ps1'
}
}
```

### Configure with Azure template

To deploy deployment scripts that pass this rule:

- Set the `properties.primaryScriptUri` property to a URL that is pinned to a specific commit hash.
- For GitHub hosted scripts, use `https://raw.githubusercontent.com/{owner}/{repo}/{commit-sha}/{path}`.
- For each item in `properties.supportingScriptUris`, use a URL that is pinned to a specific commit hash.

For example:

```json
{
"type": "Microsoft.Resources/deploymentScripts",
"apiVersion": "2023-08-01",
"name": "script-001",
"location": "[parameters('location')]",
"kind": "AzurePowerShell",
"properties": {
"azPowerShellVersion": "9.7",
"retentionInterval": "P1D",
"primaryScriptUri": "https://raw.githubusercontent.com/Azure/PSRule.Rules.Azure/8dc395b739a8be00571d039c0af9df88d85c1e2a/scripts/pipeline-deps.ps1"
}
}
```

## LINKS

- [SE:02 Secured development lifecycle](https://learn.microsoft.com/azure/well-architected/security/secure-development-lifecycle)
- [Use deployment scripts in ARM templates](https://learn.microsoft.com/azure/azure-resource-manager/templates/deployment-script-template)
- [Azure deployment reference](https://learn.microsoft.com/azure/templates/microsoft.resources/deploymentscripts)
1 change: 1 addition & 0 deletions src/PSRule.Rules.Azure/en/PSRule-rules.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
ERAvailabilityZoneSKU = "The ExpressRoute gateway ({0}) should be using one of the following AZ SKUs ({1})."
AutomationAccountDiagnosticSetting = "The diagnostic setting ({0}) should enable ({1}) or category group ({2})."
AutomationAccountAuditDiagnosticSetting = "Minimum one diagnostic setting should have ({0}) configured or category group ({1}) configured."
GitHubRawScriptUnpinned = "The script URL '{0}' is not pinned to a specific commit hash."
TemplateResourceWithoutComment = "The template ({0}) has ({1}) resource/s without comments."
TemplateResourceWithoutDescription = "The template ({0}) has ({1}) resource/s without descriptions."
PremiumRedisCacheAvailabilityZone = "The premium redis cache ({0}) deployed to region ({1}) should use a minimum of two availability zones from the following [{2}]."
Expand Down
10 changes: 10 additions & 0 deletions src/PSRule.Rules.Azure/rules/Azure.Automation.Rule.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,13 @@ Rule 'Azure.Automation.PlatformLogs' -Ref 'AZR-000089' -Type 'Microsoft.Automati
'AllMetrics'
)
}

# Synopsis: Automation runbook scripts should use a pinned URL to prevent supply chain attacks.
Rule 'Azure.Automation.RunbookPinned' -Ref 'AZR-000543' -Type 'Microsoft.Automation/automationAccounts/runbooks' -Tag @{ release = 'GA'; ruleSet = '2026_06'; 'Azure.WAF/pillar' = 'Security'; } {
$pinnedPattern = '^https://raw\.githubusercontent\.com/[^/]+/[^/]+/[0-9a-f]{40}/';
$uri = $TargetObject.properties.publishContentLink.uri;
if ($Null -eq $uri -or $uri -notLike 'https://raw.githubusercontent.com/*') {
return $Assert.Pass();
}
$Assert.Create($uri -match $pinnedPattern, $LocalizedData.GitHubRawScriptUnpinned, $uri);
}
28 changes: 28 additions & 0 deletions src/PSRule.Rules.Azure/rules/Azure.DeploymentScript.Rule.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

#
# Validation rules for Azure Deployment Scripts
#

# Synopsis: Deployment scripts should use a pinned URL to prevent supply chain attacks.
Rule 'Azure.DeploymentScript.Pinned' -Ref 'AZR-000536' -Type 'Microsoft.Resources/deploymentScripts' -Tag @{ release = 'GA'; ruleSet = '2026_06'; 'Azure.WAF/pillar' = 'Security'; } {
$pinnedPattern = '^https://raw\.githubusercontent\.com/[^/]+/[^/]+/[0-9a-f]{40}/';

$uris = @();
if ($Null -ne $TargetObject.properties.primaryScriptUri) {
$uris += $TargetObject.properties.primaryScriptUri;
}
if ($Null -ne $TargetObject.properties.supportingScriptUris) {
$uris += @($TargetObject.properties.supportingScriptUris);
}

$gitHubUris = @($uris | Where-Object { $_ -like 'https://raw.githubusercontent.com/*' });
if ($gitHubUris.Length -eq 0) {
return $Assert.Pass();
}

foreach ($uri in $gitHubUris) {
$Assert.Create($uri -match $pinnedPattern, $LocalizedData.GitHubRawScriptUnpinned, $uri);
}
}
29 changes: 29 additions & 0 deletions tests/PSRule.Rules.Azure.Tests/Azure.Automation.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,35 @@ Describe 'Azure.Automation' -Tag Automation {
}
}

Context 'Runbook conditions' {
BeforeAll {
$invokeParams = @{
Baseline = 'Azure.All'
Module = 'PSRule.Rules.Azure'
WarningAction = 'Ignore'
ErrorAction = 'Stop'
}
$dataPath = Join-Path -Path $here -ChildPath 'Resources.Automation.Runbook.json';
$result = Invoke-PSRule @invokeParams -InputPath $dataPath -Outcome All;
}

It 'Azure.Automation.RunbookPinned' {
$filteredResult = $result | Where-Object { $_.RuleName -eq 'Azure.Automation.RunbookPinned' };

# Fail
$ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' });
$ruleResult | Should -Not -BeNullOrEmpty;
$ruleResult.Length | Should -Be 1;
$ruleResult.TargetName | Should -Be 'runbook-C';

# Pass
$ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' });
$ruleResult | Should -Not -BeNullOrEmpty;
$ruleResult.Length | Should -Be 3;
$ruleResult.TargetName | Should -BeIn 'runbook-A', 'runbook-B', 'runbook-D';
}
}

Context 'With Configuration Option' {
BeforeAll {
$invokeParams = @{
Expand Down
57 changes: 57 additions & 0 deletions tests/PSRule.Rules.Azure.Tests/Azure.DeploymentScript.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

#
# Unit tests for Azure Deployment Script rules
#

[CmdletBinding()]
param (

)

BeforeAll {
# Setup error handling
$ErrorActionPreference = 'Stop';
Set-StrictMode -Version latest;

if ($Env:SYSTEM_DEBUG -eq 'true') {
$VerbosePreference = 'Continue';
}

# Setup tests paths
$rootPath = $PWD;
Import-Module (Join-Path -Path $rootPath -ChildPath out/modules/PSRule.Rules.Azure) -Force;
$here = (Resolve-Path $PSScriptRoot).Path;
}

Describe 'Azure.DeploymentScript' -Tag 'DeploymentScript' {
Context 'Conditions' {
BeforeAll {
$invokeParams = @{
Baseline = 'Azure.All'
Module = 'PSRule.Rules.Azure'
WarningAction = 'Ignore'
ErrorAction = 'Stop'
}
$dataPath = Join-Path -Path $here -ChildPath 'Resources.DeploymentScript.json';
$result = Invoke-PSRule @invokeParams -InputPath $dataPath -Outcome All;
}

It 'Azure.DeploymentScript.Pinned' {
$filteredResult = $result | Where-Object { $_.RuleName -eq 'Azure.DeploymentScript.Pinned' };

# Fail
$ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' });
$ruleResult | Should -Not -BeNullOrEmpty;
$ruleResult.Length | Should -Be 2;
$ruleResult.TargetName | Should -BeIn 'script-C', 'script-E';

# Pass
$ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' });
$ruleResult | Should -Not -BeNullOrEmpty;
$ruleResult.Length | Should -Be 4;
$ruleResult.TargetName | Should -BeIn 'script-A', 'script-B', 'script-D', 'script-F';
}
}
}
Loading