Files
Scoop/libexec/scoop-virustotal.ps1
Hsiao-nan Cheung ebd8c036fa chore(release): Bump to version 0.5.3 (#6257)
* fix (decompress): `Expand-7zipArchive` only delete temp dir / `$extractDir` if it is empty (#6092)

Co-authored-by: Hsiao-nan Cheung <niheaven@gmail.com>

* refactor(download): Move download-related functions to 'download.ps1' (#6095)

* fix(download): Fallback to default downloader when aria2 fails (#4292)

* fix(commands): Handling broken aliases (#6141)

* fix(shim): properly check `wslpath`/`cygpath` command first (#6114)

Co-authored-by: Hsiao-nan Cheung <niheaven@gmail.com>

* fix(scoop-bucket): Add missing import for `no_junction` envs (#6181)

Signed-off-by: Chawye Hsu <su+git@chawyehsu.com>

* docs(chglog): Update to 0.5.3 (#6258)

* perf(shim): Update kiennq-shim to v3.1.2 (#6261)

* fix(decompress): Replace deprecated 7ZIPEXTRACT_USE_EXTERNAL config (#6327)

Co-authored-by: Hsiao-nan Cheung <niheaven@gmail.com>

* fix(scoop-uninstall): Fix uninstaller does not gain Global state (#6430)

* global arg

* changelog

* refactor(Get-Manifest): Select actual source for manifest (#6142)

* first step

* Revert "first step"

This reverts commit c5907c3e25.

* refactor(Get-Manifest): Select actual source for installed manifest

* rework sub-commands, `scoop-depends` is NOT working at this stage

* URI manifest

* opt

* deprecated manifest

* source of manifests

* source of manifest pt2

- Mark URI(path/URL/UNC/etc.) query as standalone manifest
- Drop `installed` and `available update` items for [query] and [installed] are different sources.

* remove variable preventing I forget it

* scoop-info: fix source of manifest on bucket

* fix `scoop-depends`

* Fix Standalone and Source detection

* fix global install

* Fix scoop-cat, scoop-home

- Query for remote manifest

* scoop-list: info +deprecated

* manifest: Fix first selected manifest

* gramma..

* Fix 61b3259

* length

* fix(scoop-depends-tests): Mocking `USE_EXTERNAL_7ZIP` as $false (#6431)

* fix(scoop-depends-tests): Mocking `USE_EXTERNAL_7ZIP` as $false to avoding error when it is $true

* CHANGELOG

* feat(autoupdate): GitHub predefined hashes support (#6416)

* feat(autoupdate): predefined hash case for GitHub

- Remove `sha256:` prefix in `format_hash()`
- Add GitHub support in `get_hash_for_app()`

Close #6381

* doc(chglog): GitHub auto hash update

* fix(autoupdate): remove prefix only

* docs(CHANGELOG): Update to 0.5.3 (#6432)

* docs(CHANGELOG): Update to 0.5.3

* 6416

---------

Signed-off-by: Chawye Hsu <su+git@chawyehsu.com>
Co-authored-by: Olav Rønnestad Birkeland <6450056+o-l-a-v@users.noreply.github.com>
Co-authored-by: kiennq <kien.n.quang@gmail.com>
Co-authored-by: HUMORCE <humorce@outlook.com>
Co-authored-by: Ryan <sitiom@proton.me>
Co-authored-by: Chawye Hsu <su+git@chawyehsu.com>
Co-authored-by: Bassel Rachid <101208715+basselworkforce@users.noreply.github.com>
Co-authored-by: Wordless Echo <wordless@echo.moe>
2025-08-11 00:38:38 +00:00

386 lines
15 KiB
PowerShell

# Usage: scoop virustotal [* | app1 app2 ...] [options]
# Summary: Look for app's hash or url on virustotal.com
# Help: Look for app's hash or url on virustotal.com
#
# Use a single '*' or the '-a/--all' switch to check all installed apps.
#
# To use this command, you have to sign up to VirusTotal's community,
# and get an API key. Then, tell scoop about your API key with:
#
# scoop config virustotal_api_key <your API key: 64 lower case hex digits>
#
# Exit codes:
# 0 -> success
# 1 -> problem parsing arguments
# 2 -> at least one package was marked unsafe by VirusTotal
# 4 -> at least one exception was raised while looking for info
# 8 -> at least one package couldn't be queried because the manifest couldn't be found
# 16 -> VirusTotal API key is not configured
# Note: the exit codes (2, 4 & 8) may be combined, e.g. 6 -> exit codes
# 2 & 4 combined
#
# Options:
# -a, --all Check for all installed apps
# -s, --scan For packages where VirusTotal has no information, send download URL
# for analysis (and future retrieval). This requires you to configure
# your virustotal_api_key.
# -n, --no-depends By default, all dependencies are checked too. This flag avoids it.
# -u, --no-update-scoop Don't update Scoop before checking if it's outdated
# -p, --passthru Return reports as objects
. "$PSScriptRoot\..\lib\getopt.ps1"
. "$PSScriptRoot\..\lib\versions.ps1" # 'Select-CurrentVersion'
. "$PSScriptRoot\..\lib\manifest.ps1" # 'Get-Manifest'
. "$PSScriptRoot\..\lib\json.ps1" # 'json_path'
. "$PSScriptRoot\..\lib\download.ps1" # 'hash_for_url'
. "$PSScriptRoot\..\lib\depends.ps1" # 'Get-Dependency'
$opt, $apps, $err = getopt $args 'asnup' @('all', 'scan', 'no-depends', 'no-update-scoop', 'passthru')
if ($err) { "scoop virustotal: $err"; exit 1 }
$all = $apps -eq '*' -or $opt.a -or $opt.all
if (!$apps -and !$all) { my_usage; exit 1 }
$architecture = Get-DefaultArchitecture
if (is_scoop_outdated) {
if ($opt.u -or $opt.'no-update-scoop') {
warn 'Scoop is out of date.'
} else {
& "$PSScriptRoot\scoop-update.ps1"
}
}
if ($all) {
$apps = (installed_apps $false) + (installed_apps $true)
}
if (!$opt.n -and !$opt.'no-depends') {
$apps = $apps | Get-Dependency -Architecture $architecture | Select-Object -Unique
}
$_ERR_UNSAFE = 2
$_ERR_EXCEPTION = 4
$_ERR_NO_INFO = 8
$_ERR_NO_API_KEY = 16
$exit_code = 0
# Global API key:
$api_key = get_config VIRUSTOTAL_API_KEY
if (!$api_key) {
abort ("VirusTotal API key is not configured`n" +
" You could get one from https://www.virustotal.com/gui/my-apikey and set with`n" +
" scoop config virustotal_api_key <API key>") $_ERR_NO_API_KEY
}
# Global flag to explain only once about sleep between requests
$explained_rate_limit_sleeping = $False
# Requests counter to slow down requests submitted to VirusTotal as
# script execution progresses
$requests = 0
Function ConvertTo-VirusTotalUrlId ($url) {
$url_id = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($url))
$url_id = $url_id -replace '\+', '-'
$url_id = $url_id -replace '/', '_'
$url_id = $url_id -replace '=', ''
$url_id
}
Function Get-VirusTotalResultByHash ($hash, $url, $app) {
$hash = $hash.ToLower()
$api_url = "https://www.virustotal.com/api/v3/files/$hash"
$headers = @{}
$headers.Add('Accept', 'application/json')
$headers.Add('x-apikey', $api_key)
$response = Invoke-WebRequest -Uri $api_url -Method GET -Headers $headers -UseBasicParsing
$result = $response.Content
$stats = json_path $result '$.data.attributes.last_analysis_stats'
[int]$malicious = json_path $stats '$.malicious'
[int]$suspicious = json_path $stats '$.suspicious'
[int]$timeout = json_path $stats '$.timeout'
[int]$undetected = json_path $stats '$.undetected'
[int]$unsafe = $malicious + $suspicious
[int]$total = $unsafe + $undetected
[int]$fileSize = json_path $result '$.data.attributes.size'
$report_hash = json_path $result '$.data.attributes.sha256'
$report_url = "https://www.virustotal.com/gui/file/$report_hash"
if ($total -eq 0) {
info "$app`: Analysis in progress."
[PSCustomObject] @{
'App.Name' = $app
'App.Url' = $url
'App.Hash' = $hash
'App.HashType' = $null
'App.Size' = filesize $fileSize
'FileReport.Url' = $report_url
'FileReport.Hash' = $report_hash
'UrlReport.Url' = $null
}
} else {
$vendorResults = (ConvertFrom-Json((json_path $result '$.data.attributes.last_analysis_results'))).PSObject.Properties.Value
switch ($unsafe) {
0 {
success "$app`: $unsafe/$total, see $report_url"
}
1 {
warn "$app`: $unsafe/$total, see $report_url"
}
2 {
warn "$app`: $unsafe/$total, see $report_url"
}
Default {
warn "$([char]0x1b)[31m$app`: $unsafe/$total, see $report_url$([char]0x1b)[0m"
}
}
$maliciousResults = $vendorResults |
Where-Object -Property category -EQ 'malicious' |
Select-Object -ExpandProperty engine_name
$suspiciousResults = $vendorResults |
Where-Object -Property category -EQ 'suspicious' |
Select-Object -ExpandProperty engine_name
[PSCustomObject] @{
'App.Name' = $app
'App.Url' = $url
'App.Hash' = $hash
'App.HashType' = $null
'App.Size' = filesize $fileSize
'FileReport.Url' = $report_url
'FileReport.Hash' = $report_hash
'FileReport.Malicious' = if ($maliciousResults) { $maliciousResults } else { 0 }
'FileReport.Suspicious' = if ($suspiciousResults) { $suspiciousResults } else { 0 }
'FileReport.Timeout' = $timeout
'FileReport.Undetected' = $undetected
'UrlReport.Url' = $null
}
}
if ($unsafe -gt 0) {
$Script:exit_code = $exit_code -bor $_ERR_UNSAFE
}
}
Function Get-VirusTotalResultByUrl ($url, $app) {
$id = ConvertTo-VirusTotalUrlId $url
$api_url = "https://www.virustotal.com/api/v3/urls/$id"
$headers = @{}
$headers.Add('Accept', 'application/json')
$headers.Add('x-apikey', $api_key)
$response = Invoke-WebRequest -Uri $api_url -Method GET -Headers $headers -UseBasicParsing
$result = $response.Content
$id = json_path $result '$.data.id'
$hash = json_path $result '$.data.attributes.last_http_response_content_sha256' 6>$null
$last_analysis_date = json_path $result '$.data.attributes.last_analysis_date' 6>$null
$url_report_url = "https://www.virustotal.com/gui/url/$id"
info "$app`: Url report found."
if (!$hash) {
if (!$last_analysis_date) {
info "$app`: Analysis in progress."
} else {
info "$app`: Related file report not found."
warn "$app`: Manual file upload is required (instead of url submission)."
}
[PSCustomObject] @{
'App.Name' = $app
'App.Url' = $url
'App.Hash' = $null
'App.HashType' = $null
'FileReport.Url' = $null
'UrlReport.Url' = $url_report_url
'UrlReport.Hash' = $null
}
} else {
info "$app`: Related file report found."
[PSCustomObject] @{
'App.Name' = $app
'App.Url' = $url
'App.Hash' = $null
'App.HashType' = $null
'FileReport.Url' = $null
'UrlReport.Url' = $url_report_url
'UrlReport.Hash' = $hash
}
}
}
# Submit-ToVirusTotal
# - $url: where file to check can be downloaded
# - $app: Name of the application (used for reporting)
# - $do_scan: [boolean flag] whether to actually submit to VirusTotal
# This is a parameter instead of conditionnally calling
# the function to consolidate the warning message
# - $retrying: [boolean] Optional, for internal use to retry
# submitting the file after a delay if the rate limit is
# exceeded, without risking an infinite loop (as stack
# overflow) if the submission keeps failing.
Function Submit-ToVirusTotal ($url, $app, $do_scan, $retrying = $False) {
if (!$do_scan) {
warn "$app`: not found`: you can manually submit $url"
return
}
try {
$requests += 1
$encoded_url = [System.Web.HttpUtility]::UrlEncode($url)
$api_url = 'https://www.virustotal.com/api/v3/urls'
$content_type = 'application/x-www-form-urlencoded'
$headers = @{}
$headers.Add('Accept', 'application/json')
$headers.Add('x-apikey', $api_key)
$headers.Add('Content-Type', $content_type)
$body = "url=$encoded_url"
$result = Invoke-WebRequest -Uri $api_url -Method POST -Headers $headers -ContentType $content_type -Body $body -UseBasicParsing
if ($result.StatusCode -eq 200) {
$id = ((json_path $result '$.data.id') -split '-')[1]
$url_report_url = "https://www.virustotal.com/gui/url/$id"
$fileSize = Get-RemoteFileSize $url
if ($fileSize -gt 80000000) {
info "$app`: Remote file size: $(filesize $fileSize). Large files might require manual file upload instead of url submission."
}
info "$app`: Analysis in progress."
[PSCustomObject] @{
'App.Name' = $app
'App.Url' = $url
'App.Size' = filesize $fileSize
'FileReport.Url' = $null
'UrlReport.Url' = $url_report_url
}
return
}
# EAFP: submission failed -> sleep, then retry
if (!$retrying) {
if (!$explained_rate_limit_sleeping) {
$explained_rate_limit_sleeping = $True
info "Sleeping 60+ seconds between requests due to VirusTotal's 4/min limit"
}
Start-Sleep -s (60 + $requests)
Submit-ToVirusTotal $url $app $do_scan $True
} else {
warn "$app`: VirusTotal submission of $url failed`:`n" +
"`tAPI returned $($result.StatusCode) after retrying"
}
} catch [Exception] {
warn "$app`: VirusTotal submission failed`: $($_.Exception.Message)"
return
}
}
$reports = $apps | ForEach-Object {
$app = $_
$null, $manifest, $bucket, $null = Get-Manifest $app
if (!$manifest) {
$exit_code = $exit_code -bor $_ERR_NO_INFO
warn "$app`: manifest not found"
return
}
[int]$index = 0
$urls = script:url $manifest $architecture
$urls | ForEach-Object {
$url = $_
$index++
if ($urls.GetType().IsArray) {
info "$app`: url $index"
}
$hash = hash_for_url $manifest $url $architecture
try {
$isHashUnsupported = $false
if ($hash -match '(?<algo>[^:]+):(?<hash>.*)') {
$algo = $matches.algo
$hash = $matches.hash
if ($matches.algo -inotin 'md5', 'sha1', 'sha256') {
$hash = $null
$isHashUnsupported = $true
warn "$app`: Unsupported hash $($matches.algo). Will search by url instead."
}
} elseif ($hash) {
$algo = 'sha256'
}
if ($hash) {
$file_report = Get-VirusTotalResultByHash $hash $url $app
$file_report.'App.HashType' = $algo
$file_report
return
} elseif (!$isHashUnsupported) {
warn "$app`: Hash not found. Will search by url instead."
}
} catch [Exception] {
$exit_code = $exit_code -bor $_ERR_EXCEPTION
if ($_.Exception.Response.StatusCode -eq 404) {
$file_report_not_found = $true
warn "$app`: File report not found. Will search by url instead."
} else {
if ($_.Exception.Response.StatusCode -in 204, 429) {
abort "$app`: VirusTotal request failed`: $($_.Exception.Message)" $exit_code
}
warn "$app`: VirusTotal request failed`: $($_.Exception.Message)"
return
}
}
try {
$url_report = Get-VirusTotalResultByUrl $url $app
$url_report.'App.Hash' = $hash
$url_report.'App.HashType' = $algo
if ($url_report.'UrlReport.Hash' -and ($file_report_not_found -eq $true) -and $hash) {
if ($algo -eq 'sha256') {
if ($url_report.'UrlReport.Hash' -eq $hash) {
warn "$app`: Manual file upload is required (instead of url submission) for $url"
} else {
error "$app`: Hash not matched for $url"
}
} else {
error "$app`: Hash not matched or manual file upload is required (instead of url submission) for $url"
}
$url_report
return
}
if (!$url_report.'UrlReport.Hash') {
$url_report
return
}
} catch [Exception] {
$exit_code = $exit_code -bor $_ERR_EXCEPTION
if ($_.Exception.Response.StatusCode -eq 404) {
warn "$app`: Url report not found. Will submit $url"
Submit-ToVirusTotal $url $app ($opt.scan -or $opt.s)
return
} else {
if ($_.Exception.Response.StatusCode -in 204, 429) {
abort "$app`: VirusTotal request failed`: $($_.Exception.Message)" $exit_code
}
warn "$app`: VirusTotal request failed`: $($_.Exception.Message)"
return
}
}
try {
$file_report = Get-VirusTotalResultByHash $url_report.'UrlReport.Hash' $url $app
$file_report.'App.Hash' = $hash
$file_report.'App.HashType' = $algo
$file_report.'UrlReport.Url' = $url_report.'UrlReport.Url'
$file_report
warn "$app`: Unable to check hash match for $url"
} catch [Exception] {
$exit_code = $exit_code -bor $_ERR_EXCEPTION
if ($_.Exception.Response.StatusCode -eq 404) {
warn "$app`: File report not found for unknown reason. Manual file upload is required (instead of url submission)."
$url_report
} else {
if ($_.Exception.Response.StatusCode -in 204, 429) {
abort "$app`: VirusTotal request failed`: $($_.Exception.Message)" $exit_code
}
warn "$app`: VirusTotal request failed`: $($_.Exception.Message)"
return
}
}
}
}
if ($opt.p -or $opt.'passthru') {
$reports
}
exit $exit_code