Skip to content

Commit 9b102f9

Browse files
committed
refactor(virustotal): move VirusTotal functionality into a lib
1 parent 20a178b commit 9b102f9

File tree

2 files changed

+318
-312
lines changed

2 files changed

+318
-312
lines changed

lib/virustotal.ps1

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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

Comments
 (0)