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.

icap.lua 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. --[[
  2. Copyright (c) 2018, Vsevolod Stakhov <vsevolod@highsecure.ru>
  3. Copyright (c) 2019, Carsten Rosenberg <c.rosenberg@heinlein-support.de>
  4. Licensed under the Apache License, Version 2.0 (the "License");
  5. you may not use this file except in compliance with the License.
  6. You may obtain a copy of the License at
  7. http://www.apache.org/licenses/LICENSE-2.0
  8. Unless required by applicable law or agreed to in writing, software
  9. distributed under the License is distributed on an "AS IS" BASIS,
  10. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  11. See the License for the specific language governing permissions and
  12. limitations under the License.
  13. ]]--
  14. --[[[
  15. -- @module icap
  16. -- This module contains icap access functions.
  17. -- Currently tested with
  18. -- - Symantec
  19. -- - Sophos Savdi
  20. -- - ClamAV/c-icap
  21. -- - Kaspersky Web Traffic Security
  22. -- - Trend Micro IWSVA
  23. -- - F-Secure Internet Gatekeeper Strings
  24. --]]
  25. local lua_util = require "lua_util"
  26. local tcp = require "rspamd_tcp"
  27. local upstream_list = require "rspamd_upstream_list"
  28. local rspamd_logger = require "rspamd_logger"
  29. local common = require "lua_scanners/common"
  30. local N = 'icap'
  31. local function icap_config(opts)
  32. local icap_conf = {
  33. name = N,
  34. scan_mime_parts = true,
  35. scan_all_mime_parts = true,
  36. scan_text_mime = false,
  37. scan_image_mime = false,
  38. scheme = "scan",
  39. default_port = 4020,
  40. timeout = 10.0,
  41. log_clean = false,
  42. retransmits = 2,
  43. cache_expire = 7200, -- expire redis in one hour
  44. message = '${SCANNER}: threat found with icap scanner: "${VIRUS}"',
  45. detection_category = "virus",
  46. default_score = 1,
  47. action = false,
  48. dynamic_scan = false,
  49. }
  50. icap_conf = lua_util.override_defaults(icap_conf, opts)
  51. if not icap_conf.prefix then
  52. icap_conf.prefix = 'rs_' .. icap_conf.name .. '_'
  53. end
  54. if not icap_conf.log_prefix then
  55. icap_conf.log_prefix = icap_conf.name .. ' (' .. icap_conf.type .. ')'
  56. end
  57. if not icap_conf.log_prefix then
  58. if icap_conf.name:lower() == icap_conf.type:lower() then
  59. icap_conf.log_prefix = icap_conf.name
  60. else
  61. icap_conf.log_prefix = icap_conf.name .. ' (' .. icap_conf.type .. ')'
  62. end
  63. end
  64. if not icap_conf.servers then
  65. rspamd_logger.errx(rspamd_config, 'no servers defined')
  66. return nil
  67. end
  68. icap_conf.upstreams = upstream_list.create(rspamd_config,
  69. icap_conf.servers,
  70. icap_conf.default_port)
  71. if icap_conf.upstreams then
  72. lua_util.add_debug_alias('external_services', icap_conf.name)
  73. return icap_conf
  74. end
  75. rspamd_logger.errx(rspamd_config, 'cannot parse servers %s',
  76. icap_conf.servers)
  77. return nil
  78. end
  79. local function icap_check(task, content, digest, rule)
  80. local function icap_check_uncached ()
  81. local upstream = rule.upstreams:get_upstream_round_robin()
  82. local addr = upstream:get_addr()
  83. local retransmits = rule.retransmits
  84. local respond_headers = {}
  85. -- Build the icap queries
  86. local options_request = {
  87. string.format("OPTIONS icap://%s:%s/%s ICAP/1.0\r\n", addr:to_string(), addr:get_port(), rule.scheme),
  88. string.format('Host: %s\r\n', addr:to_string()),
  89. "User-Agent: Rspamd\r\n",
  90. "Encapsulated: null-body=0\r\n\r\n",
  91. }
  92. local size = string.format("%x", tonumber(#content))
  93. local function icap_callback(err, conn)
  94. local function icap_requery(err_m, info)
  95. -- set current upstream to fail because an error occurred
  96. upstream:fail()
  97. -- retry with another upstream until retransmits exceeds
  98. if retransmits > 0 then
  99. retransmits = retransmits - 1
  100. lua_util.debugm(rule.name, task,
  101. '%s: %s Request Error: %s - retries left: %s',
  102. rule.log_prefix, info, err_m, retransmits)
  103. -- Select a different upstream!
  104. upstream = rule.upstreams:get_upstream_round_robin()
  105. addr = upstream:get_addr()
  106. lua_util.debugm(rule.name, task, '%s: retry IP: %s:%s',
  107. rule.log_prefix, addr, addr:get_port())
  108. tcp.request({
  109. task = task,
  110. host = addr:to_string(),
  111. port = addr:get_port(),
  112. timeout = rule.timeout,
  113. stop_pattern = '\r\n',
  114. data = options_request,
  115. read = false,
  116. callback = icap_callback,
  117. })
  118. else
  119. rspamd_logger.errx(task, '%s: failed to scan, maximum retransmits '..
  120. 'exceed - error: %s', rule.log_prefix, err_m or '')
  121. common.yield_result(task, rule, 'failed - error: ' .. err_m or '', 0.0, 'fail')
  122. end
  123. end
  124. local function get_respond_query()
  125. table.insert(respond_headers, 1, string.format(
  126. 'RESPMOD icap://%s:%s/%s ICAP/1.0\r\n', addr:to_string(), addr:get_port(), rule.scheme))
  127. table.insert(respond_headers, '\r\n')
  128. table.insert(respond_headers, size .. '\r\n')
  129. table.insert(respond_headers, content)
  130. table.insert(respond_headers, '\r\n0\r\n\r\n')
  131. return respond_headers
  132. end
  133. local function add_respond_header(name, value)
  134. if name and value then
  135. table.insert(respond_headers, string.format('%s: %s\r\n', name, value))
  136. end
  137. end
  138. local function icap_result_header_table(result)
  139. local icap_headers = {}
  140. for s in result:gmatch("[^\r\n]+") do
  141. if string.find(s, '^ICAP') then
  142. icap_headers['icap'] = s
  143. end
  144. if string.find(s, '[%a%d-+]-:') then
  145. local _,_,key,value = tostring(s):find("([%a%d-+]-):%s?(.+)")
  146. if key ~= nil then
  147. icap_headers[key] = value
  148. end
  149. end
  150. end
  151. lua_util.debugm(rule.name, task, '%s: icap_headers: %s',
  152. rule.log_prefix, icap_headers)
  153. return icap_headers
  154. end
  155. local function icap_parse_result(icap_headers)
  156. local threat_string = {}
  157. --[[
  158. @ToDo: handle type in response
  159. Generic Strings:
  160. X-Infection-Found: Type=0; Resolution=2; Threat=Troj/DocDl-OYC;
  161. X-Infection-Found: Type=0; Resolution=2; Threat=W97M.Downloader;
  162. Symantec String:
  163. X-Infection-Found: Type=2; Resolution=2; Threat=Container size violation
  164. X-Infection-Found: Type=2; Resolution=2; Threat=Encrypted container violation;
  165. Sophos Strings:
  166. X-Virus-ID: Troj/DocDl-OYC
  167. Kaspersky Web Traffic Security Strings:
  168. X-Virus-ID: HEUR:Backdoor.Java.QRat.gen
  169. X-Response-Info: blocked
  170. X-Virus-ID: no threats
  171. X-Response-Info: blocked
  172. X-Response-Info: passed
  173. Trend Micro IWSVA Strings:
  174. X-Virus-ID: Trojan.W97M.POWLOAD.SMTHF1
  175. X-Infection-Found: Type=0; Resolution=2; Threat=Trojan.W97M.POWLOAD.SMTHF1;
  176. F-Secure Internet Gatekeeper Strings:
  177. X-FSecure-Scan-Result: infected
  178. X-FSecure-Infection-Name: "Malware.W97M/Agent.32584203"
  179. X-FSecure-Infected-Filename: "virus.doc"
  180. ESET File Security for Linux 7.0
  181. X-Infection-Found: Type=0; Resolution=0; Threat=VBA/TrojanDownloader.Agent.JOA;
  182. X-Virus-ID: Trojaner
  183. X-Response-Info: Blocked
  184. ]] --
  185. if icap_headers['X-Infection-Found'] then
  186. local _,_,icap_type,_,icap_threat =
  187. icap_headers['X-Infection-Found']:find("Type=(.-); Resolution=(.-); Threat=(.-);$")
  188. if not icap_type or icap_type == 2 then
  189. -- error returned
  190. lua_util.debugm(rule.name, task,
  191. '%s: icap error X-Infection-Found: %s', rule.log_prefix, icap_threat)
  192. common.yield_result(task, rule, icap_threat, 0, 'fail')
  193. else
  194. lua_util.debugm(rule.name, task,
  195. '%s: icap X-Infection-Found: %s', rule.log_prefix, icap_threat)
  196. table.insert(threat_string, icap_threat)
  197. end
  198. elseif icap_headers['X-Virus-ID'] and icap_headers['X-Virus-ID'] ~= "no threats" then
  199. lua_util.debugm(rule.name, task,
  200. '%s: icap X-Virus-ID: %s', rule.log_prefix, icap_headers['X-Virus-ID'])
  201. if string.find(icap_headers['X-Virus-ID'], ', ') then
  202. local vnames = lua_util.str_split(string.gsub(icap_headers['X-Virus-ID'], "%s", ""), ',') or {}
  203. for _,v in ipairs(vnames) do
  204. table.insert(threat_string, v)
  205. end
  206. else
  207. table.insert(threat_string, icap_headers['X-Virus-ID'])
  208. end
  209. elseif icap_headers['X-FSecure-Scan-Result'] and icap_headers['X-FSecure-Scan-Result'] ~= "clean" then
  210. local infected_filename = ""
  211. local infection_name = "-unknown-"
  212. if icap_headers['X-FSecure-Infected-Filename'] then
  213. infected_filename = string.gsub(icap_headers['X-FSecure-Infected-Filename'], '[%s"]', '')
  214. end
  215. if icap_headers['X-FSecure-Infection-Name'] then
  216. infection_name = string.gsub(icap_headers['X-FSecure-Infection-Name'], '[%s"]', '')
  217. end
  218. lua_util.debugm(rule.name, task,
  219. '%s: icap X-FSecure-Infection-Name (X-FSecure-Infected-Filename): %s (%s)',
  220. rule.log_prefix, infection_name, infected_filename)
  221. if string.find(infection_name, ', ') then
  222. local vnames = lua_util.str_split(infection_name, ',') or {}
  223. for _,v in ipairs(vnames) do
  224. table.insert(threat_string, v)
  225. end
  226. else
  227. table.insert(threat_string, infection_name)
  228. end
  229. end
  230. if #threat_string > 0 then
  231. common.yield_result(task, rule, threat_string, rule.default_score)
  232. common.save_cache(task, digest, rule, threat_string, rule.default_score)
  233. else
  234. common.save_cache(task, digest, rule, 'OK', 0)
  235. common.log_clean(task, rule)
  236. end
  237. end
  238. local function icap_r_respond_cb(err_m, data, connection)
  239. if err_m or connection == nil then
  240. icap_requery(err_m, "icap_r_respond_cb")
  241. else
  242. local result = tostring(data)
  243. conn:close()
  244. local icap_headers = icap_result_header_table(result) or {}
  245. -- Find ICAP/1.x 2xx response
  246. if icap_headers.icap and string.find(icap_headers.icap, 'ICAP%/1%.. 2%d%d') then
  247. icap_parse_result(icap_headers)
  248. elseif icap_headers.icap and string.find(icap_headers.icap, 'ICAP%/1%.. [45]%d%d') then
  249. -- Find ICAP/1.x 5/4xx response
  250. --[[
  251. Symantec String:
  252. ICAP/1.0 539 Aborted - No AV scanning license
  253. SquidClamAV/C-ICAP:
  254. ICAP/1.0 500 Server error
  255. ]]--
  256. rspamd_logger.errx(task, '%s: ICAP ERROR: %s', rule.log_prefix, icap_headers.icap)
  257. common.yield_result(task, rule, icap_headers.icap, 0.0, 'fail')
  258. return false
  259. else
  260. rspamd_logger.errx(task, '%s: unhandled response |%s|',
  261. rule.log_prefix, string.gsub(result, "\r\n", ", "))
  262. common.yield_result(task, rule, 'unhandled icap response: ' .. icap_headers.icap or "-", 0.0, 'fail')
  263. end
  264. end
  265. end
  266. local function icap_w_respond_cb(err_m, connection)
  267. if err_m or connection == nil then
  268. icap_requery(err_m, "icap_w_respond_cb")
  269. else
  270. connection:add_read(icap_r_respond_cb, '\r\n\r\n')
  271. end
  272. end
  273. local function icap_r_options_cb(err_m, data, connection)
  274. if err_m or connection == nil then
  275. icap_requery(err_m, "icap_r_options_cb")
  276. else
  277. local icap_headers = icap_result_header_table(tostring(data))
  278. if icap_headers.icap and string.find(icap_headers.icap, 'ICAP%/1%.. 2%d%d') then
  279. if icap_headers['Methods'] and string.find(icap_headers['Methods'], 'RESPMOD') then
  280. if icap_headers['Allow'] and string.find(icap_headers['Allow'], '204') then
  281. add_respond_header('Allow', '204')
  282. end
  283. if icap_headers['Service'] and string.find(icap_headers['Service'], 'IWSVA 6.5') then
  284. add_respond_header('Encapsulated', 'res-hdr=0 res-body=0')
  285. else
  286. add_respond_header('Encapsulated', 'res-body=0')
  287. end
  288. if icap_headers['Server'] and string.find(icap_headers['Server'], 'F-Secure ICAP Server') then
  289. local from = task:get_from('mime')
  290. local rcpt_to = task:get_principal_recipient()
  291. local client = task:get_from_ip()
  292. if client then add_respond_header('X-Client-IP', client:to_string()) end
  293. add_respond_header('X-Mail-From', from[1].addr)
  294. add_respond_header('X-Rcpt-To', rcpt_to)
  295. end
  296. conn:add_write(icap_w_respond_cb, get_respond_query())
  297. else
  298. rspamd_logger.errx(task, '%s: RESPMOD method not advertised: Methods: %s',
  299. rule.log_prefix, icap_headers['Methods'])
  300. common.yield_result(task, rule, 'NO RESPMOD', 0.0, 'fail')
  301. end
  302. else
  303. rspamd_logger.errx(task, '%s: OPTIONS query failed: %s',
  304. rule.log_prefix, icap_headers.icap or "-")
  305. common.yield_result(task, rule, 'OPTIONS query failed', 0.0, 'fail')
  306. end
  307. end
  308. end
  309. if err or conn == nil then
  310. icap_requery(err, "options_request")
  311. else
  312. -- set upstream ok
  313. if upstream then upstream:ok() end
  314. conn:add_read(icap_r_options_cb, '\r\n\r\n')
  315. end
  316. end
  317. tcp.request({
  318. task = task,
  319. host = addr:to_string(),
  320. port = addr:get_port(),
  321. timeout = rule.timeout,
  322. stop_pattern = '\r\n',
  323. data = options_request,
  324. read = false,
  325. callback = icap_callback,
  326. })
  327. end
  328. if common.condition_check_and_continue(task, content, rule, digest, icap_check_uncached) then
  329. return
  330. else
  331. icap_check_uncached()
  332. end
  333. end
  334. return {
  335. type = {N, 'virus', 'virus', 'scanner'},
  336. description = 'generic icap antivirus',
  337. configure = icap_config,
  338. check = icap_check,
  339. name = N
  340. }