|
| 1 | +# This script is used as part of our PR gating strategy. It takes advantage of the GHAzDO REST API to check for Code Scanning and Dependency Scanning issues a PR source and target branch. |
| 2 | +# If there are 'new' issues in the source branch, the script will fail with error code 1. |
| 3 | +# The script will also log errors, 1 per new Code Scanning/Dependency alert, it will also add PR annotations for the alert |
| 4 | +$pat = ${env:MAPPED_ADO_PAT} |
| 5 | +$orgUri = ${env:SYSTEM_COLLECTIONURI} |
| 6 | +$orgName = $orgUri -replace "^https://dev.azure.com/|/$" |
| 7 | +$project = ${env:SYSTEM_TEAMPROJECT} |
| 8 | +$repositoryId = ${env:BUILD_REPOSITORY_ID} |
| 9 | +$prTargetBranch = ${env:SYSTEM_PULLREQUEST_TARGETBRANCH} |
| 10 | +$prSourceBranch = ${env:BUILD_SOURCEBRANCH} |
| 11 | +$prId = ${env:SYSTEM_PULLREQUEST_PULLREQUESTID} |
| 12 | +$prCurrentIteration = ${env:SYSTEM_PULLREQUEST_PULLREQUESTITERATION} |
| 13 | +$buildReason = ${env:BUILD_REASON} |
| 14 | +$sourceDir = ${env:BUILD_SOURCESDIRECTORY} |
| 15 | +$headers = @{ Authorization = "Basic $([System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(($pat.Contains(":") ? $pat : ":$pat"))))" } |
| 16 | +#Get-ChildItem Env: | Format-Table -AutoSize |
| 17 | + |
| 18 | +#GATING POLICY - Which alerts to check for and which severities to include |
| 19 | +$alertTypes = @("dependency", "code") |
| 20 | +$severityPolicy = @{ |
| 21 | + "dependency" = @("critical", "high") #"medium", "low" |
| 22 | + "code" = @("critical", "high", "error") #"medium", "low", "warning", "note" #Security and Quality Severities |
| 23 | +} |
| 24 | + |
| 25 | +# Alerts - List api: https://learn.microsoft.com/en-us/rest/api/azure/devops/advancedsecurity/alerts/list (criteria.states = 1 means open alerts, criteria.alertType does not support multiple values, so we need to allow default and filter out later) |
| 26 | +$urlTargetAlerts = "https://advsec.dev.azure.com/{0}/{1}/_apis/Alert/repositories/{2}/Alerts?top=500&orderBy=lastSeen&criteria.ref={3}&criteria.states=1" -f $orgName, $project, $repositoryId, $prTargetBranch |
| 27 | +$urlSourceAlerts = "https://advsec.dev.azure.com/{0}/{1}/_apis/Alert/repositories/{2}/Alerts?top=500&orderBy=lastSeen&criteria.ref={3}&criteria.states=1" -f $orgName, $project, $repositoryId, $prSourceBranch |
| 28 | + |
| 29 | +#PR Threads - Create api: https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-threads/create |
| 30 | +$urlComment = "https://dev.azure.com/{0}/{1}/_apis/git/repositories/{2}/pullRequests/{3}/threads?api-version=7.1-preview.1" -f $orgName, $project, $repositoryId, $prId |
| 31 | +#PR Iteration Changes - Get API: https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-iteration-changes/get |
| 32 | +$urlIteration = "https://dev.azure.com/{0}/{1}/_apis/git/repositories/{2}/pullRequests/{3}/iterations/{4}/changes?api-version=7.1-preview.1&`$compareTo={5}" -f $orgName, $project, $repositoryId, $prId, $prCurrentIteration, ($prCurrentIteration - 1) |
| 33 | + |
| 34 | +# Add a PR annotations for the Alert in the changed file. This is only intended for alerts where the alert intersects with source code found in the PR diff. |
| 35 | +function AddPRComment($prAlert, $urlAlert) { |
| 36 | + $pathToCheck = $prAlert.physicalLocations[-1].filePath |
| 37 | + |
| 38 | + ## Todo - potentially improve this for transitive dependencies by walking the path to parent(will need to dedup as Dependency scanning will report findings on transitive manifests such as /node_modules/x/package.json ) |
| 39 | + if ($prAlert.alertType -eq "dependency") { |
| 40 | + #dependency alerts physicalLocations always begin with the last directory in sourceDir (ex: 's/package.json'), so parse it out |
| 41 | + #$sourceDirSegment = Split-Path $sourceDir -Leaf ###also works but harder to test locally as it needs a real Path :) |
| 42 | + $sourceDirSegment = $sourceDir.Split([System.IO.Path]::DirectorySeparatorChar)[-1] |
| 43 | + $pathToCheck = $pathToCheck.TrimStart($sourceDirSegment) |
| 44 | + } |
| 45 | + elseif($prAlert.alertType -eq "code") { |
| 46 | + $pathToCheck = "/" + $pathToCheck |
| 47 | + } |
| 48 | + |
| 49 | + # Get Pull Request iterations, we need this to map the file to a changeTrackingId |
| 50 | + $prIterations = Invoke-RestMethod -Uri $urlIteration -Method Get -Headers $headers |
| 51 | + |
| 52 | + # Find the changeTrackingId mapping to the file with the Code Scanning alert |
| 53 | + $iterationItem = $prIterations.changeEntries | Where-Object { $_.item.path -like $pathToCheck } | Select-Object -First 1 |
| 54 | + |
| 55 | + # Any change to the file with the alert in this PR iteration? |
| 56 | + if ($null -eq $iterationItem) { |
| 57 | + Write-Host "##[debug] In this iteration of the PR:Iteration $prCurrentIteration, there is no change to the file where the alert was detected: $pathToCheck." |
| 58 | + return |
| 59 | + } |
| 60 | + |
| 61 | + if ($prAlert.alertType -eq "dependency") { |
| 62 | + # Define the Body hashtable |
| 63 | + # Dependency alerts do not have line numbers, so we will not add a line number to the comment |
| 64 | + $body = @{ |
| 65 | + "comments" = @( |
| 66 | + @{ |
| 67 | + "content" = "**$($prAlert.title)** |
| 68 | + $($prAlert.tools.rules.description) |
| 69 | + See details [here]($($urlAlert))" |
| 70 | + "commentType" = 1 |
| 71 | + } |
| 72 | + ) |
| 73 | + "status" = 1 |
| 74 | + "threadContext" = @{ |
| 75 | + "filePath" = "./$($prAlert.physicalLocations[-1].filePath)" |
| 76 | + } |
| 77 | + "pullRequestThreadContext" = @{ |
| 78 | + "changeTrackingId" = $($iterationItem.changeTrackingId) |
| 79 | + "iterationContext" = @{ |
| 80 | + "firstComparingIteration" = $($prCurrentIteration) |
| 81 | + "secondComparingIteration" = $($prCurrentIteration) |
| 82 | + } |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | + elseif($prAlert.alertType -eq "code") { |
| 87 | + $lineEnd = $($prAlert.physicalLocations[-1].region.lineEnd) |
| 88 | + $lineStart = $($prAlert.physicalLocations[-1].region.lineStart) |
| 89 | + |
| 90 | + if ($lineStart -eq 0) { |
| 91 | + $lineStart = 1 |
| 92 | + } |
| 93 | + |
| 94 | + if ($lineEnd -eq 0) { |
| 95 | + $lineEnd = $lineStart |
| 96 | + } |
| 97 | + |
| 98 | + # Define the Body hashtable |
| 99 | + $body = @{ |
| 100 | + "comments" = @( |
| 101 | + @{ |
| 102 | + "content" = "**$($prAlert.title)** |
| 103 | + $($prAlert.tools.rules.description) |
| 104 | + See details [here]($($urlAlert))" |
| 105 | + "commentType" = 1 |
| 106 | + } |
| 107 | + ) |
| 108 | + "status" = 1 |
| 109 | + "threadContext" = @{ |
| 110 | + "filePath" = "./$($prAlert.physicalLocations[-1].filePath)" |
| 111 | + "rightFileStart" = @{ |
| 112 | + "line" = $lineStart |
| 113 | + "offset" = $($prAlert.physicalLocations[-1].region.columnStart) |
| 114 | + } |
| 115 | + "rightFileEnd" = @{ |
| 116 | + "line" = $lineEnd |
| 117 | + "offset" = $($prAlert.physicalLocations[-1].region.columnEnd) |
| 118 | + } |
| 119 | + } |
| 120 | + "pullRequestThreadContext" = @{ |
| 121 | + "changeTrackingId" = $($iterationItem.changeTrackingId) |
| 122 | + "iterationContext" = @{ |
| 123 | + "firstComparingIteration" = $($prCurrentIteration) |
| 124 | + "secondComparingIteration" = $($prCurrentIteration) |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + # Convert the hashtable to a JSON string |
| 131 | + $bodyJson = $body | ConvertTo-Json -Depth 10 |
| 132 | + #Write-Output $bodyJson |
| 133 | + |
| 134 | + # Send the PR Threads Create POST request |
| 135 | + $response = Invoke-RestMethod -Uri $urlComment -Method Post -Headers $headers -Body $bodyJson -ContentType "application/json" |
| 136 | + |
| 137 | + #Write-Output $response |
| 138 | + Write-Host "##[debug] New thread created in PR:Iteration $prCurrentIteration : $($response._links.self.href)" |
| 139 | + |
| 140 | + return |
| 141 | +} |
| 142 | + |
| 143 | +Write-Host "Will check to see if there are any new Dependency or Code scanning alerts in this PR branch" |
| 144 | +Write-Host "PR source : $($prSourceBranch). PR target: $($prTargetBranch)" |
| 145 | + |
| 146 | +if ($buildReason -ne 'PullRequest') { |
| 147 | + Write-Host "This build is not part of a Pull Request so all is ok" |
| 148 | + exit 0 |
| 149 | +} |
| 150 | + |
| 151 | +# Wait |
| 152 | +Write-Host "Wait 5 mins for all other scans to finish... Should check the Build validations for the PR to see if they are all done." |
| 153 | +Start-Sleep -Seconds 300 |
| 154 | + |
| 155 | +# Get the alerts on the pr target branch (all without filter) and the PR source branch (only currently open) |
| 156 | +$alertsPRSource = Invoke-WebRequest -Uri $urlSourceAlerts -Headers $headers -Method Get |
| 157 | + |
| 158 | +# The Advanced Security scanning of the target branch runs in a separate pipeline. This scan might not have been completed. |
| 159 | +# Try to get the results 3 times with a 1 min wait between each try. |
| 160 | +$retries = 3 |
| 161 | +while ($retries -gt 0) { |
| 162 | + try { |
| 163 | + $alertsPRTarget = Invoke-WebRequest -Uri $urlTargetAlerts -Headers $headers -Method Get -ErrorAction Stop |
| 164 | + # Success |
| 165 | + break |
| 166 | + } |
| 167 | + catch { |
| 168 | + # No GHAzDO results on the target branch, wait and retry? |
| 169 | + if ($_.ErrorDetails.Message.Split("`"") -contains "BranchNotFoundException") { |
| 170 | + $retries-- |
| 171 | + if ($retries -eq 0) { |
| 172 | + # We have retried the maximum number of times, give up |
| 173 | + Write-Host "##vso[task.logissue type=error] We have retried the maximum number of times, give up." |
| 174 | + throw $_ |
| 175 | + } |
| 176 | + |
| 177 | + # Wait and then try again |
| 178 | + Write-Host "There are no GHAzDO results on the target branch, wait and try again." |
| 179 | + Start-Sleep -Seconds 60 |
| 180 | + } |
| 181 | + else { |
| 182 | + # Something else is wrong, give up |
| 183 | + Write-Host "##vso[task.logissue type=error] There was an unexpected error." |
| 184 | + throw $_ |
| 185 | + } |
| 186 | + } |
| 187 | +} |
| 188 | + |
| 189 | +if ($alertsPRTarget.StatusCode -ne 200) { |
| 190 | + Write-Host "##vso[task.logissue type=error] Error getting alerts from Azure DevOps Advanced Security PR target branch:", $alertsPRTarget.StatusCode, $alertsPRTarget.StatusDescription |
| 191 | + exit 1 |
| 192 | +} |
| 193 | + |
| 194 | +if ($alertsPRSource.StatusCode -ne 200) { |
| 195 | + Write-Host "##vso[task.logissue type=error] Error getting alerts from Azure DevOps Advanced Security PR source branch:", $alertsPRSource.StatusCode, $alertsPRSource.StatusDescription |
| 196 | + exit 1 |
| 197 | +} |
| 198 | + |
| 199 | +# Filter out the alert types that we are interested in |
| 200 | +$jsonPRTarget = ($alertsPRTarget.Content | ConvertFrom-Json).value | Where-Object { $alertTypes -contains $_.alertType } |
| 201 | +$jsonPRSource = ($alertsPRSource.Content | ConvertFrom-Json).value | Where-Object { $alertTypes -contains $_.alertType } |
| 202 | + |
| 203 | +# Extract alert ids from the list of alerts on pr target/source branch. |
| 204 | +$prTargetAlertIds = $jsonPRTarget | Select-Object -ExpandProperty alertId |
| 205 | +$prSourceAlertIds = $jsonPRSource | Select-Object -ExpandProperty alertId |
| 206 | + |
| 207 | +# Check for alert ids that are reported in the PR source branch but not the pr target branch |
| 208 | +$newAlertIds = Compare-Object $prSourceAlertIds $prTargetAlertIds -PassThru | Where-Object { $_.SideIndicator -eq '<=' } |
| 209 | +$dependencyAlerts = $codeAlerts = 0 |
| 210 | + |
| 211 | +# Are there any new alert ids in the PR source branch? |
| 212 | +if ($newAlertIds.length -gt 0) { |
| 213 | + Write-Host "##[warning] The code changes in this PR looks to be introducing new security alerts:" |
| 214 | + |
| 215 | + # Loop over the objects in the prAlerts JSON object |
| 216 | + foreach ($prAlert in $jsonPRSource) { |
| 217 | + if ($newAlertIds -contains $prAlert.alertId) { |
| 218 | + |
| 219 | + #check to see $alert.severity in $severityPolicy for given alertType - valid severities: https://learn.microsoft.com/en-us/rest/api/azure/devops/advancedsecurity/alerts/list#severity |
| 220 | + if ($severityPolicy[$prAlert.alertType] -notcontains $prAlert.severity) { |
| 221 | + Write-Host "##[warning] Ignored by policy - $($prAlert.severity) severity $($prAlert.alertType) alert detected #$($prAlert.alertId) : $($prAlert.title) in pr branch $($prSourceBranch)." |
| 222 | + continue |
| 223 | + } |
| 224 | + |
| 225 | + # New Alert for this PR. Log and report it. |
| 226 | + Write-Host "" |
| 227 | + if ($prAlert.alertType -eq "dependency") { |
| 228 | + Write-Host "##vso[task.logissue type=error] New $($prAlert.severity) severity $($prAlert.alertType) alert detected #$($prAlert.alertId) in library: $($prAlert.logicalLocations[-1].fullyQualifiedName). `"$($prAlert.title)`". Detected in manifest: $($prAlert.physicalLocations[-1].filePath)." |
| 229 | + $dependencyAlerts++ |
| 230 | + } |
| 231 | + elseif ($prAlert.alertType -eq "code") { |
| 232 | + Write-Host "##vso[task.logissue type=error;sourcepath=$($prAlert.physicalLocations[-1].filePath);linenumber=$($prAlert.physicalLocations[-1].region.lineStart);columnnumber=$($prAlert.physicalLocations[-1].region.columnStart)] New $($prAlert.severity) severity $($prAlert.alertType) alert detected #$($prAlert.alertId) : $($prAlert.title)." |
| 233 | + $codeAlerts++ |
| 234 | + } |
| 235 | + |
| 236 | + Write-Host "##[error] Fix or dismiss this new $($prAlert.alertType) alert in the Advanced Security UI for pr branch $($prSourceBranch)." |
| 237 | + $urlAlert = "https://dev.azure.com/{0}/{1}/_git/{2}/alerts/{3}?branch={4}" -f $orgName, $project, $repositoryId, $prAlert.alertId, $prSourceBranch |
| 238 | + Write-Host "##[error] Details for this new alert: $($urlAlert)" |
| 239 | + AddPRComment $prAlert $urlAlert |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + if ($dependencyAlerts + $codeAlerts -gt 0) { |
| 244 | + Write-Host |
| 245 | + Write-Host "##[error] Dissmiss or fix failing alerts listed (dependency #: $dependencyAlerts / code #: $codeAlerts ) and try re-queue the CIVerify task." |
| 246 | + exit 1 #TODO - dynamically pass/fail the build only if a PR comment was added, indicating that there are new alerts that were directly created by this PR. Since we do incremental PR iteration based comments this is not currently viable. |
| 247 | + } |
| 248 | + else { |
| 249 | + Write-Host "##[warning] New alerts detected but none that violate policy - all is fine though these will appear in the Advanced Security UI." |
| 250 | + exit 0 |
| 251 | + } |
| 252 | +} |
| 253 | +else { |
| 254 | + Write-Output "No new alerts - all is fine" |
| 255 | + exit 0 |
| 256 | +} |
0 commit comments