You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

virustotal.lua 7.2KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. --[[
  2. Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. ]]--
  13. --[[[
  14. -- @module virustotal
  15. -- This module contains Virustotal integration support
  16. -- https://www.virustotal.com/
  17. --]]
  18. local lua_util = require "lua_util"
  19. local http = require "rspamd_http"
  20. local rspamd_cryptobox_hash = require "rspamd_cryptobox_hash"
  21. local rspamd_logger = require "rspamd_logger"
  22. local common = require "lua_scanners/common"
  23. local N = 'virustotal'
  24. local function virustotal_config(opts)
  25. local default_conf = {
  26. name = N,
  27. url = 'https://www.virustotal.com/vtapi/v2/file',
  28. timeout = 5.0,
  29. log_clean = false,
  30. retransmits = 1,
  31. cache_expire = 7200, -- expire redis in 2h
  32. message = '${SCANNER}: spam message found: "${VIRUS}"',
  33. detection_category = "virus",
  34. default_score = 1,
  35. action = false,
  36. scan_mime_parts = true,
  37. scan_text_mime = false,
  38. scan_image_mime = false,
  39. apikey = nil, -- Required to set by user
  40. -- Specific for virustotal
  41. minimum_engines = 3, -- Minimum required to get scored
  42. full_score_engines = 7, -- After this number we set max score
  43. }
  44. default_conf = lua_util.override_defaults(default_conf, opts)
  45. if not default_conf.prefix then
  46. default_conf.prefix = 'rs_' .. default_conf.name .. '_'
  47. end
  48. if not default_conf.log_prefix then
  49. if default_conf.name:lower() == default_conf.type:lower() then
  50. default_conf.log_prefix = default_conf.name
  51. else
  52. default_conf.log_prefix = default_conf.name .. ' (' .. default_conf.type .. ')'
  53. end
  54. end
  55. if not default_conf.apikey then
  56. rspamd_logger.errx(rspamd_config, 'no apikey defined for virustotal, disable checks')
  57. return nil
  58. end
  59. lua_util.add_debug_alias('external_services', default_conf.name)
  60. return default_conf
  61. end
  62. local function virustotal_check(task, content, digest, rule, maybe_part)
  63. local function virustotal_check_uncached()
  64. local function make_url(hash)
  65. return string.format('%s/report?apikey=%s&resource=%s',
  66. rule.url, rule.apikey, hash)
  67. end
  68. local hash = rspamd_cryptobox_hash.create_specific('md5')
  69. hash:update(content)
  70. hash = hash:hex()
  71. local url = make_url(hash)
  72. lua_util.debugm(N, task, "send request %s", url)
  73. local request_data = {
  74. task = task,
  75. url = url,
  76. timeout = rule.timeout,
  77. }
  78. local function vt_http_callback(http_err, code, body, headers)
  79. if http_err then
  80. rspamd_logger.errx(task, 'HTTP error: %s, body: %s, headers: %s', http_err, body, headers)
  81. else
  82. local cached
  83. local dyn_score
  84. -- Parse the response
  85. if code ~= 200 then
  86. if code == 404 then
  87. cached = 'OK'
  88. if rule['log_clean'] then
  89. rspamd_logger.infox(task, '%s: hash %s clean (not found)',
  90. rule.log_prefix, hash)
  91. else
  92. lua_util.debugm(rule.name, task, '%s: hash %s clean (not found)',
  93. rule.log_prefix, hash)
  94. end
  95. elseif code == 204 then
  96. -- Request rate limit exceeded
  97. rspamd_logger.infox(task, 'virustotal request rate limit exceeded')
  98. task:insert_result(rule.symbol_fail, 1.0, 'rate limit exceeded')
  99. return
  100. else
  101. rspamd_logger.errx(task, 'invalid HTTP code: %s, body: %s, headers: %s', code, body, headers)
  102. task:insert_result(rule.symbol_fail, 1.0, 'Bad HTTP code: ' .. code)
  103. return
  104. end
  105. else
  106. local ucl = require "ucl"
  107. local parser = ucl.parser()
  108. local res, json_err = parser:parse_string(body)
  109. lua_util.debugm(rule.name, task, '%s: got reply data: "%s"',
  110. rule.log_prefix, body)
  111. if res then
  112. local obj = parser:get_object()
  113. if not obj.positives or type(obj.positives) ~= 'number' then
  114. if obj.response_code then
  115. if obj.response_code == 0 then
  116. cached = 'OK'
  117. if rule['log_clean'] then
  118. rspamd_logger.infox(task, '%s: hash %s clean (not found)',
  119. rule.log_prefix, hash)
  120. else
  121. lua_util.debugm(rule.name, task, '%s: hash %s clean (not found)',
  122. rule.log_prefix, hash)
  123. end
  124. else
  125. rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s',
  126. 'bad response code: ' .. tostring(obj.response_code), body, headers)
  127. task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no `positives` element')
  128. return
  129. end
  130. else
  131. rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s',
  132. 'no response_code', body, headers)
  133. task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no `positives` element')
  134. return
  135. end
  136. else
  137. if obj.positives < rule.minimum_engines then
  138. lua_util.debugm(rule.name, task, '%s: hash %s has not enough hits: %s where %s is min',
  139. rule.log_prefix, obj.positives, rule.minimum_engines)
  140. -- TODO: add proper hashing!
  141. cached = 'OK'
  142. else
  143. if obj.positives > rule.full_score_engines then
  144. dyn_score = 1.0
  145. else
  146. local norm_pos = obj.positives - rule.minimum_engines
  147. dyn_score = norm_pos / (rule.full_score_engines - rule.minimum_engines)
  148. end
  149. if dyn_score < 0 or dyn_score > 1 then
  150. dyn_score = 1.0
  151. end
  152. local sopt = string.format("%s:%s/%s",
  153. hash, obj.positives, obj.total)
  154. common.yield_result(task, rule, sopt, dyn_score, nil, maybe_part)
  155. cached = sopt
  156. end
  157. end
  158. else
  159. -- not res
  160. rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s',
  161. json_err, body, headers)
  162. task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: ' .. json_err)
  163. return
  164. end
  165. end
  166. if cached then
  167. common.save_cache(task, digest, rule, cached, dyn_score, maybe_part)
  168. end
  169. end
  170. end
  171. request_data.callback = vt_http_callback
  172. http.request(request_data)
  173. end
  174. if common.condition_check_and_continue(task, content, rule, digest,
  175. virustotal_check_uncached) then
  176. return
  177. else
  178. virustotal_check_uncached()
  179. end
  180. end
  181. return {
  182. type = 'antivirus',
  183. description = 'Virustotal integration',
  184. configure = virustotal_config,
  185. check = virustotal_check,
  186. name = N
  187. }