rules/AzureDevOps.Pipelines.Releases.Rule.ps1

# Synopsis: Release pipeline production environments should be protected by approval.
Rule 'Azure.DevOps.Pipelines.Releases.Definition.ProductionApproval' `
    -Ref 'ADO-RD-001' `
    -Type 'Azure.DevOps.Pipelines.Releases.Definition' `
    -If { $null -ne ($TargetObject.environments | Where-Object { $_.name -imatch "prd|prod|live|master|main"}) } `
    -Tag @{ release = 'GA'} `
    -Level Error {
        # Description 'Release pipeline production environments should be protected by approval.'
        Reason 'The release pipeline contains a production environment that is not protected by approval.'
        Recommend 'Consider adding approval to the production environment.'
        # Links 'https://docs.microsoft.com/en-us/azure/devops/pipelines/release/approvals/?view=azure-devops'
        $prodEnvironments = $TargetObject.environments | Where-Object { $_.name -imatch "prd|prod|live|master|main"}
        $prodEnvironments | ForEach-Object {
            $Assert.HasField($_, "preDeployApprovals", $true)
            $Assert.HasField($_.preDeployApprovals, "approvals[0].approver", $true)
            $Assert.HasFieldValue($_.preDeployApprovals, "approvals[0].approver.displayName")
            $Assert.GreaterOrEqual($_.preDeployApprovals.approvals, "count", $Configuration.GetValueOrDefault('releaseMinimumProductionApproverCount', 1))
        }
}

# Synopsis: Users should not be able to approve their own release.
Rule 'Azure.DevOps.Pipelines.Releases.Definition.SelfApproval' `
    -Ref 'ADO-RD-002' `
    -Type 'Azure.DevOps.Pipelines.Releases.Definition' `
    -If { $null -ne ($TargetObject.environments | Where-Object { $_.name -imatch "prd|prod|live|master|main"}) } `
    -Tag @{ release = 'GA'} `
    -Level Error {
        # Description 'Users should not be able to approve their own release.'
        Reason 'The release pipeline contains a production where users can approve their own releases.'
        Recommend 'Consider not allowing user to approve their own work to the production environment.'
        # Links 'https://docs.microsoft.com/en-us/azure/devops/pipelines/release/approvals/?view=azure-devops'
        $prodEnvironments = $TargetObject.environments | Where-Object { $_.name -imatch "prd|prod|live|master|main"}
        $prodEnvironments | ForEach-Object {
            $Assert.HasField($_, "preDeployApprovals", $true)
            $Assert.HasField($_.preDeployApprovals, "approvalOptions.releaseCreatorCanBeApprover", $true)
            $Assert.HasFieldValue($_.preDeployApprovals, "approvalOptions.releaseCreatorCanBeApprover", $false)
        }
}

# Synopsis: Release pipeline should not inherit permissions from the project.
Rule 'Azure.DevOps.Pipelines.Releases.Definition.InheritedPermissions' `
    -Ref 'ADO-RD-003' `
    -Type 'Azure.DevOps.Pipelines.Releases.Definition' `
    -Tag @{ release = 'GA'} `
    -Level Error {
        # Description 'Release pipeline should not inherit permissions from the project.'
        Reason 'The release pipeline inherits permissions from the project.'
        Recommend 'Consider removing inherited permissions'
        # Links 'https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-best-practices?view=azure-devops#scoped-permissions'
        AllOf {
            $Assert.HasField($TargetObject, "Acls.inheritPermissions", $true)
            $Assert.HasFieldValue($TargetObject, "Acls.inheritPermissions", $false)
        }
}

