File: metadefender.lua

package info (click to toggle)
rspamd 3.14.3-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 35,064 kB
  • sloc: ansic: 247,728; cpp: 107,741; javascript: 31,385; perl: 3,089; asm: 2,512; pascal: 1,625; python: 1,510; sh: 589; sql: 313; makefile: 195; xml: 74
file content (259 lines) | stat: -rw-r--r-- 9,191 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
--[[
Copyright (c) 2025, Vsevolod Stakhov <vsevolod@rspamd.com>

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 metadefender
-- This module contains MetaDefender Cloud integration support for hash lookups
-- https://metadefender.opswat.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 = 'metadefender'

local function metadefender_config(opts)
  local default_conf = {
    name = N,
    url = 'https://api.metadefender.com/v4/hash',
    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 metadefender
    minimum_engines = 3,  -- Minimum required to get scored
    -- Threshold-based categorization
    low_category = 5,     -- Low threat: minimum_engines to low_category-1
    medium_category = 10, -- Medium threat: low_category to medium_category-1
    -- High threat: medium_category and above
    -- Symbol categories
    symbols = {
      clean = {
        symbol = 'METADEFENDER_CLEAN',
        score = -0.5,
        description = 'MetaDefender decided attachment to be clean'
      },
      low = {
        symbol = 'METADEFENDER_LOW',
        score = 2.0,
        description = 'MetaDefender found low number of threats'
      },
      medium = {
        symbol = 'METADEFENDER_MEDIUM',
        score = 5.0,
        description = 'MetaDefender found medium number of threats'
      },
      high = {
        symbol = 'METADEFENDER_HIGH',
        score = 8.0,
        description = 'MetaDefender found high number of threats'
      },
    },
  }

  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 metadefender, disable checks')

    return nil
  end

  lua_util.add_debug_alias('external_services', default_conf.name)
  return default_conf
end

local function metadefender_check(task, content, digest, rule, maybe_part)
  local function metadefender_check_uncached()
    local function make_url(hash)
      return string.format('%s/%s', rule.url, hash)
    end

    -- MetaDefender uses SHA256 hash
    local hash = rspamd_cryptobox_hash.create_specific('sha256')
    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,
      headers = {
        ['apikey'] = rule.apikey,
      }
    }

    local function md_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)
        task:insert_result(rule.symbol_fail, 1.0, 'HTTP error: ' .. http_err)
      else
        local cached
        -- 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)',
                rule.log_prefix, hash)
            end
          elseif code == 429 then
            -- Request rate limit exceeded
            rspamd_logger.infox(task, 'metadefender request rate limit exceeded')
            task:insert_result(rule.symbol_fail, 1.0, 'rate limit exceeded')
            return
          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()
          local res, json_err = parser:parse_string(body)

          lua_util.debugm(rule.name, task, '%s: got reply data: "%s"',
            rule.log_prefix, body)

          if res then
            local obj = parser:get_object()

            -- MetaDefender API response structure:
            -- scan_results.scan_all_result_a: 'Clean', 'Infected', 'Suspicious'
            -- scan_results.scan_all_result_i: numeric result (0=clean)
            -- scan_results.total_detected_avs: number of engines detecting malware
            -- scan_results.total_avs: total number of engines

            if not obj.scan_results then
              rspamd_logger.errx(task, 'invalid JSON reply: no scan_results field, body: %s', body)
              task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no scan_results')
              return
            end

            local scan_results = obj.scan_results
            local detected = scan_results.total_detected_avs or 0
            local total = scan_results.total_avs or 0

            if detected == 0 then
              if rule['log_clean'] then
                rspamd_logger.infox(task, '%s: hash %s clean',
                  rule.log_prefix, hash)
              else
                lua_util.debugm(rule.name, task, '%s: hash %s clean',
                  rule.log_prefix, hash)
              end
              -- Insert CLEAN symbol
              if rule.symbols and rule.symbols.clean then
                local clean_sym = rule.symbols.clean.symbol or 'METADEFENDER_CLEAN'
                local sopt = string.format("%s:0/%s", hash, total)
                task:insert_result(clean_sym, 1.0, sopt)
                -- Save with symbol name for proper cache retrieval
                cached = string.format("%s\v%s", clean_sym, sopt)
              else
                cached = 'OK'
              end
            else
              if detected < 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, hash, detected, rule.minimum_engines)
                cached = 'OK'
              else
                -- Determine category based on detection count
                local category
                local category_sym
                local sopt = string.format("%s:%s/%s", hash, detected, total)

                if detected >= rule.medium_category then
                  category = 'high'
                  category_sym = rule.symbols.high.symbol or 'METADEFENDER_HIGH'
                elseif detected >= rule.low_category then
                  category = 'medium'
                  category_sym = rule.symbols.medium.symbol or 'METADEFENDER_MEDIUM'
                else
                  category = 'low'
                  category_sym = rule.symbols.low.symbol or 'METADEFENDER_LOW'
                end

                rspamd_logger.infox(task, '%s: result - %s: "%s" - category: %s',
                  rule.log_prefix, rule.detection_category .. 'found', sopt, category)

                task:insert_result(category_sym, 1.0, sopt)
                -- Save with symbol name for proper cache retrieval
                cached = string.format("%s\v%s", category_sym, sopt)
              end
            end
          else
            -- not res
            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
          common.save_cache(task, digest, rule, cached, 1.0, maybe_part)
        end
      end
    end

    request_data.callback = md_http_callback
    http.request(request_data)
  end

  if common.condition_check_and_continue(task, content, rule, digest,
        metadefender_check_uncached) then
    return
  else
    metadefender_check_uncached()
  end
end

return {
  type = 'antivirus',
  description = 'MetaDefender Cloud integration',
  configure = metadefender_config,
  check = metadefender_check,
  name = N
}