Skip to content

Commit 91a02ce

Browse files
pcramar15ch13
authored andcommitted
Followup to PR #1918: scoop-virustotal.ps1 fixes (#1934)
* Fix interpretation of response's status code to detect redirections * Improve documentation of virustotal subcommand - usage & configuration of virustotal_api_key - special parameter '*' to test all installed apps - make necessity of having a virustotal_api_key for --scan explicit - show that it's possible to check several packages at once * Never use virustotal_api_key to query if a package is safe The URL in the code wasn't an API end-point anyway. * Refactor logic to warn user about apps unknown to VirusTotal * Warn once when virustotal_api_key's absence prevents VirusTotal submission This is preparation for changes to come in the package submission logic. * Use API to submit download link to VirusTotal, rate limited in EAFP fashion This is a roundabout way to get the file to be scanned without having to download & upload it ourselves. Rate limiting is implemented using EAFP: if submission fails, we wait at least 60s before retrying at most once. * Color undecided VirusTotal information the same way as `dangerous' files If the scanning is still in progress, VirusTotal returns 0 malicious, 0 suspicious and 0 undetected. Err on the safe side and color this the same way as `dangerous' files. * Remove requirement to only verify installed apps The initial use case for this feature was to scan packages to avoid installing dangerous apps. Assuming they are infected, we want if possible to avoid downloading them at all. * Check dependencies with VirusTotal, too (by default) * Manually apply `Lint: PSAvoidUsingCmdletAliases' (see e1bb1e9, #2075) This is to avoid conflicts when merging lukesampson:master * Explain applist's return value transformation: drop `global' flag for each app * Move variable declarations and apps list generation to the top * Reformat code and comply to linted function names * Reduce nesting, remove hacky hash/url retrieval * Remove $global variables * Fix regression bug in Search-VirusTotal() * Remove applist() because it's irrelevant if app is installed globally
1 parent 803d0bc commit 91a02ce

File tree

1 file changed

+123
-104
lines changed

1 file changed

+123
-104
lines changed

libexec/scoop-virustotal.ps1

+123-104
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
# Usage: scoop virustotal <app> [options]
1+
# Usage: scoop virustotal [* | app1 app2 ...] [options]
22
# Summary: Look for app's hash on virustotal.com
33
# Help: Look for app's hash (MD5, SHA1 or SHA256) on virustotal.com
44
#
5+
# Use a single '*' for app to check all installed apps.
6+
#
57
# The download's hash is also a key to access VirusTotal's scan results.
68
# This allows to check the safety of the files without even downloading
79
# them in many cases. If the hash is unknown to VirusTotal, the
810
# download link is printed to submit it to VirusTotal.
911
#
12+
# If you have signed up to VirusTotal's community, you have an API key
13+
# that this script can use to submit unknown packages for inspection
14+
# if you use the `--scan' flag. Tell scoop about your API key with:
15+
#
16+
# scoop config virustotal_api_key <your API key: 64 lower case hex digits>
17+
#
1018
# Exit codes:
1119
# 0 -> success
1220
# 1 -> problem parsing arguments
@@ -21,7 +29,10 @@
2129
# Options:
2230
# -a, --arch <32bit|64bit> Use the specified architecture, if the app supports it
2331
# -s, --scan For packages where VirusTotal has no information, send download URL
24-
# for analysis (and future retrieval)
32+
# for analysis (and future retrieval). This requires you to configure
33+
# your virustotal_api_key.
34+
# -n, --no-depends By default, all dependencies are checked, too. This flag allows
35+
# to avoid it.
2536

2637
. "$psscriptroot\..\lib\core.ps1"
2738
. "$psscriptroot\..\lib\help.ps1"
@@ -30,26 +41,49 @@
3041
. "$psscriptroot\..\lib\buckets.ps1"
3142
. "$psscriptroot\..\lib\json.ps1"
3243
. "$psscriptroot\..\lib\config.ps1"
44+
. "$psscriptroot\..\lib\decompress.ps1"
45+
. "$psscriptroot\..\lib\install.ps1"
46+
. "$psscriptroot\..\lib\depends.ps1"
3347

3448
reset_aliases
3549

36-
$opt, $apps, $err = getopt $args 'a:s' @('arch=', 'scan')
50+
$opt, $apps, $err = getopt $args 'a:sn' @('arch=', 'scan', 'no-depends')
3751
if($err) { "scoop virustotal: $err"; exit 1 }
52+
if(!$apps) { my_usage; exit 1 }
3853
$architecture = ensure_architecture ($opt.a + $opt.arch)
3954

55+
if(is_scoop_outdated) { scoop update }
56+
57+
$apps_param = $apps
58+
59+
if($apps_param -eq '*') {
60+
$apps = installed_apps $false
61+
$apps += installed_apps $true
62+
}
63+
64+
if (!$opt.n -and !$opt."no-depends") {
65+
$apps = install_order $apps $architecture
66+
}
67+
4068
$_ERR_UNSAFE = 2
4169
$_ERR_EXCEPTION = 4
4270
$_ERR_NO_INFO = 8
4371

4472
$exit_code = 0
4573

74+
# Global flag to warn only once about missing API key:
75+
$warned_no_api_key = $False
76+
77+
# Global flag to explain only once about sleep between requests
78+
$explained_rate_limit_sleeping = $False
79+
80+
# Requests counter to slow down requests submitted to VirusTotal as
81+
# script execution progresses
82+
$requests = 0
83+
4684
Function Get-VirusTotalResult($hash, $app) {
4785
$hash = $hash.ToLower()
4886
$url = "https://www.virustotal.com/ui/files/$hash"
49-
$api_key = get_config("virustotal_api_key")
50-
if ($api_key) {
51-
$url += '?apikey=' + $api_key
52-
}
5387
$result = (new-object net.webclient).downloadstring($url)
5488
$stats = json_path $result '$.data.attributes.last_analysis_stats'
5589
$malicious = json_path $stats '$.malicious'
@@ -58,10 +92,10 @@ Function Get-VirusTotalResult($hash, $app) {
5892
$unsafe = [int]$malicious + [int]$suspicious
5993
$see_url = "see https://www.virustotal.com/#/file/$hash/detection"
6094
switch ($unsafe) {
61-
0 {$fg = "DarkGreen"}
62-
1 {$fg = "DarkYellow"}
63-
2 {$fg = "Yellow"}
64-
default {$fg = "Red"}
95+
0 { if ($undetected -eq 0) { $fg = "Yellow" } else { $fg = "DarkGreen" } }
96+
1 { $fg = "DarkYellow" }
97+
2 { $fg = "Yellow" }
98+
default { $fg = "Red" }
6599
}
66100
write-host -f $fg "$app`: $unsafe/$undetected, $see_url"
67101
if($unsafe -gt 0) {
@@ -70,23 +104,21 @@ Function Get-VirusTotalResult($hash, $app) {
70104
return 0
71105
}
72106

73-
Function Search-VirusTotal ($h, $app) {
74-
if ($h -match "(?<algo>[^:]+):(?<hash>.*)") {
75-
$hash = $matches["hash"]
76-
if ($matches["algo"] -match "(md5|sha1|sha256)") {
107+
Function Search-VirusTotal ($hash, $app) {
108+
if ($hash -match '(?<algo>[^:]+):(?<hash>.*)') {
109+
$hash = $matches['hash']
110+
if ($matches['algo'] -match '(md5|sha1|sha256)') {
77111
return Get-VirusTotalResult $hash $app
78-
}
79-
else {
80-
warn("$app`: Unsupported hash $($matches['algo']). VirusTotal needs md5, sha1 or sha256.")
112+
} else {
113+
warn "$app`: Unsupported hash $($matches['algo']). VirusTotal needs md5, sha1 or sha256."
81114
return $_ERR_NO_INFO
82115
}
83116
}
84-
else {
85-
return Get-VirusTotalResult $h $app
86-
}
117+
118+
return Get-VirusTotalResult $hash $app
87119
}
88120

89-
Function Get-RedirectedUrl {
121+
Function Submit-RedirectedUrl {
90122
# Follow up to one level of HTTP redirection
91123
#
92124
# Copied from http://www.powershellmagazine.com/2013/01/29/pstip-retrieve-a-redirected-url/
@@ -99,7 +131,7 @@ Function Get-RedirectedUrl {
99131
$request = [System.Net.WebRequest]::Create($url)
100132
$request.AllowAutoRedirect=$false
101133
$response=$request.GetResponse()
102-
if ($response.StatusCode -eq "Found") {
134+
if (([int]$response.StatusCode -ge 300) -and ([int]$response.StatusCode -lt 400)) {
103135
$redir = $response.GetResponseHeader("Location")
104136
}
105137
else {
@@ -109,109 +141,96 @@ Function Get-RedirectedUrl {
109141
return $redir
110142
}
111143

112-
Function Submit-ToVirusTotal ($url, $app, $do_scan) {
113-
if ($do_scan) {
114-
try {
115-
# Follow redirections (for e.g. sourceforge URLs) because
116-
# VirusTotal analyzes only "direct" download links
117-
$url = $url.Split("#").GetValue(0)
118-
$new_redir = $url
119-
do {
120-
$orig_redir = $new_redir
121-
$new_redir = Get-RedirectedUrl $orig_redir
122-
} while ($orig_redir -ne $new_redir)
123-
$uri = "https://www.virustotal.com/ui/urls?url=$new_redir"
124-
$api_key = get_config("virustotal_api_key")
125-
if ($api_key) {
126-
$url += '&apikey=' + $api_key
127-
}
128-
Invoke-RestMethod -Method POST -Uri $uri | Out-Null
129-
$submitted = $True
130-
} catch [Exception] {
131-
warn("$app`: VirusTotal submission failed`: $($_.Exception.Message)")
132-
$submitted = $False
133-
return
134-
}
135-
}
136-
else {
137-
$submitted = $False
138-
}
139-
if ($submitted) {
140-
warn("$app`: not found`: submitted $url")
144+
# Submit-ToVirusTotal
145+
# - $url: where file to check can be downloaded
146+
# - $app: Name of the application (used for reporting)
147+
# - $do_scan: [boolean flag] whether to actually submit to VirusTotal
148+
# This is a parameter instead of conditionnally calling
149+
# the function to consolidate the warning message
150+
# - $retrying: [boolean] Optional, for internal use to retry
151+
# submitting the file after a delay if the rate limit is
152+
# exceeded, without risking an infinite loop (as stack
153+
# overflow) if the submission keeps failing.
154+
Function Submit-ToVirusTotal ($url, $app, $do_scan, $retrying=$False) {
155+
$api_key = get_config("virustotal_api_key")
156+
if ($do_scan -and !$api_key -and !$warned_no_api_key) {
157+
$warned_no_api_key = $true
158+
info "Submitting unknown apps needs a VirusTotal API key. " +
159+
"Set it up with`n`tscoop config virustotal_api_key <API key>"
160+
141161
}
142-
else {
143-
warn("$app`: not found`: manually submit $url")
162+
if (!$do_scan -or !$api_key) {
163+
warn "$app`: not found`: manually submit $url"
164+
return
144165
}
145-
}
146-
147-
if(!$apps) {
148-
my_usage; exit 1
149-
}
150-
151-
if(is_scoop_outdated) {
152-
scoop update
153-
}
154166

155-
$apps_param = $apps
167+
try {
168+
# Follow redirections (for e.g. sourceforge URLs) because
169+
# VirusTotal analyzes only "direct" download links
170+
$url = $url.Split("#").GetValue(0)
171+
$new_redir = $url
172+
do {
173+
$orig_redir = $new_redir
174+
$new_redir = Submit-RedirectedUrl $orig_redir
175+
} while ($orig_redir -ne $new_redir)
176+
$requests += 1
177+
$result = Invoke-WebRequest -Uri "https://www.virustotal.com/vtapi/v2/url/scan" -Body @{apikey=$api_key;url=$new_redir} -Method Post -UseBasicParsing
178+
$submitted = $result.StatusCode -eq 200
179+
if ($submitted) {
180+
warn "$app`: not found`: submitted $url"
181+
return
182+
}
156183

157-
if($apps_param -eq '*') {
158-
$apps = applist (installed_apps $false) $false
159-
} else {
160-
$apps = ensure_all_installed $apps_param
184+
# EAFP: submission failed -> sleep, then retry
185+
if (!$retrying) {
186+
if (!$explained_rate_limit_sleeping) {
187+
$explained_rate_limit_sleeping = $True
188+
info "Sleeping 60+ seconds between requests due to VirusTotal's 4/min limit"
189+
}
190+
Start-Sleep -s (60 + $requests)
191+
Submit-ToVirusTotal $new_redir $app $do_scan $True
192+
} else {
193+
warn "$app`: VirusTotal submission of $url failed`:`n" +
194+
"`tAPI returned $($result.StatusCode) after retrying"
195+
}
196+
} catch [Exception] {
197+
warn "$app`: VirusTotal submission failed`: $($_.Exception.Message)"
198+
return
199+
}
161200
}
162201

163-
$requests = 0
164-
165202
$apps | ForEach-Object {
166-
($app, $global) = $_
203+
$app = $_
204+
# write-host $app
167205
$manifest, $bucket = find_manifest $app
168206
if(!$manifest) {
169207
$exit_code = $exit_code -bor $_ERR_NO_INFO
170-
warn("$app`: manifest not found")
171-
return
172-
}
173-
174-
$hash = hash $manifest $architecture
175-
if (!$hash) {
176-
$exit_code = $exit_code -bor $_ERR_NO_INFO
177-
warn("$app`: hash not found in manifest")
208+
warn "$app`: manifest not found"
178209
return
179210
}
180211

181-
$url = url $manifest $architecture
212+
$urls = url $manifest $architecture
213+
$urls | ForEach-Object {
214+
$url = $_
215+
$hash = hash_for_url $manifest $url $architecture
182216

183-
# Hacky way to see if $hash is an array (i.e. there was a list of
184-
# hashes in the manifest) or a string (i.e. there was 1! hash in
185-
# the manifest).
186-
if ($hash[0].Length -eq 1) {
187-
# Wrap download URL in array to traverse it in lockstep with
188-
# the loop over the hash.
189-
$url = @($url)
190-
}
191-
192-
$hash | ForEach-Object { $i = 0 } {
193-
$requests += 1
194-
if ($requests -eq 5) {
195-
info("Sleeping 60+ seconds between requests due to VirusTotal's 4/min limit")
196-
}
197-
if ($requests -gt 4) {
198-
Start-Sleep -s (50 + ($requests * 2))
199-
}
200217
try {
201-
$exit_code = $exit_code -bor (Search-VirusTotal $_ $app)
218+
if($hash) {
219+
$exit_code = $exit_code -bor (Search-VirusTotal $hash $app)
220+
} else {
221+
warn "$app`: Can't find hash for $url"
222+
}
202223
} catch [Exception] {
203224
$exit_code = $exit_code -bor $_ERR_EXCEPTION
204225
if ($_.Exception.Message -like "*(404)*") {
205-
Submit-ToVirusTotal $url[$i] $app ($opt.scan -or $opt.s)
206-
}
207-
else {
226+
Submit-ToVirusTotal $url $app ($opt.scan -or $opt.s)
227+
} else {
208228
if ($_.Exception.Message -match "\(204|429\)") {
209-
abort "$app`: VirusTotal request failed`: $($_.Exception.Message)" $exit_code
229+
abort "$app`: VirusTotal request failed`: $($_.Exception.Message)", $exit_code
210230
}
211-
warn("$app`: VirusTotal request failed`: $($_.Exception.Message)")
231+
warn "$app`: VirusTotal request failed`: $($_.Exception.Message)"
212232
}
213233
}
214-
$i = $i + 1
215234
}
216235
}
217236

0 commit comments

Comments
 (0)