1
- # Usage: scoop virustotal <app> [options]
1
+ # Usage: scoop virustotal [* | app1 app2 ...] [options]
2
2
# Summary: Look for app's hash on virustotal.com
3
3
# Help: Look for app's hash (MD5, SHA1 or SHA256) on virustotal.com
4
4
#
5
+ # Use a single '*' for app to check all installed apps.
6
+ #
5
7
# The download's hash is also a key to access VirusTotal's scan results.
6
8
# This allows to check the safety of the files without even downloading
7
9
# them in many cases. If the hash is unknown to VirusTotal, the
8
10
# download link is printed to submit it to VirusTotal.
9
11
#
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
+ #
10
18
# Exit codes:
11
19
# 0 -> success
12
20
# 1 -> problem parsing arguments
21
29
# Options:
22
30
# -a, --arch <32bit|64bit> Use the specified architecture, if the app supports it
23
31
# -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.
25
36
26
37
. " $psscriptroot \..\lib\core.ps1"
27
38
. " $psscriptroot \..\lib\help.ps1"
30
41
. " $psscriptroot \..\lib\buckets.ps1"
31
42
. " $psscriptroot \..\lib\json.ps1"
32
43
. " $psscriptroot \..\lib\config.ps1"
44
+ . " $psscriptroot \..\lib\decompress.ps1"
45
+ . " $psscriptroot \..\lib\install.ps1"
46
+ . " $psscriptroot \..\lib\depends.ps1"
33
47
34
48
reset_aliases
35
49
36
- $opt , $apps , $err = getopt $args ' a:s ' @ (' arch=' , ' scan' )
50
+ $opt , $apps , $err = getopt $args ' a:sn ' @ (' arch=' , ' scan' , ' no-depends ' )
37
51
if ($err ) { " scoop virustotal: $err " ; exit 1 }
52
+ if (! $apps ) { my_usage; exit 1 }
38
53
$architecture = ensure_architecture ($opt.a + $opt.arch )
39
54
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
+
40
68
$_ERR_UNSAFE = 2
41
69
$_ERR_EXCEPTION = 4
42
70
$_ERR_NO_INFO = 8
43
71
44
72
$exit_code = 0
45
73
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
+
46
84
Function Get-VirusTotalResult ($hash , $app ) {
47
85
$hash = $hash.ToLower ()
48
86
$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
- }
53
87
$result = (new-object net.webclient).downloadstring($url )
54
88
$stats = json_path $result ' $.data.attributes.last_analysis_stats'
55
89
$malicious = json_path $stats ' $.malicious'
@@ -58,10 +92,10 @@ Function Get-VirusTotalResult($hash, $app) {
58
92
$unsafe = [int ]$malicious + [int ]$suspicious
59
93
$see_url = " see https://www.virustotal.com/#/file/$hash /detection"
60
94
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" }
65
99
}
66
100
write-host -f $fg " $app `: $unsafe /$undetected , $see_url "
67
101
if ($unsafe -gt 0 ) {
@@ -70,23 +104,21 @@ Function Get-VirusTotalResult($hash, $app) {
70
104
return 0
71
105
}
72
106
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)' ) {
77
111
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."
81
114
return $_ERR_NO_INFO
82
115
}
83
116
}
84
- else {
85
- return Get-VirusTotalResult $h $app
86
- }
117
+
118
+ return Get-VirusTotalResult $hash $app
87
119
}
88
120
89
- Function Get -RedirectedUrl {
121
+ Function Submit -RedirectedUrl {
90
122
# Follow up to one level of HTTP redirection
91
123
#
92
124
# Copied from http://www.powershellmagazine.com/2013/01/29/pstip-retrieve-a-redirected-url/
@@ -99,7 +131,7 @@ Function Get-RedirectedUrl {
99
131
$request = [System.Net.WebRequest ]::Create($url )
100
132
$request.AllowAutoRedirect = $false
101
133
$response = $request.GetResponse ()
102
- if ($response.StatusCode -eq " Found " ) {
134
+ if (([ int ] $response.StatusCode -ge 300 ) -and ([ int ] $response .StatusCode -lt 400 ) ) {
103
135
$redir = $response.GetResponseHeader (" Location" )
104
136
}
105
137
else {
@@ -109,109 +141,96 @@ Function Get-RedirectedUrl {
109
141
return $redir
110
142
}
111
143
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`t scoop config virustotal_api_key <API key>"
160
+
141
161
}
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
144
165
}
145
- }
146
-
147
- if (! $apps ) {
148
- my_usage; exit 1
149
- }
150
-
151
- if (is_scoop_outdated) {
152
- scoop update
153
- }
154
166
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
+ }
156
183
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
+ " `t API returned $ ( $result.StatusCode ) after retrying"
195
+ }
196
+ } catch [Exception ] {
197
+ warn " $app `: VirusTotal submission failed`: $ ( $_.Exception.Message ) "
198
+ return
199
+ }
161
200
}
162
201
163
- $requests = 0
164
-
165
202
$apps | ForEach-Object {
166
- ($app , $global ) = $_
203
+ $app = $_
204
+ # write-host $app
167
205
$manifest , $bucket = find_manifest $app
168
206
if (! $manifest ) {
169
207
$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"
178
209
return
179
210
}
180
211
181
- $url = url $manifest $architecture
212
+ $urls = url $manifest $architecture
213
+ $urls | ForEach-Object {
214
+ $url = $_
215
+ $hash = hash_for_url $manifest $url $architecture
182
216
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
- }
200
217
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
+ }
202
223
} catch [Exception ] {
203
224
$exit_code = $exit_code -bor $_ERR_EXCEPTION
204
225
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 {
208
228
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
210
230
}
211
- warn( " $app `: VirusTotal request failed`: $ ( $_.Exception.Message ) " )
231
+ warn " $app `: VirusTotal request failed`: $ ( $_.Exception.Message ) "
212
232
}
213
233
}
214
- $i = $i + 1
215
234
}
216
235
}
217
236
0 commit comments