2019-10-31 16:56:25 +01:00
|
|
|
--[[
|
2022-03-25 21:16:35 +01:00
|
|
|
Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
|
2019-10-31 16:56:25 +01:00
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
]]--
|
|
|
|
|
|
|
|
--[[[
|
|
|
|
-- @module virustotal
|
2022-02-22 23:01:28 +01:00
|
|
|
-- This module contains Virustotal integration support
|
2019-10-31 16:56:25 +01:00
|
|
|
-- https://www.virustotal.com/
|
|
|
|
--]]
|
|
|
|
|
|
|
|
local lua_util = require "lua_util"
|
|
|
|
local http = require "rspamd_http"
|
|
|
|
local rspamd_cryptobox_hash = require "rspamd_cryptobox_hash"
|
|
|
|
local rspamd_logger = require "rspamd_logger"
|
|
|
|
local common = require "lua_scanners/common"
|
|
|
|
|
|
|
|
local N = 'virustotal'
|
|
|
|
|
|
|
|
local function virustotal_config(opts)
|
|
|
|
|
|
|
|
local default_conf = {
|
|
|
|
name = N,
|
|
|
|
url = 'https://www.virustotal.com/vtapi/v2/file',
|
|
|
|
timeout = 5.0,
|
|
|
|
log_clean = false,
|
|
|
|
retransmits = 1,
|
|
|
|
cache_expire = 7200, -- expire redis in 2h
|
|
|
|
message = '${SCANNER}: spam message found: "${VIRUS}"',
|
|
|
|
detection_category = "virus",
|
|
|
|
default_score = 1,
|
|
|
|
action = false,
|
|
|
|
scan_mime_parts = true,
|
|
|
|
scan_text_mime = false,
|
|
|
|
scan_image_mime = false,
|
|
|
|
apikey = nil, -- Required to set by user
|
|
|
|
-- Specific for virustotal
|
|
|
|
minimum_engines = 3, -- Minimum required to get scored
|
|
|
|
full_score_engines = 7, -- After this number we set max score
|
|
|
|
}
|
|
|
|
|
|
|
|
default_conf = lua_util.override_defaults(default_conf, opts)
|
|
|
|
|
|
|
|
if not default_conf.prefix then
|
|
|
|
default_conf.prefix = 'rs_' .. default_conf.name .. '_'
|
|
|
|
end
|
|
|
|
|
|
|
|
if not default_conf.log_prefix then
|
|
|
|
if default_conf.name:lower() == default_conf.type:lower() then
|
|
|
|
default_conf.log_prefix = default_conf.name
|
|
|
|
else
|
|
|
|
default_conf.log_prefix = default_conf.name .. ' (' .. default_conf.type .. ')'
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
if not default_conf.apikey then
|
|
|
|
rspamd_logger.errx(rspamd_config, 'no apikey defined for virustotal, disable checks')
|
|
|
|
|
|
|
|
return nil
|
|
|
|
end
|
|
|
|
|
|
|
|
lua_util.add_debug_alias('external_services', default_conf.name)
|
|
|
|
return default_conf
|
|
|
|
end
|
|
|
|
|
2021-08-06 13:42:06 +02:00
|
|
|
local function virustotal_check(task, content, digest, rule, maybe_part)
|
2019-10-31 16:56:25 +01:00
|
|
|
local function virustotal_check_uncached()
|
|
|
|
local function make_url(hash)
|
|
|
|
return string.format('%s/report?apikey=%s&resource=%s',
|
|
|
|
rule.url, rule.apikey, hash)
|
|
|
|
end
|
|
|
|
|
|
|
|
local hash = rspamd_cryptobox_hash.create_specific('md5')
|
|
|
|
hash:update(content)
|
|
|
|
hash = hash:hex()
|
|
|
|
|
|
|
|
local url = make_url(hash)
|
|
|
|
lua_util.debugm(N, task, "send request %s", url)
|
|
|
|
local request_data = {
|
|
|
|
task = task,
|
|
|
|
url = url,
|
|
|
|
timeout = rule.timeout,
|
|
|
|
}
|
|
|
|
|
|
|
|
local function vt_http_callback(http_err, code, body, headers)
|
|
|
|
if http_err then
|
|
|
|
rspamd_logger.errx(task, 'HTTP error: %s, body: %s, headers: %s', http_err, body, headers)
|
|
|
|
else
|
|
|
|
local cached
|
2019-11-02 10:38:27 +01:00
|
|
|
local dyn_score
|
2019-10-31 16:56:25 +01:00
|
|
|
-- Parse the response
|
|
|
|
if code ~= 200 then
|
|
|
|
if code == 404 then
|
|
|
|
cached = 'OK'
|
|
|
|
if rule['log_clean'] then
|
|
|
|
rspamd_logger.infox(task, '%s: hash %s clean (not found)',
|
|
|
|
rule.log_prefix, hash)
|
|
|
|
else
|
|
|
|
lua_util.debugm(rule.name, task, '%s: hash %s clean (not found)',
|
2019-11-02 14:41:35 +01:00
|
|
|
rule.log_prefix, hash)
|
2019-10-31 16:56:25 +01:00
|
|
|
end
|
2019-11-02 13:12:34 +01:00
|
|
|
elseif code == 204 then
|
|
|
|
-- Request rate limit exceeded
|
|
|
|
rspamd_logger.infox(task, 'virustotal request rate limit exceeded')
|
|
|
|
task:insert_result(rule.symbol_fail, 1.0, 'rate limit exceeded')
|
|
|
|
return
|
2019-10-31 16:56:25 +01:00
|
|
|
else
|
|
|
|
rspamd_logger.errx(task, 'invalid HTTP code: %s, body: %s, headers: %s', code, body, headers)
|
|
|
|
task:insert_result(rule.symbol_fail, 1.0, 'Bad HTTP code: ' .. code)
|
|
|
|
return
|
|
|
|
end
|
|
|
|
else
|
|
|
|
local ucl = require "ucl"
|
|
|
|
local parser = ucl.parser()
|
2023-08-07 12:41:28 +02:00
|
|
|
local res, json_err = parser:parse_string(body)
|
2019-10-31 16:56:25 +01:00
|
|
|
|
|
|
|
lua_util.debugm(rule.name, task, '%s: got reply data: "%s"',
|
|
|
|
rule.log_prefix, body)
|
|
|
|
|
|
|
|
if res then
|
|
|
|
local obj = parser:get_object()
|
2019-11-07 16:05:28 +01:00
|
|
|
if not obj.positives or type(obj.positives) ~= 'number' then
|
2019-11-02 13:12:34 +01:00
|
|
|
if obj.response_code then
|
|
|
|
if obj.response_code == 0 then
|
|
|
|
cached = 'OK'
|
|
|
|
if rule['log_clean'] then
|
|
|
|
rspamd_logger.infox(task, '%s: hash %s clean (not found)',
|
|
|
|
rule.log_prefix, hash)
|
|
|
|
else
|
|
|
|
lua_util.debugm(rule.name, task, '%s: hash %s clean (not found)',
|
2019-11-02 14:41:35 +01:00
|
|
|
rule.log_prefix, hash)
|
2019-11-02 13:12:34 +01:00
|
|
|
end
|
|
|
|
else
|
|
|
|
rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s',
|
|
|
|
'bad response code: ' .. tostring(obj.response_code), body, headers)
|
|
|
|
task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no `positives` element')
|
|
|
|
return
|
|
|
|
end
|
|
|
|
else
|
|
|
|
rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s',
|
|
|
|
'no response_code', body, headers)
|
|
|
|
task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no `positives` element')
|
|
|
|
return
|
|
|
|
end
|
2019-10-31 16:56:25 +01:00
|
|
|
else
|
2019-11-07 16:05:28 +01:00
|
|
|
if obj.positives < rule.minimum_engines then
|
|
|
|
lua_util.debugm(rule.name, task, '%s: hash %s has not enough hits: %s where %s is min',
|
|
|
|
rule.log_prefix, obj.positives, rule.minimum_engines)
|
|
|
|
-- TODO: add proper hashing!
|
|
|
|
cached = 'OK'
|
2019-10-31 16:56:25 +01:00
|
|
|
else
|
2019-11-07 16:05:28 +01:00
|
|
|
if obj.positives > rule.full_score_engines then
|
|
|
|
dyn_score = 1.0
|
|
|
|
else
|
|
|
|
local norm_pos = obj.positives - rule.minimum_engines
|
|
|
|
dyn_score = norm_pos / (rule.full_score_engines - rule.minimum_engines)
|
|
|
|
end
|
2019-10-31 16:56:25 +01:00
|
|
|
|
2019-11-07 16:05:28 +01:00
|
|
|
if dyn_score < 0 or dyn_score > 1 then
|
|
|
|
dyn_score = 1.0
|
|
|
|
end
|
|
|
|
local sopt = string.format("%s:%s/%s",
|
|
|
|
hash, obj.positives, obj.total)
|
2021-08-06 13:42:06 +02:00
|
|
|
common.yield_result(task, rule, sopt, dyn_score, nil, maybe_part)
|
2019-11-07 16:05:28 +01:00
|
|
|
cached = sopt
|
2019-10-31 16:56:25 +01:00
|
|
|
end
|
|
|
|
end
|
|
|
|
else
|
2019-11-07 16:05:28 +01:00
|
|
|
-- not res
|
2019-10-31 16:56:25 +01:00
|
|
|
rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s',
|
|
|
|
json_err, body, headers)
|
|
|
|
task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: ' .. json_err)
|
|
|
|
return
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
if cached then
|
2021-08-06 13:42:06 +02:00
|
|
|
common.save_cache(task, digest, rule, cached, dyn_score, maybe_part)
|
2019-10-31 16:56:25 +01:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
request_data.callback = vt_http_callback
|
|
|
|
http.request(request_data)
|
|
|
|
end
|
|
|
|
|
|
|
|
if common.condition_check_and_continue(task, content, rule, digest,
|
|
|
|
virustotal_check_uncached) then
|
|
|
|
return
|
|
|
|
else
|
|
|
|
|
|
|
|
virustotal_check_uncached()
|
|
|
|
end
|
|
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
return {
|
|
|
|
type = 'antivirus',
|
|
|
|
description = 'Virustotal integration',
|
|
|
|
configure = virustotal_config,
|
|
|
|
check = virustotal_check,
|
|
|
|
name = N
|
|
|
|
}
|