|
| 1 | +. "$PSScriptRoot\json.ps1" # 'json_path' |
| 2 | +. "$PSScriptRoot\download.ps1" # 'hash_for_url' |
| 3 | + |
| 4 | +# Error codes |
| 5 | +$script:_ERR_UNSAFE = 2 |
| 6 | +$script:_ERR_EXCEPTION = 4 |
| 7 | +$script:_ERR_NO_INFO = 8 |
| 8 | +$script:_ERR_NO_API_KEY = 16 |
| 9 | + |
| 10 | +# Global state variables |
| 11 | +$script:requests = 0 |
| 12 | +$script:explained_rate_limit_sleeping = $False |
| 13 | +$script:exit_code = 0 |
| 14 | + |
| 15 | +function ConvertTo-VirusTotalUrlId ($url) { |
| 16 | + $url_id = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($url)) |
| 17 | + $url_id = $url_id -replace '\+', '-' |
| 18 | + $url_id = $url_id -replace '/', '_' |
| 19 | + $url_id = $url_id -replace '=', '' |
| 20 | + $url_id |
| 21 | +} |
| 22 | + |
| 23 | +function Get-VirusTotalResultByHash ($hash, $url, $app, $api_key) { |
| 24 | + $hash = $hash.ToLower() |
| 25 | + $api_url = "https://www.virustotal.com/api/v3/files/$hash" |
| 26 | + $headers = @{ |
| 27 | + 'Accept' = 'application/json' |
| 28 | + 'x-apikey' = $api_key |
| 29 | + } |
| 30 | + $response = Invoke-WebRequest -Uri $api_url -Method GET -Headers $headers -UseBasicParsing |
| 31 | + $result = $response.Content |
| 32 | + $stats = json_path $result '$.data.attributes.last_analysis_stats' |
| 33 | + [int]$malicious = json_path $stats '$.malicious' |
| 34 | + [int]$suspicious = json_path $stats '$.suspicious' |
| 35 | + [int]$timeout = json_path $stats '$.timeout' |
| 36 | + [int]$undetected = json_path $stats '$.undetected' |
| 37 | + [int]$unsafe = $malicious + $suspicious |
| 38 | + [int]$total = $unsafe + $undetected |
| 39 | + [int]$fileSize = json_path $result '$.data.attributes.size' |
| 40 | + $report_hash = json_path $result '$.data.attributes.sha256' |
| 41 | + $report_url = "https://www.virustotal.com/gui/file/$report_hash" |
| 42 | + if ($total -eq 0) { |
| 43 | + info "$app`: Analysis in progress." |
| 44 | + [PSCustomObject] @{ |
| 45 | + 'App.Name' = $app |
| 46 | + 'App.Url' = $url |
| 47 | + 'App.Hash' = $hash |
| 48 | + 'App.HashType' = $null |
| 49 | + 'App.Size' = filesize $fileSize |
| 50 | + 'FileReport.Url' = $report_url |
| 51 | + 'FileReport.Hash' = $report_hash |
| 52 | + 'UrlReport.Url' = $null |
| 53 | + } |
| 54 | + } else { |
| 55 | + $vendorResults = (ConvertFrom-Json((json_path $result '$.data.attributes.last_analysis_results'))).PSObject.Properties.Value |
| 56 | + switch ($unsafe) { |
| 57 | + 0 { success "$app`: $unsafe/$total, see $report_url" } |
| 58 | + 1 { warn "$app`: $unsafe/$total, see $report_url" } |
| 59 | + 2 { warn "$app`: $unsafe/$total, see $report_url" } |
| 60 | + Default { warn "$([char]0x1b)[31m$app`: $unsafe/$total, see $report_url$([char]0x1b)[0m" } |
| 61 | + } |
| 62 | + $maliciousResults = $vendorResults | |
| 63 | + Where-Object -Property category -EQ 'malicious' | |
| 64 | + Select-Object -ExpandProperty engine_name |
| 65 | + $suspiciousResults = $vendorResults | |
| 66 | + Where-Object -Property category -EQ 'suspicious' | |
| 67 | + Select-Object -ExpandProperty engine_name |
| 68 | + [PSCustomObject] @{ |
| 69 | + 'App.Name' = $app |
| 70 | + 'App.Url' = $url |
| 71 | + 'App.Hash' = $hash |
| 72 | + 'App.HashType' = $null |
| 73 | + 'App.Size' = filesize $fileSize |
| 74 | + 'FileReport.Url' = $report_url |
| 75 | + 'FileReport.Hash' = $report_hash |
| 76 | + 'FileReport.Malicious' = if ($maliciousResults) { $maliciousResults } else { 0 } |
| 77 | + 'FileReport.Suspicious' = if ($suspiciousResults) { $suspiciousResults } else { 0 } |
| 78 | + 'FileReport.Timeout' = $timeout |
| 79 | + 'FileReport.Undetected' = $undetected |
| 80 | + 'UrlReport.Url' = $null |
| 81 | + } |
| 82 | + } |
| 83 | + if ($unsafe -gt 0) { |
| 84 | + $Script:exit_code = $exit_code -bor $script:_ERR_UNSAFE |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +function Get-VirusTotalResultByUrl ($url, $app, $api_key) { |
| 89 | + $id = ConvertTo-VirusTotalUrlId $url |
| 90 | + $api_url = "https://www.virustotal.com/api/v3/urls/$id" |
| 91 | + $headers = @{ |
| 92 | + 'Accept' = 'application/json' |
| 93 | + 'x-apikey' = $api_key |
| 94 | + } |
| 95 | + $response = Invoke-WebRequest -Uri $api_url -Method GET -Headers $headers -UseBasicParsing |
| 96 | + $result = $response.Content |
| 97 | + $id = json_path $result '$.data.id' |
| 98 | + $hash = json_path $result '$.data.attributes.last_http_response_content_sha256' 6>$null |
| 99 | + $last_analysis_date = json_path $result '$.data.attributes.last_analysis_date' 6>$null |
| 100 | + $url_report_url = "https://www.virustotal.com/gui/url/$id" |
| 101 | + info "$app`: Url report found." |
| 102 | + if (!$hash) { |
| 103 | + if (!$last_analysis_date) { |
| 104 | + info "$app`: Analysis in progress." |
| 105 | + } else { |
| 106 | + info "$app`: Related file report not found." |
| 107 | + warn "$app`: Manual file upload is required (instead of url submission)." |
| 108 | + } |
| 109 | + [PSCustomObject] @{ |
| 110 | + 'App.Name' = $app |
| 111 | + 'App.Url' = $url |
| 112 | + 'App.Hash' = $null |
| 113 | + 'App.HashType' = $null |
| 114 | + 'FileReport.Url' = $null |
| 115 | + 'UrlReport.Url' = $url_report_url |
| 116 | + 'UrlReport.Hash' = $null |
| 117 | + } |
| 118 | + } else { |
| 119 | + info "$app`: Related file report found." |
| 120 | + [PSCustomObject] @{ |
| 121 | + 'App.Name' = $app |
| 122 | + 'App.Url' = $url |
| 123 | + 'App.Hash' = $null |
| 124 | + 'App.HashType' = $null |
| 125 | + 'FileReport.Url' = $null |
| 126 | + 'UrlReport.Url' = $url_report_url |
| 127 | + 'UrlReport.Hash' = $hash |
| 128 | + } |
| 129 | + } |
| 130 | +} |
| 131 | + |
| 132 | +# Submit-ToVirusTotal |
| 133 | +# - $url: where file to check can be downloaded |
| 134 | +# - $app: Name of the application (used for reporting) |
| 135 | +# - $do_scan: [boolean flag] whether to actually submit to VirusTotal |
| 136 | +# This is a parameter instead of conditionnally calling |
| 137 | +# the function to consolidate the warning message |
| 138 | +# - $api_key: VirusTotal API key |
| 139 | +# - $retrying: [boolean] Optional, for internal use to retry |
| 140 | +# submitting the file after a delay if the rate limit is |
| 141 | +# exceeded, without risking an infinite loop (as stack |
| 142 | +# overflow) if the submission keeps failing. |
| 143 | +function Submit-ToVirusTotal ($url, $app, $do_scan, $api_key, $retrying = $False) { |
| 144 | + if (!$do_scan) { |
| 145 | + warn "$app`: not found`: you can manually submit $url" |
| 146 | + return |
| 147 | + } |
| 148 | + |
| 149 | + try { |
| 150 | + $script:requests += 1 |
| 151 | + |
| 152 | + $encoded_url = [System.Web.HttpUtility]::UrlEncode($url) |
| 153 | + $api_url = 'https://www.virustotal.com/api/v3/urls' |
| 154 | + $content_type = 'application/x-www-form-urlencoded' |
| 155 | + $headers = @{ |
| 156 | + 'Accept' = 'application/json' |
| 157 | + 'x-apikey' = $api_key |
| 158 | + 'Content-Type' = $content_type |
| 159 | + } |
| 160 | + $body = "url=$encoded_url" |
| 161 | + $result = Invoke-WebRequest -Uri $api_url -Method POST -Headers $headers -ContentType $content_type -Body $body -UseBasicParsing |
| 162 | + if ($result.StatusCode -eq 200) { |
| 163 | + $id = ((json_path $result '$.data.id') -split '-')[1] |
| 164 | + $url_report_url = "https://www.virustotal.com/gui/url/$id" |
| 165 | + $fileSize = Get-RemoteFileSize $url |
| 166 | + if ($fileSize -gt 80000000) { |
| 167 | + info "$app`: Remote file size: $(filesize $fileSize). Large files might require manual file upload instead of url submission." |
| 168 | + } |
| 169 | + info "$app`: Analysis in progress." |
| 170 | + [PSCustomObject] @{ |
| 171 | + 'App.Name' = $app |
| 172 | + 'App.Url' = $url |
| 173 | + 'App.Hash' = $null |
| 174 | + 'App.HashType' = $null |
| 175 | + 'FileReport.Url' = $null |
| 176 | + 'UrlReport.Url' = $url_report_url |
| 177 | + 'UrlReport.Hash' = $null |
| 178 | + } |
| 179 | + return |
| 180 | + } |
| 181 | + |
| 182 | + # EAFP: submission failed -> sleep, then retry |
| 183 | + if (!$retrying) { |
| 184 | + if (!$script:explained_rate_limit_sleeping) { |
| 185 | + info 'VirusTotal API has rate limits. Waiting between requests...' |
| 186 | + $script:explained_rate_limit_sleeping = $True |
| 187 | + } |
| 188 | + Start-Sleep -s (60 + $script:requests) |
| 189 | + Submit-ToVirusTotal $url $app $do_scan $api_key $True |
| 190 | + } else { |
| 191 | + warn "$app`: VirusTotal submission of $url failed`:`n" + |
| 192 | + "`tAPI returned $($result.StatusCode) after retrying" |
| 193 | + } |
| 194 | + } catch [Exception] { |
| 195 | + warn "$app`: VirusTotal submission failed`: $($_.Exception.Message)" |
| 196 | + return |
| 197 | + } |
| 198 | +} |
| 199 | + |
| 200 | +function Get-VirusTotalApiKey { |
| 201 | + $api_key = get_config VIRUSTOTAL_API_KEY |
| 202 | + if (!$api_key) { |
| 203 | + abort ("VirusTotal API key is not configured`n" + |
| 204 | + " You could get one from https://www.virustotal.com/gui/my-apikey and set with`n" + |
| 205 | + " scoop config virustotal_api_key <API key>") $_ERR_NO_API_KEY |
| 206 | + } |
| 207 | + return $api_key |
| 208 | +} |
| 209 | + |
| 210 | +function virustotal_check_app($app, $manifest, $architecture, $api_key, $scan) { |
| 211 | + [int]$index = 0 |
| 212 | + $urls = script:url $manifest $architecture |
| 213 | + $urls | ForEach-Object { |
| 214 | + $url = $_ |
| 215 | + $index++ |
| 216 | + if ($urls.GetType().IsArray) { |
| 217 | + info "$app`: url $index" |
| 218 | + } |
| 219 | + $hash = hash_for_url $manifest $url $architecture |
| 220 | + |
| 221 | + try { |
| 222 | + $isHashUnsupported = $false |
| 223 | + if ($hash -match '(?<algo>[^:]+):(?<hash>.*)') { |
| 224 | + $algo = $matches.algo |
| 225 | + $hash = $matches.hash |
| 226 | + if ($matches.algo -inotin 'md5', 'sha1', 'sha256') { |
| 227 | + $hash = $null |
| 228 | + $isHashUnsupported = $true |
| 229 | + warn "$app`: Unsupported hash $($matches.algo). Will search by url instead." |
| 230 | + } |
| 231 | + } elseif ($hash) { |
| 232 | + $algo = 'sha256' |
| 233 | + } |
| 234 | + if ($hash) { |
| 235 | + $file_report = Get-VirusTotalResultByHash $hash $url $app $api_key |
| 236 | + $file_report.'App.HashType' = $algo |
| 237 | + $file_report |
| 238 | + return |
| 239 | + } elseif (!$isHashUnsupported) { |
| 240 | + warn "$app`: Hash not found. Will search by url instead." |
| 241 | + } |
| 242 | + } catch [Exception] { |
| 243 | + $script:exit_code = $exit_code -bor $script:_ERR_EXCEPTION |
| 244 | + if ($_.Exception.Response.StatusCode -eq 404) { |
| 245 | + $file_report_not_found = $true |
| 246 | + warn "$app`: File report not found. Will search by url instead." |
| 247 | + } else { |
| 248 | + warn "$app`: VirusTotal file report query failed`: $($_.Exception.Message)" |
| 249 | + if ($_.Exception.Response) { |
| 250 | + warn "`tAPI returned $($_.Exception.Response.StatusCode)" |
| 251 | + } |
| 252 | + return |
| 253 | + } |
| 254 | + } |
| 255 | + |
| 256 | + try { |
| 257 | + $url_report = Get-VirusTotalResultByUrl $url $app $api_key |
| 258 | + $url_report.'App.Hash' = $hash |
| 259 | + $url_report.'App.HashType' = $matches['algo'] |
| 260 | + if ($url_report.'UrlReport.Hash' -and ($file_report_not_found -eq $true) -and $hash) { |
| 261 | + try { |
| 262 | + $file_report = Get-VirusTotalResultByHash $url_report.'UrlReport.Hash' $url $app $api_key |
| 263 | + if ($file_report.'FileReport.Hash' -ieq $matches['hash']) { |
| 264 | + $file_report.'App.HashType' = $matches['algo'] |
| 265 | + $file_report.'UrlReport.Url' = $url_report.'UrlReport.Url' |
| 266 | + return $file_report |
| 267 | + } |
| 268 | + } catch { |
| 269 | + warn "$app`: Unable to get file report for $($url_report.'UrlReport.Hash')" |
| 270 | + } |
| 271 | + } |
| 272 | + if (!$url_report.'UrlReport.Hash') { |
| 273 | + Submit-ToVirusTotal $url $app $scan $api_key |
| 274 | + return $url_report |
| 275 | + } |
| 276 | + } catch [Exception] { |
| 277 | + $script:exit_code = $exit_code -bor $script:_ERR_EXCEPTION |
| 278 | + if ($_.Exception.Response.StatusCode -eq 404) { |
| 279 | + Submit-ToVirusTotal $url $app $scan $api_key |
| 280 | + return |
| 281 | + } else { |
| 282 | + warn "$app`: VirusTotal URL report query failed`: $($_.Exception.Message)" |
| 283 | + if ($_.Exception.Response) { |
| 284 | + warn "`tAPI returned $($_.Exception.Response.StatusCode)" |
| 285 | + } |
| 286 | + return |
| 287 | + } |
| 288 | + } |
| 289 | + |
| 290 | + try { |
| 291 | + $file_report = Get-VirusTotalResultByHash $url_report.'UrlReport.Hash' $url $app $api_key |
| 292 | + $file_report.'App.Hash' = $hash |
| 293 | + $file_report.'App.HashType' = $matches['algo'] |
| 294 | + $file_report.'UrlReport.Url' = $url_report.'UrlReport.Url' |
| 295 | + $file_report |
| 296 | + warn "$app`: Unable to check hash match for $url" |
| 297 | + } catch [Exception] { |
| 298 | + $script:exit_code = $exit_code -bor $script:_ERR_EXCEPTION |
| 299 | + if ($_.Exception.Response.StatusCode -eq 404) { |
| 300 | + Submit-ToVirusTotal $url $app $scan $api_key |
| 301 | + $url_report |
| 302 | + } else { |
| 303 | + warn "$app`: VirusTotal file report query failed`: $($_.Exception.Message)" |
| 304 | + if ($_.Exception.Response) { |
| 305 | + warn "`tAPI returned $($_.Exception.Response.StatusCode)" |
| 306 | + } |
| 307 | + return |
| 308 | + } |
| 309 | + } |
| 310 | + } |
| 311 | +} |
0 commit comments