# Synopsis: Release pipeline should not have plain text secrets in variables
Rule 'Azure.DevOps.Pipelines.Releases.Definition.NoPlainTextSecrets' `
    -Ref 'ADO-RD-004' `
    -Type 'Azure.DevOps.Pipelines.Releases.Definition' `
    -Tag @{ release = 'GA'} `
    -Level Warning {
        # Description 'Release pipeline should not have plain text secrets in variables'
        Reason 'The release pipeline contains a variable with a secret as plain text.'
        Recommend 'Consider using a secret variable for your pipeline'
        # Links 'https://docs.microsoft.com/en-us/azure/devops/pipelines/process/variables?view=azure-devops&tabs=yaml%2Cbatch#secret-variables'
        AllOf {
            $TargetObject.variables.psobject.Properties | ForEach-Object {
                # Check if the variable matches the regex patterns
                if ($null -ne $_.Value.value) {
                    AllOf {
                        $Assert.HasFieldValue($_, "Value.value")
                            # $Assert.NotMatch($_, "value", "^(?=\D*\d)(?=[^a-z]*[a-z])(?=[^A-Z]*[A-Z])(?=(\w*\W|\w*))[0-9\Wa-zA-Z]{7,20}$")
                            $Assert.NotMatch($_, "Value.value", "((P|p)assword|pwd)\s*=\s*\w+;?")
                            # SQL/MySQL conn strings
                            $Assert.NotMatch($_, "Value.value", "(?# To match SQL/MySQL conn strings.)^Data Source=[^;]+;Initial Catalog=[^;]+;User ID=[^;]+;Password=[^;]+;$")
                            $Assert.NotMatch($_, "Value.value", "(?# To match SQL/MySQL conn strings.)((U|u)ser(Id)?|uid)\s*=\s*\w+;?")
                            # Azure storage keys, connection strings, and SAS
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure storage keys.)^[A-Za-z0-9/+]{86}==$")
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure storage connection strings.)DefaultEndpointsProtocol=https;AccountName=\w+;AccountKey=[A-Za-z0-9/+]{86}==$")
                            $Assert.NotMatch($_, "Value.value", "(?# To match storage SAS.)([^?]*\?sv=)[^&]+(&s[a-z]=[^&]+){4}")
                            # Azure AD client secrets
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure AD client secrets.)^[A-Za-z0-9-._~]{32}$")
                            # Azure DevOps PATs
                            $Assert.NotMatch($_, "Value.value", "(?# To match ADO PATs.)^[a-z2-7]{52}$")
                            # Azure Event Hub connection strings
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure Event Hub connection strings.)Endpoint=sb://[^/]+\.servicebus\.windows\.net/;SharedAccessKeyName=[^;]+;SharedAccessKey=[A-Za-z0-9+/=]{44}==$")
                            # Azure Service Bus connection strings
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure Service Bus connection strings.)Endpoint=sb://[^/]+\.servicebus\.windows\.net/;SharedAccessKeyName=[^;]+;SharedAccessKey=[A-Za-z0-9+/=]{44}==$")
                            # Azure Service Bus SAS
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure Service Bus SAS.)((S|s)hared(A|a)ccess(S|s)ignature|sas)\s*=\s*\w+;?")
                    }
                } else {
                    $Assert.Null($_, "Value.value")
                }
            }

            # Loop through all the environments
            $TargetObject.environments | ForEach-Object {
                # Loop through all the environment variables
                $_.variables.psobject.Properties | ForEach-Object {
                    # Check if the variable matches the regex patterns
                    if ($null -ne $_.Value.value) {
                        AllOf {
                            $Assert.HasFieldValue($_, "Value.value")
                            # $Assert.NotMatch($_, "value", "^(?=\D*\d)(?=[^a-z]*[a-z])(?=[^A-Z]*[A-Z])(?=(\w*\W|\w*))[0-9\Wa-zA-Z]{7,20}$")
                            $Assert.NotMatch($_, "Value.value", "((P|p)assword|pwd)\s*=\s*\w+;?")
                            # SQL/MySQL conn strings
                            $Assert.NotMatch($_, "Value.value", "(?# To match SQL/MySQL conn strings.)^Data Source=[^;]+;Initial Catalog=[^;]+;User ID=[^;]+;Password=[^;]+;$")
                            $Assert.NotMatch($_, "Value.value", "(?# To match SQL/MySQL conn strings.)((U|u)ser(Id)?|uid)\s*=\s*\w+;?")
                            # Azure storage keys, connection strings, and SAS
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure storage keys.)^[A-Za-z0-9/+]{86}==$")
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure storage connection strings.)DefaultEndpointsProtocol=https;AccountName=\w+;AccountKey=[A-Za-z0-9/+]{86}==$")
                            $Assert.NotMatch($_, "Value.value", "(?# To match storage SAS.)([^?]*\?sv=)[^&]+(&s[a-z]=[^&]+){4}")
                            # Azure AD client secrets
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure AD client secrets.)^[A-Za-z0-9-._~]{32}$")
                            # Azure DevOps PATs
                            $Assert.NotMatch($_, "Value.value", "(?# To match ADO PATs.)^[a-z2-7]{52}$")
                            # Azure Event Hub connection strings
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure Event Hub connection strings.)Endpoint=sb://[^/]+\.servicebus\.windows\.net/;SharedAccessKeyName=[^;]+;SharedAccessKey=[A-Za-z0-9+/=]{44}==$")
                            # Azure Service Bus connection strings
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure Service Bus connection strings.)Endpoint=sb://[^/]+\.servicebus\.windows\.net/;SharedAccessKeyName=[^;]+;SharedAccessKey=[A-Za-z0-9+/=]{44}==$")
                            # Azure Service Bus SAS
                            $Assert.NotMatch($_, "Value.value", "(?# To match Azure Service Bus SAS.)((S|s)hared(A|a)ccess(S|s)ignature|sas)\s*=\s*\w+;?")
                        }
                    } else {
                        $Assert.Null($_, "Value.value")
                    }
                }
            }
            # If there were no variables, then the test passes
            $Assert.HasField($TargetObject, "variables", $true)
    }
}