Skip to content

Commit 08d22f2

Browse files
committed
feat(virustotal): integrate VirusTotal checks into download and install processes
1 parent daefa4a commit 08d22f2

File tree

6 files changed

+104
-20
lines changed

6 files changed

+104
-20
lines changed

lib/download.ps1

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# Description: Functions for downloading files
22

3+
. "$PSScriptRoot\..\lib\core.ps1"
4+
. "$PSScriptRoot\..\lib\virustotal.ps1"
5+
36
## Meta downloader
47

5-
function Invoke-ScoopDownload ($app, $version, $manifest, $bucket, $architecture, $dir, $use_cache = $true, $check_hash = $true) {
8+
function Invoke-ScoopDownload ($app, $version, $manifest, $bucket, $architecture, $dir, $use_cache = $true, $check_hash = $true, $check_virustotal = $false) {
69
# we only want to show this warning once
710
if (!$use_cache) { warn 'Cache is being ignored.' }
811

@@ -12,11 +15,40 @@ function Invoke-ScoopDownload ($app, $version, $manifest, $bucket, $architecture
1215
# can be multiple cookies: they will be used for all HTTP requests.
1316
$cookies = $manifest.cookie
1417

18+
# Pre-check all URLs with VirusTotal before downloading
19+
$api_key = Get-VirusTotalApiKey
20+
21+
$safe_urls = @()
22+
if ($check_virustotal) {
23+
foreach ($url in $urls) {
24+
$hash = hash_for_url $manifest $url $architecture
25+
$reports = Check-VirusTotalUrl $app $url $hash $api_key $false
26+
$reports | ForEach-Object {
27+
$file_report = $_
28+
$url = $file_report.'App.Url'
29+
30+
$maliciousResults = $file_report.'FileReport.Malicious'
31+
$suspiciousResults = $file_report.'FileReport.Suspicious'
32+
if ($maliciousResults -gt 0 -or $suspiciousResults -gt 0) {
33+
warn "$app`: One or more VirusTotal checks failed. Aborting before download."
34+
} else {
35+
info "$app`: Safe URL: $url"
36+
$safe_urls += $url
37+
}
38+
}
39+
}
40+
if ($safe_urls.Count -eq 0) {
41+
abort "No URL passed VirusTotal check for $app. Aborting before download."
42+
}
43+
} else {
44+
$safe_urls = $urls
45+
}
46+
1547
# download first
1648
if (Test-Aria2Enabled) {
1749
Invoke-CachedAria2Download $app $version $manifest $architecture $dir $cookies $use_cache $check_hash
1850
} else {
19-
foreach ($url in $urls) {
51+
foreach ($url in $safe_urls) {
2052
$fname = url_filename $url
2153

2254
try {
@@ -45,7 +77,7 @@ function Invoke-ScoopDownload ($app, $version, $manifest, $bucket, $architecture
4577
}
4678
}
4779

48-
return $urls.ForEach({ url_filename $_ })
80+
return $safe_urls.ForEach({ url_filename $_ })
4981
}
5082

5183
## [System.Net] downloader
@@ -457,9 +489,9 @@ function Invoke-CachedAria2Download ($app, $version, $manifest, $architecture, $
457489
warn "Download failed! (Error $lastexitcode) $(aria_exit_code $lastexitcode)"
458490
warn $urlstxt_content
459491
warn $aria2
460-
warn $(new_issue_msg $app $bucket "download via aria2 failed")
492+
warn $(new_issue_msg $app $bucket 'download via aria2 failed')
461493

462-
Write-Host "Fallback to default downloader ..."
494+
Write-Host 'Fallback to default downloader ...'
463495

464496
try {
465497
foreach ($url in $urls) {
@@ -666,7 +698,7 @@ function get_magic_bytes_pretty($file, $glue = ' ') {
666698
return (get_magic_bytes $file | ForEach-Object { $_.ToString('x2') }) -join $glue
667699
}
668700

669-
Function Get-RemoteFileSize ($Uri) {
701+
function Get-RemoteFileSize ($Uri) {
670702
$response = Invoke-WebRequest -Uri $Uri -Method HEAD -UseBasicParsing
671703
if (!$response.Headers.StatusCode) {
672704
$response.Headers.'Content-Length' | ForEach-Object { [int]$_ }
@@ -689,13 +721,13 @@ function url_remote_filename($url) {
689721
# this function extracts the original filename from the URL.
690722
$uri = (New-Object URI $url)
691723
$basename = Split-Path $uri.PathAndQuery -Leaf
692-
If ($basename -match '.*[?=]+([\w._-]+)') {
724+
if ($basename -match '.*[?=]+([\w._-]+)') {
693725
$basename = $matches[1]
694726
}
695-
If (($basename -notlike '*.*') -or ($basename -match '^[v.\d]+$')) {
727+
if (($basename -notlike '*.*') -or ($basename -match '^[v.\d]+$')) {
696728
$basename = Split-Path $uri.AbsolutePath -Leaf
697729
}
698-
If (($basename -notlike '*.*') -and ($uri.Fragment -ne '')) {
730+
if (($basename -notlike '*.*') -and ($uri.Fragment -ne '')) {
699731
$basename = $uri.Fragment.Trim('/', '#')
700732
}
701733
return $basename

lib/install.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ function nightly_version($quiet = $false) {
55
return "nightly-$(Get-Date -Format 'yyyyMMdd')"
66
}
77

8-
function install_app($app, $architecture, $global, $suggested, $use_cache = $true, $check_hash = $true) {
8+
function install_app($app, $architecture, $global, $suggested, $use_cache = $true, $check_hash = $true, $check_virustotal = $false) {
99
$app, $manifest, $bucket, $url = Get-Manifest $app
1010

1111
if (!$manifest) {
@@ -49,7 +49,7 @@ function install_app($app, $architecture, $global, $suggested, $use_cache = $tru
4949
$original_dir = $dir # keep reference to real (not linked) directory
5050
$persist_dir = persistdir $app $global
5151

52-
$fname = Invoke-ScoopDownload $app $version $manifest $bucket $architecture $dir $use_cache $check_hash
52+
$fname = Invoke-ScoopDownload $app $version $manifest $bucket $architecture $dir $use_cache $check_hash $check_virustotal
5353
Invoke-Extraction -Path $dir -Name $fname -Manifest $manifest -ProcessorArchitecture $architecture
5454
Invoke-HookScript -HookType 'pre_install' -Manifest $manifest -ProcessorArchitecture $architecture
5555

lib/virustotal.ps1

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
. "$PSScriptRoot\json.ps1" # 'json_path'
2-
. "$PSScriptRoot\download.ps1" # 'hash_for_url'
2+
# . "$PSScriptRoot\download.ps1" # 'hash_for_url'
33

44
# Error codes
55
$script:_ERR_UNSAFE = 2
@@ -314,3 +314,16 @@ function virustotal_check_app($app, $manifest, $architecture, $api_key, $scan) {
314314
Check-VirusTotalUrl $app $url $hash $api_key $scan
315315
}
316316
}
317+
318+
function hash_for_url($manifest, $url, $arch) {
319+
$hashes = @(hash $manifest $arch) | Where-Object { $_ -ne $null }
320+
321+
if ($hashes.length -eq 0) { return $null }
322+
323+
$urls = @(script:url $manifest $arch)
324+
325+
$index = [array]::IndexOf($urls, $url)
326+
if ($index -eq -1) { abort "Couldn't find hash in manifest for '$url'." }
327+
328+
@($hashes)[$index]
329+
}

libexec/scoop-config.ps1

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@
9292
# API key used for uploading/scanning files using virustotal.
9393
# See: 'https://support.virustotal.com/hc/en-us/articles/115002088769-Please-give-me-an-API-key'
9494
#
95+
# use_virustotal: $true|$false
96+
# When set to $true, Scoop will always use VirusTotal to scan files after downloading.
97+
#
9598
# cat_style:
9699
# When set to a non-empty string, Scoop will use 'bat' to display the manifest for
97100
# the `scoop cat` command and while doing manifest review. This requires 'bat' to be

libexec/scoop-install.ps1

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
# -i, --independent Don't install dependencies automatically
2525
# -k, --no-cache Don't use the download cache
2626
# -s, --skip-hash-check Skip hash validation (use with caution!)
27+
# -w, --virustotal-check Check the download against VirusTotal (may be slow)
2728
# -u, --no-update-scoop Don't update Scoop before installing if it's outdated
2829
# -a, --arch <32bit|64bit|arm64> Use the specified architecture, if the app supports it
2930

@@ -43,11 +44,12 @@ if (get_config USE_SQLITE_CACHE) {
4344
. "$PSScriptRoot\..\lib\database.ps1"
4445
}
4546

46-
$opt, $apps, $err = getopt $args 'giksua:' 'global', 'independent', 'no-cache', 'skip-hash-check', 'no-update-scoop', 'arch='
47+
$opt, $apps, $err = getopt $args 'gikswua:' 'global', 'independent', 'no-cache', 'skip-hash-check', 'virustotal-check', 'no-update-scoop', 'arch='
4748
if ($err) { error "scoop install: $err"; exit 1 }
4849

4950
$global = $opt.g -or $opt.global
5051
$check_hash = !($opt.s -or $opt.'skip-hash-check')
52+
$check_virustotal = $opt.w -or $opt.'virustotal-check' -or (get_config USE_VIRUSTOTAL $false)
5153
$independent = $opt.i -or $opt.independent
5254
$use_cache = !($opt.k -or $opt.'no-cache')
5355
$architecture = Get-DefaultArchitecture
@@ -132,7 +134,7 @@ if ((Test-Aria2Enabled) -and (get_config 'aria2-warning-enabled' $true)) {
132134
warn "Should it cause issues, run 'scoop config aria2-enabled false' to disable it."
133135
warn "To disable this warning, run 'scoop config aria2-warning-enabled false'."
134136
}
135-
$apps | ForEach-Object { install_app $_ $architecture $global $suggested $use_cache $check_hash }
137+
$apps | ForEach-Object { install_app $_ $architecture $global $suggested $use_cache $check_hash $check_virustotal }
136138

137139
show_suggestions $suggested
138140

libexec/scoop-update.ps1

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# -i, --independent Don't install dependencies automatically
1212
# -k, --no-cache Don't use the download cache
1313
# -s, --skip-hash-check Skip hash validation (use with caution!)
14+
# -w, --virustotal-check Check the download against VirusTotal (may be slow)
1415
# -q, --quiet Hide extraneous messages
1516
# -a, --all Update all apps (alternative to '*')
1617

@@ -29,11 +30,12 @@ if (get_config USE_SQLITE_CACHE) {
2930
. "$PSScriptRoot\..\lib\database.ps1"
3031
}
3132

32-
$opt, $apps, $err = getopt $args 'gfiksqa' 'global', 'force', 'independent', 'no-cache', 'skip-hash-check', 'quiet', 'all'
33+
$opt, $apps, $err = getopt $args 'gfikswqa' 'global', 'force', 'independent', 'no-cache', 'skip-hash-check', 'virustotal-check', 'quiet', 'all'
3334
if ($err) { error "scoop update: $err"; exit 1 }
3435
$global = $opt.g -or $opt.global
3536
$force = $opt.f -or $opt.force
3637
$check_hash = !($opt.s -or $opt.'skip-hash-check')
38+
$check_virustotal = $opt.w -or $opt.'virustotal-check' -or (get_config USE_VIRUSTOTAL $false)
3739
$use_cache = !($opt.k -or $opt.'no-cache')
3840
$quiet = $opt.q -or $opt.quiet
3941
$independent = $opt.i -or $opt.independent
@@ -258,7 +260,7 @@ function Sync-Bucket {
258260
}
259261
}
260262

261-
function update($app, $global, $quiet = $false, $independent, $suggested, $use_cache = $true, $check_hash = $true) {
263+
function update($app, $global, $quiet = $false, $independent, $suggested, $use_cache = $true, $check_hash = $true, $check_virustotal = $false) {
262264
$old_version = Select-CurrentVersion -AppName $app -Global:$global
263265
$old_manifest = installed_manifest $app $old_version $global
264266
$install = install_info $app $old_version $global
@@ -300,6 +302,38 @@ function update($app, $global, $quiet = $false, $independent, $suggested, $use_c
300302
}
301303
#endregion Workaround for #2952
302304

305+
# can be multiple urls: if there are, then installer should go first to make 'installer.args' section work
306+
$urls = @(script:url $manifest $architecture)
307+
308+
# Pre-check all URLs with VirusTotal before downloading
309+
$api_key = Get-VirusTotalApiKey
310+
311+
$safe_urls = @()
312+
if ($check_virustotal) {
313+
foreach ($url in $urls) {
314+
$hash = hash_for_url $manifest $url $architecture
315+
$reports = Check-VirusTotalUrl $app $url $hash $api_key $false
316+
$reports | ForEach-Object {
317+
$file_report = $_
318+
$url = $file_report.'App.Url'
319+
320+
$maliciousResults = $file_report.'FileReport.Malicious'
321+
$suspiciousResults = $file_report.'FileReport.Suspicious'
322+
if ($maliciousResults -gt 0 -or $suspiciousResults -gt 0) {
323+
warn "$app`: One or more VirusTotal checks failed. Aborting before download."
324+
} else {
325+
info "$app`: Safe URL: $url"
326+
$safe_urls += $url
327+
}
328+
}
329+
}
330+
if ($safe_urls.Count -eq 0) {
331+
abort "No URL passed VirusTotal check for $app. Aborting before download."
332+
}
333+
} else {
334+
$safe_urls = $urls
335+
}
336+
303337
# region Workaround
304338
# Workaround for https://github.com/ScoopInstaller/Scoop/issues/2220 until install is refactored
305339
# Remove and replace whole region after proper fix
@@ -309,7 +343,7 @@ function update($app, $global, $quiet = $false, $independent, $suggested, $use_c
309343
} else {
310344
$urls = script:url $manifest $architecture
311345

312-
foreach ($url in $urls) {
346+
foreach ($url in $safe_urls) {
313347
Invoke-CachedDownload $app $version $url $null $manifest.cookie $true
314348

315349
if ($check_hash) {
@@ -376,12 +410,12 @@ function update($app, $global, $quiet = $false, $independent, $suggested, $use_c
376410
}
377411

378412
if ($independent) {
379-
install_app $app $architecture $global $suggested $use_cache $check_hash
413+
install_app $app $architecture $global $suggested $use_cache $check_hash $check_virustotal
380414
} else {
381415
# Also add missing dependencies
382416
$apps = @(Get-Dependency $app $architecture) -ne $app
383417
ensure_none_failed $apps
384-
$apps.Where({ !(installed $_) }) + $app | ForEach-Object { install_app $_ $architecture $global $suggested $use_cache $check_hash }
418+
$apps.Where({ !(installed $_) }) + $app | ForEach-Object { install_app $_ $architecture $global $suggested $use_cache $check_hash $check_virustotal}
385419
}
386420
}
387421

@@ -462,7 +496,7 @@ if (-not ($apps -or $all)) {
462496

463497
$suggested = @{}
464498
# $outdated is a list of ($app, $global) tuples
465-
$outdated | ForEach-Object { update @_ $quiet $independent $suggested $use_cache $check_hash }
499+
$outdated | ForEach-Object { update @_ $quiet $independent $suggested $use_cache $check_hash $check_virustotal }
466500
}
467501

468502
exit 0

0 commit comments

Comments
 (0)