From 52c2618a74c155445f9505a44eb47b6f4373cf78 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Mon, 8 Apr 2024 14:35:42 -0700 Subject: [PATCH] Release v1.1.1 (#37) - dep: eslint-plugin-haraka -> @haraka/eslint-config - lint: remove duplicate / stale rules from .eslintrc - populate `[files]` in package.json. Delete .npmignore. - doc: Changes -> CHANGELOG - doc(CHANGELOG): fixed broken release link - prettier Closes #35 --- .eslintrc.yaml | 19 +- .github/workflows/ci.yml | 7 +- .github/workflows/codeql.yml | 6 +- .github/workflows/publish.yml | 2 +- .npmignore | 58 -- .prettierrc | 1 + .release | 2 +- Changes.md => CHANGELOG.md | 22 +- README.md | 43 +- index.js | 1165 ++++++++++++++++++--------------- package.json | 25 +- test/index.js | 540 ++++++++------- 12 files changed, 992 insertions(+), 898 deletions(-) delete mode 100644 .npmignore create mode 100644 .prettierrc rename Changes.md => CHANGELOG.md (59%) diff --git a/.eslintrc.yaml b/.eslintrc.yaml index ed7ccfe..035a400 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -2,21 +2,6 @@ env: node: true es6: true mocha: true - es2020: true + es2022: true -plugins: - - haraka - -extends: - - eslint:recommended - - plugin:haraka/recommended - -root: true - -globals: - OK: true - CONT: true - DENY: true - DENYSOFT: true - DENYDISCONNECT: true - DENYSOFTDISCONNECT: true +extends: ['@haraka'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d1cca8..3d01042 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,11 @@ name: CI -on: [ push, pull_request ] +on: [push, pull_request] env: CI: true jobs: - lint: uses: haraka/.github/.github/workflows/lint.yml@master @@ -15,9 +14,9 @@ jobs: # secrets: inherit ubuntu: - needs: [ lint ] + needs: [lint] uses: haraka/.github/.github/workflows/ubuntu.yml@master windows: - needs: [ lint ] + needs: [lint] uses: haraka/.github/.github/workflows/windows.yml@master diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 383aca2..816e8c3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,10 +1,10 @@ -name: "CodeQL" +name: 'CodeQL' on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] schedule: - cron: '18 7 * * 4' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d489fbd..e81c15f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,4 +13,4 @@ env: jobs: publish: uses: haraka/.github/.github/workflows/publish.yml@master - secrets: inherit \ No newline at end of file + secrets: inherit diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 3e8e260..0000000 --- a/.npmignore +++ /dev/null @@ -1,58 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* - -# Runtime data -pids -*.pid -*.seed - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules -jspm_packages - -# Optional npm cache directory -.npm - -# Optional REPL history -.node_repl_history - -package-lock.json -bower_components -# Optional npm cache directory -.npmrc -.idea -.DS_Store -haraka-update.sh - -.github -.release -.codeclimate.yml -.editorconfig -.gitignore -.gitmodules -.lgtm.yml -appveyor.yml -codecov.yml -.travis.yml -.eslintrc.yaml -.eslintrc.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0176969 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +singleQuote: true diff --git a/.release b/.release index 9be2b27..7cd5707 160000 --- a/.release +++ b/.release @@ -1 +1 @@ -Subproject commit 9be2b270ef836bcfefda085674bf62e2a91defe8 +Subproject commit 7cd5707f7d69f8d4dca1ec407ada911890e59d0a diff --git a/Changes.md b/CHANGELOG.md similarity index 59% rename from Changes.md rename to CHANGELOG.md index ab69c60..ec0e9e4 100644 --- a/Changes.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ +# Changelog + +The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +### [1.1.1] - 2024-04-08 + +- dep: eslint-plugin-haraka -> @haraka/eslint-config +- lint: remove duplicate / stale rules from .eslintrc +- populate `[files]` in package.json. Delete .npmignore. +- doc: Changes -> CHANGELOG +- doc(CHANGELOG): fixed broken release link +- prettier ### [1.1.0] - 2024-03-20 @@ -8,19 +19,16 @@ - fix: minor debug logging issue #32 - fix: restored missing function call #31 - ### [1.0.7] - 2022-06-16 - import fresh from Haraka - convert tests to mocha - ### 1.0.6 - Dec 22, 2021 - Fix issue with nested archives not getting counted, #26 - replace travis & appveyor with GHA - ### 1.0.5 - Aug 21, 2018 - more tests @@ -28,13 +36,13 @@ - fix a cfg path issue - remove leading dot from file_ext - ### 1.0.4 - Aug 21, 2018 - finished converting tests to mocha, tests work again. Yay. - es6 updates - backported changes from haraka/plugins/attachment.js - -[1.0.7]: https://github.com/haraka/haraka-plugin-attachment/releases/tag/1.0.7 -[1.1.0]: https://github.com/haraka/haraka-plugin-attachment/releases/tag/1.1.0 +[1.0.6]: https://github.com/haraka/haraka-plugin-attachment/releases/tag/v1.0.6 +[1.0.7]: https://github.com/haraka/haraka-plugin-attachment/releases/tag/v1.0.7 +[1.1.0]: https://github.com/haraka/haraka-plugin-attachment/releases/tag/v1.1.0 +[1.1.1]: https://github.com/haraka/haraka-plugin-attachment/releases/tag/v1.1.1 diff --git a/README.md b/README.md index 2d068cf..54884a5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -attachment -========== +# attachment [![Build Status][ci-img]][ci-url] [![Code Climate][clim-img]][clim-url] @@ -7,79 +6,73 @@ attachment This plugin allows you to reject messages based on Content-Type within the message or any MIME parts or on the filename of any attachments. -Limitations ------------ +## Limitations -This plugin cannot detect forged MIME types where the sender is lying about the type. The type is not confirmed in any way currently. +This plugin cannot detect forged MIME types where the sender is lying about the type. The type is not confirmed in any way currently. Encrypted archives that contain encrypted sub-archives cannot be expanded and will cause the plugin to reject the message. - -Requirements ------------- +## Requirements To check filenames inside archive files the npm module `tmp` is required and the `bsdtar` binary must be available. If either `tmp` or `bsdtar` are unavailable then the plugin will not expand archives. - -Logging -------- +## Logging At INFO level logging this plugin will output the filename and type of each attached file along with an MD5 checksum of the contents. The MD5 checksum is useful to check against www.virustotal.com +## Configuration -Configuration -------------- +- attachment.ini -* attachment.ini - * default settings shown + - default settings shown - - timeout=30 + * timeout=30 Timeout in seconds before the plugin will abort. - - disallowed_extensions=exe,com,pif,bat,scr,vbs,cmd,cpl,dll + * disallowed_extensions=exe,com,pif,bat,scr,vbs,cmd,cpl,dll File extensions that should be rejected when detected. [archive] - - max\_depth=5 + + * max_depth=5 The maximum level of nested archives that will be unpacked. If this is exceeded the message will be rejected. [archive] - - extensions=zip,tar,tgz,taz,z,gz,rar,7z + + * extensions=zip,tar,tgz,taz,z,gz,rar,7z File extensions that should be treated as archives. This can be any file type supported by bsdtar. - -* attachment.filename.regex +- attachment.filename.regex This file contains a list of regular expressions, one per line that will be tested against each filename found within a message. The first regexp to match will cause the message to be rejected. Any invalid regexps will be detected, reported and skipped. -* attachment.archive.filename.regex +- attachment.archive.filename.regex This file contains a list of regular expressions, one per line that will be tested against each filename found within an archive file. The first regexp to match will cause the message to be rejected. Any invalid regexps will be detected, reported and skipped. -* attachment.ctype.regex +- attachment.ctype.regex This file contains a list of regular expressions, one per line that will be tested against each MIME Content-Type header in the message. The first regexp to match will cause the message to be rejected. Any invalid regexps will be detected, reported and skipped. - - + [ci-img]: https://github.com/haraka/haraka-plugin-attachment/actions/workflows/ci.yml/badge.svg [ci-url]: https://github.com/haraka/haraka-plugin-attachment/actions/workflows/ci.yml [clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-attachment/badges/gpa.svg diff --git a/index.js b/index.js index 2e3c64a..e7f3871 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,7 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); +const fs = require('fs'); +const path = require('path'); const { spawn } = require('child_process'); const crypto = require('crypto'); @@ -9,630 +9,719 @@ let tmp; let archives_disabled = false; exports.re = { - ct: /^([^/]+\/[^;\r\n ]+)/, // content type -} + ct: /^([^/]+\/[^;\r\n ]+)/, // content type +}; exports.register = function () { + this.load_tmp_module(); + this.load_attachment_ini(); - this.load_tmp_module(); - this.load_attachment_ini(); + this.load_n_compile_re('file', 'attachment.filename.regex'); + this.load_n_compile_re('ctype', 'attachment.ctype.regex'); + this.load_n_compile_re('archive', 'attachment.archive.filename.regex'); - this.load_n_compile_re('file', 'attachment.filename.regex'); - this.load_n_compile_re('ctype', 'attachment.ctype.regex'); - this.load_n_compile_re('archive', 'attachment.archive.filename.regex'); - - this.register_hook('data_post', 'wait_for_attachment_hooks'); - this.register_hook('data_post', 'check_attachments'); -} + this.register_hook('data_post', 'wait_for_attachment_hooks'); + this.register_hook('data_post', 'check_attachments'); +}; exports.load_tmp_module = function () { - try { - tmp = require('tmp'); - tmp.setGracefulCleanup(); - } - catch (e) { - archives_disabled = true; - this.logwarn(`the 'tmp' module is required to extract filenames from archives`); - } -} + try { + tmp = require('tmp'); + tmp.setGracefulCleanup(); + } catch (e) { + archives_disabled = true; + this.logwarn( + `the 'tmp' module is required to extract filenames from archives`, + ); + } +}; exports.load_attachment_ini = function () { - const plugin = this; - - plugin.cfg = plugin.config.get('attachment.ini', () => { - plugin.load_attachment_ini(); - }); - - plugin.cfg.timeout = (plugin.cfg.main.timeout || 30) * 1000; - - // repair a mismatch between legacy docs and code - const extns = (plugin.cfg.archive && plugin.cfg.archive.extensions) ? - plugin.cfg.archive.extensions : // new - plugin.cfg.main.archive_extensions ? // old code - plugin.cfg.main.archive_extensions : - plugin.cfg.main.archive_extns ? // old docs - plugin.cfg.main.archive_extns : - 'zip tar tgz taz z gz rar 7z'; - - plugin.cfg.archive.exts = this.options_to_object(extns) - - plugin.cfg.archive.max_depth = - (plugin.cfg.archive && plugin.cfg.archive.max_depth) ? - plugin.cfg.archive.max_depth : // new - plugin.cfg.main.archive_max_depth ? // old - plugin.cfg.main.archive_max_depth : - 5; - - plugin.load_dissallowed_extns(); -} - -exports.find_bsdtar_path = cb => { - let found = false; - let i = 0; - ['/bin', '/usr/bin', '/usr/local/bin'].forEach((dir) => { - if (found) return; - i++; - fs.stat(`${dir}/bsdtar`, (err) => { - i--; - if (found) return; - if (err) { - if (i===0) cb(new Error('bsdtar not found')); - return; - } - found = true; - cb(null, dir); - }); - if (i===0) cb(new Error('bsdtar not found')); + const plugin = this; + + plugin.cfg = plugin.config.get('attachment.ini', () => { + plugin.load_attachment_ini(); + }); + + plugin.cfg.timeout = (plugin.cfg.main.timeout || 30) * 1000; + + // repair a mismatch between legacy docs and code + const extns = + plugin.cfg.archive && plugin.cfg.archive.extensions + ? plugin.cfg.archive.extensions // new + : plugin.cfg.main.archive_extensions // old code + ? plugin.cfg.main.archive_extensions + : plugin.cfg.main.archive_extns // old docs + ? plugin.cfg.main.archive_extns + : 'zip tar tgz taz z gz rar 7z'; + + plugin.cfg.archive.exts = this.options_to_object(extns); + + plugin.cfg.archive.max_depth = + plugin.cfg.archive && plugin.cfg.archive.max_depth + ? plugin.cfg.archive.max_depth // new + : plugin.cfg.main.archive_max_depth // old + ? plugin.cfg.main.archive_max_depth + : 5; + + plugin.load_dissallowed_extns(); +}; + +exports.find_bsdtar_path = (cb) => { + let found = false; + let i = 0; + ['/bin', '/usr/bin', '/usr/local/bin'].forEach((dir) => { + if (found) return; + i++; + fs.stat(`${dir}/bsdtar`, (err) => { + i--; + if (found) return; + if (err) { + if (i === 0) cb(new Error('bsdtar not found')); + return; + } + found = true; + cb(null, dir); }); -} + if (i === 0) cb(new Error('bsdtar not found')); + }); +}; exports.hook_init_master = exports.hook_init_child = function (next) { - const plugin = this; - - plugin.find_bsdtar_path((err, dir) => { - if (err) { - archives_disabled = true; - plugin.logwarn(`This plugin requires the 'bsdtar' binary to extract filenames from archive files`); - } - else { - plugin.logdebug(`found bsdtar in ${dir}`); - plugin.bsdtar_path = `${dir}/bsdtar`; - } - next(); - }); -} + const plugin = this; + + plugin.find_bsdtar_path((err, dir) => { + if (err) { + archives_disabled = true; + plugin.logwarn( + `This plugin requires the 'bsdtar' binary to extract filenames from archive files`, + ); + } else { + plugin.logdebug(`found bsdtar in ${dir}`); + plugin.bsdtar_path = `${dir}/bsdtar`; + } + next(); + }); +}; exports.load_dissallowed_extns = function () { - const plugin = this; - - if (!plugin.cfg.main.disallowed_extensions) return; - - if (!plugin.re) plugin.re = {}; - plugin.re.bad_extn = new RegExp( - '\\.(?:' + - (plugin.cfg.main.disallowed_extensions - .replace(/\s+/,' ') - .split(/[;, ]/) - .join('|')) + - ')$', 'i'); -} + const plugin = this; + + if (!plugin.cfg.main.disallowed_extensions) return; + + if (!plugin.re) plugin.re = {}; + plugin.re.bad_extn = new RegExp( + '\\.(?:' + + plugin.cfg.main.disallowed_extensions + .replace(/\s+/, ' ') + .split(/[;, ]/) + .join('|') + + ')$', + 'i', + ); +}; exports.load_n_compile_re = function (name, file) { - const plugin = this; - const valid_re = []; + const plugin = this; + const valid_re = []; - const try_re = plugin.config.get(file, 'list', function () { - plugin.load_n_compile_re(name, file); - }); + const try_re = plugin.config.get(file, 'list', function () { + plugin.load_n_compile_re(name, file); + }); - for (let r=0; r < try_re.length; r++) { - try { - const reg = new RegExp(try_re[r], 'i'); - valid_re.push(reg); - } - catch (e) { - this.logerror(`skipping invalid regexp: /${try_re[r]}/ (${e})`); - } + for (let r = 0; r < try_re.length; r++) { + try { + const reg = new RegExp(try_re[r], 'i'); + valid_re.push(reg); + } catch (e) { + this.logerror(`skipping invalid regexp: /${try_re[r]}/ (${e})`); } + } - if (!plugin.re) plugin.re = {}; - plugin.re[name] = valid_re; -} + if (!plugin.re) plugin.re = {}; + plugin.re[name] = valid_re; +}; exports.options_to_object = function (options) { - if (!options) return false; - - const res = {}; - options.toLowerCase().replace(/\s+/,' ').split(/[;, ]/).forEach((opt) => { - if (!opt) return; - res[opt.trim()]=true; - }) - - if (Object.keys(res).length) return res; - return false; -} - -exports.unarchive_recursive = async function (connection, f, archive_file_name, cb) { - if (archives_disabled) { - connection.logdebug(this, 'archive support disabled'); - return cb(); - } - - const plugin = this; - const tmpfiles = []; - - let timeouted = false; - let encrypted = false; - let depthExceeded = false; - - function timeoutedSpawn (cmd_path, args, env, pipe_stdout_ws) { - connection.logdebug(plugin, `running "${cmd_path} ${args.join(' ')}"`); - - return new Promise(function (resolve, reject) { - - let output = ''; - const p = spawn(cmd_path, args, env); - - // Start timer - let timeout = false; - const timer = setTimeout(() => { - timeout = timeouted = true; - p.kill(); - - reject(`command "${cmd_path} ${args}" timed out`); - }, plugin.cfg.timeout); - - - if (pipe_stdout_ws) { - p.stdout.pipe(pipe_stdout_ws); - } - else { - p.stdout.on('data', (data) => output += data); - } + if (!options) return false; + + const res = {}; + options + .toLowerCase() + .replace(/\s+/, ' ') + .split(/[;, ]/) + .forEach((opt) => { + if (!opt) return; + res[opt.trim()] = true; + }); - p.stderr.on('data', (data) => { + if (Object.keys(res).length) return res; + return false; +}; + +exports.unarchive_recursive = async function ( + connection, + f, + archive_file_name, + cb, +) { + if (archives_disabled) { + connection.logdebug(this, 'archive support disabled'); + return cb(); + } + + const plugin = this; + const tmpfiles = []; + + let timeouted = false; + let encrypted = false; + let depthExceeded = false; + + function timeoutedSpawn(cmd_path, args, env, pipe_stdout_ws) { + connection.logdebug(plugin, `running "${cmd_path} ${args.join(' ')}"`); + + return new Promise(function (resolve, reject) { + let output = ''; + const p = spawn(cmd_path, args, env); + + // Start timer + let timeout = false; + const timer = setTimeout(() => { + timeout = timeouted = true; + p.kill(); + + reject(`command "${cmd_path} ${args}" timed out`); + }, plugin.cfg.timeout); + + if (pipe_stdout_ws) { + p.stdout.pipe(pipe_stdout_ws); + } else { + p.stdout.on('data', (data) => (output += data)); + } + + p.stderr.on('data', (data) => { + if (data.includes('Incorrect passphrase')) { + encrypted = true; + } - if (data.includes('Incorrect passphrase')) { - encrypted = true; - } + // it seems that stderr might be sometimes filled after exit so we rather print it out than wait for result + connection.logdebug(plugin, `"${cmd_path} ${args.join(' ')}": ${data}`); + }); - // it seems that stderr might be sometimes filled after exit so we rather print it out than wait for result - connection.logdebug(plugin, `"${cmd_path} ${args.join(' ')}": ${data}`); - }); + p.on('exit', (code, signal) => { + if (timeout) return; + clearTimeout(timer); - p.on('exit', (code, signal) => { - if (timeout) return; - clearTimeout(timer); + if (code && code > 0) { + // Error was returned + return reject( + `"${cmd_path} ${args.join(' ')}" returned error code: ${code}}`, + ); + } - if (code && code > 0) { - // Error was returned - return reject(`"${cmd_path} ${args.join(' ')}" returned error code: ${code}}`); - } + if (signal) { + // Process terminated due to signal + return reject( + `"${cmd_path} ${args.join(' ')}" terminated by signal: ${signal}`, + ); + } + resolve(output); + }); + }); + } - if (signal) { - // Process terminated due to signal - return reject(`"${cmd_path} ${args.join(' ')}" terminated by signal: ${signal}`); - } + function createTmp() { + // might be better to use async version of tmp in future not cb based + return new Promise((resolve, reject) => { + tmp.file((err, tmpfile, fd) => { + if (err) reject(err); - resolve(output); - }); - }); - } + const t = {}; + t.name = tmpfile; + t.fd = fd; - function createTmp () { - // might be better to use async version of tmp in future not cb based - return new Promise((resolve, reject) => { - tmp.file((err, tmpfile, fd) => { - if (err) reject(err); + resolve(t); + }); + }); + } - const t = {}; - t.name = tmpfile; - t.fd = fd; + async function unpackArchive(in_file, file) { + const t = await createTmp(); + tmpfiles.push([t.fd, t.name]); - resolve(t); - }); - }); - } + connection.logdebug( + plugin, + `created tmp file: ${t.name} (fd=${t.fd}) for file ${file}`, + ); - async function unpackArchive (in_file, file) { - - const t = await createTmp(); - tmpfiles.push([t.fd, t.name]); - - connection.logdebug(plugin, `created tmp file: ${t.name} (fd=${t.fd}) for file ${file}`); - - const tws = fs.createWriteStream(t.name); - try { - // bsdtar seems to be asking for password if archive is encrypted workaround with --passphrase will end up - // with "Incorrect passphrase" for encrypted archives, but will be ignored with nonencrypted - await timeoutedSpawn(plugin.bsdtar_path, - ['-Oxf', in_file, `--include=${file}`, '--passphrase', 'deliberately_invalid'], - { - 'cwd': '/tmp', - 'env': { - 'LANG': 'C' - }, - }, - tws - ); - } - catch (e) { - connection.logdebug(plugin, e); - } - return t; + const tws = fs.createWriteStream(t.name); + try { + // bsdtar seems to be asking for password if archive is encrypted workaround with --passphrase will end up + // with "Incorrect passphrase" for encrypted archives, but will be ignored with nonencrypted + await timeoutedSpawn( + plugin.bsdtar_path, + [ + '-Oxf', + in_file, + `--include=${file}`, + '--passphrase', + 'deliberately_invalid', + ], + { + cwd: '/tmp', + env: { + LANG: 'C', + }, + }, + tws, + ); + } catch (e) { + connection.logdebug(plugin, e); } + return t; + } - async function listArchive (in_file) { - try { - const lines = await timeoutedSpawn(plugin.bsdtar_path, ['-tf', in_file, '--passphrase', 'deliberately_invalid'], { - 'cwd': '/tmp', - 'env': {'LANG': 'C'}, - }); - - // Extract non-empty filenames - return lines.split(/\r?\n/).filter(fl => fl); - } - catch (e) { - connection.logdebug(plugin, e); - return []; - } + async function listArchive(in_file) { + try { + const lines = await timeoutedSpawn( + plugin.bsdtar_path, + ['-tf', in_file, '--passphrase', 'deliberately_invalid'], + { + cwd: '/tmp', + env: { LANG: 'C' }, + }, + ); + + // Extract non-empty filenames + return lines.split(/\r?\n/).filter((fl) => fl); + } catch (e) { + connection.logdebug(plugin, e); + return []; } - - - function deleteTempFiles () { - tmpfiles.forEach(t => { - fs.close(t[0], () => { - connection.logdebug(plugin, `closed fd: ${t[0]}`); - fs.unlink(t[1], () => { - connection.logdebug(plugin, `deleted tempfile: ${t[1]}`); - }); - }); + } + + function deleteTempFiles() { + tmpfiles.forEach((t) => { + fs.close(t[0], () => { + connection.logdebug(plugin, `closed fd: ${t[0]}`); + fs.unlink(t[1], () => { + connection.logdebug(plugin, `deleted tempfile: ${t[1]}`); }); - } - - async function processFile (in_file, prefix, file, depth) { - let result = [(prefix ? `${prefix}/` : '') + file]; - - connection.logdebug(plugin, `found file: ${prefix ? `${prefix}/` : ''}${file} depth=${depth}`); - - if (!plugin.isArchive(path.extname(file.toLowerCase()))) { - return result; - } + }); + }); + } - connection.logdebug(plugin, `need to extract file: ${prefix ? `${prefix}/` : ''}${file}`); + async function processFile(in_file, prefix, file, depth) { + let result = [(prefix ? `${prefix}/` : '') + file]; - const t = await unpackArchive(in_file, file); + connection.logdebug( + plugin, + `found file: ${prefix ? `${prefix}/` : ''}${file} depth=${depth}`, + ); - // Recurse - try { - result = result.concat(await listFiles(t.name, (prefix ? `${prefix}/` : '') + file, depth + 1)); - } - catch (e) { - connection.logdebug(plugin, e); - } - - return result; + if (!plugin.isArchive(path.extname(file.toLowerCase()))) { + return result; } - async function listFiles (in_file, prefix, depth) { - const result = []; - depth = depth || 0; - - if (timeouted) { - connection.logdebug(plugin, `already timeouted, not going to process ${prefix ? `${prefix}/` : ''}${in_file}`); - return result; - } - - if (depth >= plugin.cfg.archive.max_depth) { - depthExceeded = true; - connection.logdebug(plugin, `hit maximum depth with ${prefix ? `${prefix}/` : ''}${in_file}`); - return result; - } + connection.logdebug( + plugin, + `need to extract file: ${prefix ? `${prefix}/` : ''}${file}`, + ); - const fls = await listArchive(in_file); - await Promise.all(fls.map(async (file) => { - const output = await processFile(in_file, prefix, file, depth + 1); - result.push(...output); - })); + const t = await unpackArchive(in_file, file); - connection.loginfo(plugin, `finish (${prefix ? `${prefix}/` : ''}${in_file}): count=${result.length} depth=${depth}`); - return result; + // Recurse + try { + result = result.concat( + await listFiles(t.name, (prefix ? `${prefix}/` : '') + file, depth + 1), + ); + } catch (e) { + connection.logdebug(plugin, e); } - setTimeout(() => { - timeouted = true; - }, plugin.cfg.timeout); + return result; + } - const files = await listFiles(f, archive_file_name); - deleteTempFiles(); + async function listFiles(in_file, prefix, depth) { + const result = []; + depth = depth || 0; if (timeouted) { - cb(new Error("archive extraction timeouted"), files); - } - else if (depthExceeded) { - cb(new Error("maximum archive depth exceeded"), files); + connection.logdebug( + plugin, + `already timeouted, not going to process ${prefix ? `${prefix}/` : ''}${in_file}`, + ); + return result; } - else if (encrypted) { - cb(new Error("archive encrypted"), files); - } - else { - cb(null, files); - } -} -exports.compute_and_log_md5sum = function (connection, ctype, filename, stream) { - const plugin = this; - const md5 = crypto.createHash('md5'); - let bytes = 0; - - stream.on('data', (data) => { - md5.update(data); - bytes += data.length; - }) + if (depth >= plugin.cfg.archive.max_depth) { + depthExceeded = true; + connection.logdebug( + plugin, + `hit maximum depth with ${prefix ? `${prefix}/` : ''}${in_file}`, + ); + return result; + } - stream.once('end', () => { - stream.pause(); + const fls = await listArchive(in_file); + await Promise.all( + fls.map(async (file) => { + const output = await processFile(in_file, prefix, file, depth + 1); + result.push(...output); + }), + ); + + connection.loginfo( + plugin, + `finish (${prefix ? `${prefix}/` : ''}${in_file}): count=${result.length} depth=${depth}`, + ); + return result; + } + + setTimeout(() => { + timeouted = true; + }, plugin.cfg.timeout); + + const files = await listFiles(f, archive_file_name); + deleteTempFiles(); + + if (timeouted) { + cb(new Error('archive extraction timeouted'), files); + } else if (depthExceeded) { + cb(new Error('maximum archive depth exceeded'), files); + } else if (encrypted) { + cb(new Error('archive encrypted'), files); + } else { + cb(null, files); + } +}; + +exports.compute_and_log_md5sum = function ( + connection, + ctype, + filename, + stream, +) { + const plugin = this; + const md5 = crypto.createHash('md5'); + let bytes = 0; + + stream.on('data', (data) => { + md5.update(data); + bytes += data.length; + }); + + stream.once('end', () => { + stream.pause(); - const digest = md5.digest('hex') || ''; - const ct = plugin.content_type(connection, ctype) + const digest = md5.digest('hex') || ''; + const ct = plugin.content_type(connection, ctype); - connection.transaction.notes.attachments.push({ - ctype: ct, - filename, - extension: plugin.file_extension(filename), - md5: digest, - }); + connection.transaction.notes.attachments.push({ + ctype: ct, + filename, + extension: plugin.file_extension(filename), + md5: digest, + }); - connection.transaction.results.push(plugin, { - attach: { - file: filename, - ctype: ct, - md5: digest, - bytes, - }, - emit: true, - }) - connection.loginfo(plugin, `file="${filename}" ctype="${ctype}" md5=${digest} bytes=${bytes}`); - }) -} + connection.transaction.results.push(plugin, { + attach: { + file: filename, + ctype: ct, + md5: digest, + bytes, + }, + emit: true, + }); + connection.loginfo( + plugin, + `file="${filename}" ctype="${ctype}" md5=${digest} bytes=${bytes}`, + ); + }); +}; exports.file_extension = function (filename) { - if (!filename) return ''; + if (!filename) return ''; - const ext_match = filename.match(/\.([^. ]+)$/); - if (!ext_match || !ext_match[1]) return ''; + const ext_match = filename.match(/\.([^. ]+)$/); + if (!ext_match || !ext_match[1]) return ''; - return ext_match[1].toLowerCase(); -} + return ext_match[1].toLowerCase(); +}; exports.content_type = function (connection, ctype) { - const plugin = this; + const plugin = this; - const ct_match = ctype.match(plugin.re.ct); - if (!ct_match || !ct_match[1]) return 'unknown/unknown'; + const ct_match = ctype.match(plugin.re.ct); + if (!ct_match || !ct_match[1]) return 'unknown/unknown'; - connection.logdebug(plugin, `found content type: ${ct_match[1]}`); - connection.transaction.notes.attachment_ctypes.push(ct_match[1]); - return ct_match[1].toLowerCase(); -} + connection.logdebug(plugin, `found content type: ${ct_match[1]}`); + connection.transaction.notes.attachment_ctypes.push(ct_match[1]); + return ct_match[1].toLowerCase(); +}; exports.isArchive = function (file_ext) { - // check with and without the dot prefixed - if (this.cfg.archive.exts[file_ext]) return true; - if (file_ext[0] === '.' && this.cfg.archive.exts[file_ext.substring(1)]) return true; - return false; -} - -exports.start_attachment = function (connection, ctype, filename, body, stream) { - - const plugin = this; - const txn = connection?.transaction; - - function next () { - if (txn?.notes?.attachment_next && txn.notes.attachment_count === 0) { - return txn.notes.attachment_next(); - } + // check with and without the dot prefixed + if (this.cfg.archive.exts[file_ext]) return true; + if (file_ext[0] === '.' && this.cfg.archive.exts[file_ext.substring(1)]) + return true; + return false; +}; + +exports.start_attachment = function ( + connection, + ctype, + filename, + body, + stream, +) { + const plugin = this; + const txn = connection?.transaction; + + function next() { + if (txn?.notes?.attachment_next && txn.notes.attachment_count === 0) { + return txn.notes.attachment_next(); } + } - let file_ext = '.unknown' + let file_ext = '.unknown'; - if (filename) { - file_ext = plugin.file_extension(filename); - txn.notes.attachment_files.push(filename); - } + if (filename) { + file_ext = plugin.file_extension(filename); + txn.notes.attachment_files.push(filename); + } - plugin.compute_and_log_md5sum(connection, ctype, filename, stream); + plugin.compute_and_log_md5sum(connection, ctype, filename, stream); - if (!filename) return; + if (!filename) return; - connection.logdebug(plugin, `found attachment file: ${filename}`); - // See if filename extension matches archive extension list - if (archives_disabled || !plugin.isArchive(file_ext)) return; + connection.logdebug(plugin, `found attachment file: ${filename}`); + // See if filename extension matches archive extension list + if (archives_disabled || !plugin.isArchive(file_ext)) return; - connection.logdebug(plugin, `found ${file_ext} on archive list`); - txn.notes.attachment_count++; + connection.logdebug(plugin, `found ${file_ext} on archive list`); + txn.notes.attachment_count++; - stream.connection = connection; - stream.pause(); + stream.connection = connection; + stream.pause(); - tmp.file((err, fn, fd) => { - function cleanup () { - fs.close(fd, () => { - connection.logdebug(plugin, `closed fd: ${fd}`); - fs.unlink(fn, () => { - connection.logdebug(plugin, `unlinked: ${fn}`); - }); - }); - stream.resume(); - } + tmp.file((err, fn, fd) => { + function cleanup() { + fs.close(fd, () => { + connection.logdebug(plugin, `closed fd: ${fd}`); + fs.unlink(fn, () => { + connection.logdebug(plugin, `unlinked: ${fn}`); + }); + }); + stream.resume(); + } + if (err) { + txn.notes.attachment_result = [DENYSOFT, err.message]; + connection.logerror(plugin, `Error writing tempfile: ${err.message}`); + txn.notes.attachment_count--; + cleanup(); + stream.resume(); + return next(); + } + connection.logdebug( + plugin, + `Got tmpfile: attachment="${filename}" tmpfile="${fn}" fd={fd}`, + ); + + const ws = fs.createWriteStream(fn); + stream.pipe(ws); + stream.resume(); + + ws.on('error', (error) => { + txn.notes.attachment_count--; + txn.notes.attachment_result = [DENYSOFT, error.message]; + connection.logerror(plugin, `stream error: ${error.message}`); + cleanup(); + next(); + }); + + ws.on('close', () => { + connection.logdebug(plugin, 'end of stream reached'); + connection.pause(); + plugin.unarchive_recursive(connection, fn, filename, (error, files) => { + txn.notes.attachment_count--; + cleanup(); if (err) { - txn.notes.attachment_result = [ DENYSOFT, err.message ]; - connection.logerror(plugin, `Error writing tempfile: ${err.message}`); - txn.notes.attachment_count--; - cleanup(); - stream.resume(); - return next(); + connection.logerror(plugin, error.message); + if (err.message === 'maximum archive depth exceeded') { + txn.notes.attachment_result = [ + DENY, + 'Message contains nested archives exceeding the maximum depth', + ]; + } else if (/Encrypted file is unsupported/i.test(error.message)) { + if (!plugin.cfg.main.allow_encrypted_archives) { + txn.notes.attachment_result = [ + DENY, + 'Message contains encrypted archive', + ]; + } + } else if (/Mac metadata is too large/i.test(error.message)) { + // Skip this error + } else { + if (!connection.relaying) { + txn.notes.attachment_result = [ + DENYSOFT, + 'Error unpacking archive', + ]; + } + } } - connection.logdebug(plugin, `Got tmpfile: attachment="${filename}" tmpfile="${fn}" fd={fd}`); - - const ws = fs.createWriteStream(fn); - stream.pipe(ws); - stream.resume(); - - ws.on('error', (error) => { - txn.notes.attachment_count--; - txn.notes.attachment_result = [ DENYSOFT, error.message ]; - connection.logerror(plugin, `stream error: ${error.message}`); - cleanup(); - next(); - }); - ws.on('close', () => { - connection.logdebug(plugin, 'end of stream reached'); - connection.pause(); - plugin.unarchive_recursive(connection, fn, filename, (error, files) => { - txn.notes.attachment_count--; - cleanup(); - if (err) { - connection.logerror(plugin, error.message); - if (err.message === 'maximum archive depth exceeded') { - txn.notes.attachment_result = [ DENY, 'Message contains nested archives exceeding the maximum depth' ]; - } - else if (/Encrypted file is unsupported/i.test(error.message)) { - if (!plugin.cfg.main.allow_encrypted_archives) { - txn.notes.attachment_result = [ DENY, 'Message contains encrypted archive' ]; - } - } - else if (/Mac metadata is too large/i.test(error.message)) { - // Skip this error - } - else { - if (!connection.relaying) { - txn.notes.attachment_result = [ DENYSOFT, 'Error unpacking archive' ]; - } - } - } - - txn.notes.attachment_archive_files = txn.notes.attachment_archive_files.concat(files); - connection.resume(); - next(); - }); - }); + txn.notes.attachment_archive_files = + txn.notes.attachment_archive_files.concat(files); + connection.resume(); + next(); + }); }); -} - + }); +}; exports.hook_data = function (next, connection) { - const plugin = this; - if (!connection?.transaction) return next(); - const txn = connection?.transaction; - - txn.parse_body = 1; - txn.notes.attachment_count = 0; - txn.notes.attachments = []; - txn.notes.attachment_ctypes = []; - txn.notes.attachment_files = []; - txn.notes.attachment_archive_files = []; - txn.attachment_hooks((ctype, filename, body, stream) => { - plugin.start_attachment(connection, ctype, filename, body, stream); - }); - next(); -} + const plugin = this; + if (!connection?.transaction) return next(); + const txn = connection?.transaction; + + txn.parse_body = 1; + txn.notes.attachment_count = 0; + txn.notes.attachments = []; + txn.notes.attachment_ctypes = []; + txn.notes.attachment_files = []; + txn.notes.attachment_archive_files = []; + txn.attachment_hooks((ctype, filename, body, stream) => { + plugin.start_attachment(connection, ctype, filename, body, stream); + }); + next(); +}; exports.disallowed_extensions = function (txn) { - const plugin = this; - if (!plugin.re.bad_extn) return false; - - let bad = false; - [ txn.notes.attachment_files, txn.notes.attachment_archive_files ].forEach(items => { - if (bad) return; - if (!items || !Array.isArray(items)) return; - for (const extn of items) { - if (!plugin.re.bad_extn.test(extn)) continue; - bad = extn.split('.').slice(0).pop(); - break; - } - }) - - return bad; -} + const plugin = this; + if (!plugin.re.bad_extn) return false; + + let bad = false; + [txn.notes.attachment_files, txn.notes.attachment_archive_files].forEach( + (items) => { + if (bad) return; + if (!items || !Array.isArray(items)) return; + for (const extn of items) { + if (!plugin.re.bad_extn.test(extn)) continue; + bad = extn.split('.').slice(0).pop(); + break; + } + }, + ); + + return bad; +}; exports.check_attachments = function (next, connection) { - const txn = connection?.transaction; - if (!txn) return next(); - - // Check for any stored errors from the attachment hooks - if (txn.notes.attachment_result) { - const result = txn.notes.attachment_result; - return next(result[0], result[1]); - } - - const ctypes = txn.notes.attachment_ctypes; - - // Add in any content type from message body - const body = txn.body; - let body_ct; - if (body && (body_ct = this.re.ct.exec(body.header.get('content-type')))) { - connection.logdebug(this, `found content type: ${body_ct[1]}`); - ctypes.push(body_ct[1]); - } - // MIME parts - if (body && body.children) { - for (let c=0; c { - if (connection?.transaction?.notes?.attachment_count > 0) { - connection.transaction.notes.attachment_next = next; - } - else { - next(); - } -} + if (connection?.transaction?.notes?.attachment_count > 0) { + connection.transaction.notes.attachment_next = next; + } else { + next(); + } +}; diff --git a/package.json b/package.json index 6f64918..029e732 100644 --- a/package.json +++ b/package.json @@ -3,33 +3,40 @@ "name": "haraka-plugin-attachment", "license": "MIT", "description": "A message attachment scanning plugin for Haraka", - "version": "1.1.0", + "version": "1.1.1", "homepage": "https://github.com/haraka/haraka-plugin-attachment", "repository": { "type": "git", "url": "git@github.com:haraka/haraka-plugin-attachment.git" }, "main": "index.js", + "files": [ + "CHANGELOG.md", + "config" + ], "engines": { "node": ">= 12" }, "scripts": { - "test": "npx mocha --exit", - "lint": "npx eslint *.js test", - "lintfix": "npx eslint --fix *.js test", - "versions": "npx dependency-version-checker check" + "format": "npm run prettier:fix && npm run lint:fix", + "lint": "npx eslint@^8 *.js test", + "lint:fix": "npx eslint@^8 *.js test --fix", + "prettier": "npx prettier . --check", + "prettier:fix": "npx prettier . --write --log-level=warn", + "test": "npx mocha@10", + "versions": "npx @msimerson/dependency-version-checker check", + "versions:fix": "npx @msimerson/dependency-version-checker update && npm run prettier:fix" }, "dependencies": { "tmp": "0.2.3", "haraka-constants": "1.0.6", - "haraka-utils": "1.0.3" + "haraka-utils": "1.1.2" }, "optionalDependencies": {}, "devDependencies": { - "eslint-plugin-haraka": "1.0.15", + "@haraka/eslint-config": "1.1.3", "haraka-config": "1.1.0", - "haraka-test-fixtures": "1.3.3", - "mocha": "10.3.0" + "haraka-test-fixtures": "1.3.5" }, "bugs": { "url": "https://github.com/haraka/haraka-plugin-attachment/issues" diff --git a/test/index.js b/test/index.js index 61cd559..8b5ef94 100644 --- a/test/index.js +++ b/test/index.js @@ -1,275 +1,345 @@ 'use strict'; -const assert = require('assert') -const fs = require('fs') -const path = require('path'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); const fixtures = require('haraka-test-fixtures'); const attach = new fixtures.plugin('index'); -function _set_up (done) { +function _set_up(done) { + this.plugin = new fixtures.plugin('attachment'); + this.plugin.cfg = {}; + this.plugin.cfg.timeout = 10; - this.plugin = new fixtures.plugin('attachment'); - this.plugin.cfg = {}; - this.plugin.cfg.timeout = 10; + this.connection = fixtures.connection.createConnection(); + this.connection.init_transaction(); - this.connection = fixtures.connection.createConnection() - this.connection.init_transaction(); + this.connection.logdebug = function (where, message) { + if (process.env.DEBUG) console.log(message); + }; + this.connection.loginfo = function (where, message) { + console.log(message); + }; - this.connection.logdebug = function (where, message) { if (process.env.DEBUG) console.log(message); }; - this.connection.loginfo = function (where, message) { console.log(message); }; + this.directory = path.resolve(__dirname, 'fixtures'); - this.directory = path.resolve(__dirname, 'fixtures'); - - // finds bsdtar - this.plugin.register(); - this.plugin.hook_init_master(done); + // finds bsdtar + this.plugin.register(); + this.plugin.hook_init_master(done); } describe('options_to_object', function () { - it('converts string to object', function () { - const expected = {'gz': true, 'zip': true}; - assert.deepEqual(expected, attach.options_to_object('gz zip')); - assert.deepEqual(expected, attach.options_to_object('gz,zip')); - assert.deepEqual(expected, attach.options_to_object(' gz , zip ')); - }) -}) + it('converts string to object', function () { + const expected = { gz: true, zip: true }; + assert.deepEqual(expected, attach.options_to_object('gz zip')); + assert.deepEqual(expected, attach.options_to_object('gz,zip')); + assert.deepEqual(expected, attach.options_to_object(' gz , zip ')); + }); +}); describe('load_dissallowed_extns', function () { - it('loads comma separated options', function () { - attach.cfg = { main: { disallowed_extensions: 'exe,scr' } }; - attach.load_dissallowed_extns(); - - assert.ok(attach.re.bad_extn); - assert.ok(attach.re.bad_extn.test('bad.scr')); - }) - - it('loads space separated options', function () { - attach.cfg = { main: { disallowed_extensions: 'dll tnef' } }; - attach.load_dissallowed_extns(); - assert.ok(attach.re.bad_extn); - assert.ok(attach.re.bad_extn.test('bad.dll')); - }) -}) + it('loads comma separated options', function () { + attach.cfg = { main: { disallowed_extensions: 'exe,scr' } }; + attach.load_dissallowed_extns(); + + assert.ok(attach.re.bad_extn); + assert.ok(attach.re.bad_extn.test('bad.scr')); + }); + + it('loads space separated options', function () { + attach.cfg = { main: { disallowed_extensions: 'dll tnef' } }; + attach.load_dissallowed_extns(); + assert.ok(attach.re.bad_extn); + assert.ok(attach.re.bad_extn.test('bad.dll')); + }); +}); describe('file_extension', function () { - it('returns a file extension from a filename', function () { - assert.equal('ext', attach.file_extension('file.ext')); - }) + it('returns a file extension from a filename', function () { + assert.equal('ext', attach.file_extension('file.ext')); + }); - it('returns empty string for no extension', function () { - assert.equal('', attach.file_extension('file')); - }) -}) + it('returns empty string for no extension', function () { + assert.equal('', attach.file_extension('file')); + }); +}); describe('disallowed_extensions', function () { - it('blocks filename extensions in attachment_files', function (done) { - - attach.cfg = { main: { disallowed_extensions: 'exe;scr' } }; - attach.load_dissallowed_extns(); + it('blocks filename extensions in attachment_files', function (done) { + attach.cfg = { main: { disallowed_extensions: 'exe;scr' } }; + attach.load_dissallowed_extns(); - const connection = fixtures.connection.createConnection(); - connection.init_transaction(); - const txn = connection.transaction; + const connection = fixtures.connection.createConnection(); + connection.init_transaction(); + const txn = connection.transaction; - txn.notes.attachment_files = ['naughty.exe']; - assert.equal('exe', attach.disallowed_extensions(txn)); + txn.notes.attachment_files = ['naughty.exe']; + assert.equal('exe', attach.disallowed_extensions(txn)); - txn.notes.attachment_files = ['good.pdf', 'naughty.exe']; - assert.equal('exe', attach.disallowed_extensions(txn)); - done(); - }) + txn.notes.attachment_files = ['good.pdf', 'naughty.exe']; + assert.equal('exe', attach.disallowed_extensions(txn)); + done(); + }); - it('blocks filename extensions in archive_files', function (done) { + it('blocks filename extensions in archive_files', function (done) { + attach.cfg = { main: { disallowed_extensions: 'dll tnef' } }; + attach.load_dissallowed_extns(); - attach.cfg = { main: { disallowed_extensions: 'dll tnef' } }; - attach.load_dissallowed_extns(); + const connection = fixtures.connection.createConnection(); + connection.init_transaction(); + const txn = connection.transaction; + txn.notes.attachment = {}; - const connection = fixtures.connection.createConnection(); - connection.init_transaction(); - const txn = connection.transaction; - txn.notes.attachment = {}; + txn.notes.attachment_archive_files = ['icky.tnef']; + assert.equal('tnef', attach.disallowed_extensions(txn)); - txn.notes.attachment_archive_files = ['icky.tnef']; - assert.equal('tnef', attach.disallowed_extensions(txn)); + txn.notes.attachment_archive_files = ['good.pdf', 'naughty.dll']; + assert.equal('dll', attach.disallowed_extensions(txn)); - txn.notes.attachment_archive_files = ['good.pdf', 'naughty.dll']; - assert.equal('dll', attach.disallowed_extensions(txn)); + txn.notes.attachment_archive_files = ['good.pdf', 'better.png']; + assert.equal(false, attach.disallowed_extensions(txn)); - txn.notes.attachment_archive_files = ['good.pdf', 'better.png']; - assert.equal(false, attach.disallowed_extensions(txn)); - - done(); - }) -}) + done(); + }); +}); describe('load_n_compile_re', function () { - it('loads regex lines from file, compiles to array', function () { - attach.load_n_compile_re('test', 'attachment.filename.regex'); - assert.ok(attach.re.test); - assert.ok(attach.re.test[0].test('foo.exe')); - }) -}) + it('loads regex lines from file, compiles to array', function () { + attach.load_n_compile_re('test', 'attachment.filename.regex'); + assert.ok(attach.re.test); + assert.ok(attach.re.test[0].test('foo.exe')); + }); +}); describe('check_items_against_regexps', function () { - it('positive', function () { - attach.load_n_compile_re('test', 'attachment.filename.regex'); - - assert.ok(attach.check_items_against_regexps(['file.exe'], attach.re.test)); - assert.ok(attach.check_items_against_regexps(['fine.pdf','awful.exe'], attach.re.test)); - }) - - it('negative', function () { - attach.load_n_compile_re('test', 'attachment.filename.regex'); - - assert.ok(!attach.check_items_against_regexps(['file.png'], attach.re.test)); - assert.ok(!attach.check_items_against_regexps(['fine.pdf','godiva.chocolate'], attach.re.test)); - }) -}) + it('positive', function () { + attach.load_n_compile_re('test', 'attachment.filename.regex'); + + assert.ok(attach.check_items_against_regexps(['file.exe'], attach.re.test)); + assert.ok( + attach.check_items_against_regexps( + ['fine.pdf', 'awful.exe'], + attach.re.test, + ), + ); + }); + + it('negative', function () { + attach.load_n_compile_re('test', 'attachment.filename.regex'); + + assert.ok( + !attach.check_items_against_regexps(['file.png'], attach.re.test), + ); + assert.ok( + !attach.check_items_against_regexps( + ['fine.pdf', 'godiva.chocolate'], + attach.re.test, + ), + ); + }); +}); describe('isArchive', function () { - it('zip', function () { - attach.load_attachment_ini(); - // console.log(attach.cfg.archive); - assert.equal(true, attach.isArchive('.zip')); - assert.equal(true, attach.isArchive('zip')); - }) - - it('png', function () { - attach.load_attachment_ini(); - assert.equal(false, attach.isArchive('.png')); - assert.equal(false, attach.isArchive('png')); - }) -}) + it('zip', function () { + attach.load_attachment_ini(); + // console.log(attach.cfg.archive); + assert.equal(true, attach.isArchive('.zip')); + assert.equal(true, attach.isArchive('zip')); + }); + + it('png', function () { + attach.load_attachment_ini(); + assert.equal(false, attach.isArchive('.png')); + assert.equal(false, attach.isArchive('png')); + }); +}); describe('unarchive_recursive', function () { - beforeEach(_set_up) - - it('3layers', function (done) { - if (!this.plugin.bsdtar_path) return done() - this.plugin.unarchive_recursive(this.connection, `${this.directory}/3layer.zip`, '3layer.zip', (e, files) => { - assert.equal(e, null); - assert.equal(files.length, 3); - - done() - }); - }) - - it('empty.gz', function (done) { - if (!this.plugin.bsdtar_path) return done() - this.plugin.unarchive_recursive(this.connection, `${this.directory}/empty.gz`, 'empty.gz', (e, files) => { - assert.equal(e, null); - assert.equal(files.length, 0); - done() - }); - }) - - it('encrypt.zip', function (done) { - if (!this.plugin.bsdtar_path) return done() - this.plugin.unarchive_recursive(this.connection, `${this.directory}/encrypt.zip`, 'encrypt.zip', (e, files) => { - // we see files list in encrypted zip, but we can't extract so no error here - assert.equal(e, null); - assert.equal(files?.length, 1); - done() - }); - }) - - it('encrypt-recursive.zip', function (done) { - if (!this.plugin.bsdtar_path) return done() - this.plugin.unarchive_recursive(this.connection, `${this.directory}/encrypt-recursive.zip`, 'encrypt-recursive.zip', (e, files) => { - // we can't extract encrypted file in encrypted zip so error here - assert.equal(true, e.message.includes('encrypted')); - assert.equal(files.length, 1); - done() - }); - }) - - it('gz-in-zip.zip', function (done) { - if (!this.plugin.bsdtar_path) return done() - - this.plugin.unarchive_recursive(this.connection, `${this.directory}/gz-in-zip.zip`, 'gz-in-zip.zip', (e, files) => { - // gz is not listable in bsdtar - assert.equal(e, null); - assert.equal(files.length, 1); - done() - }); - }) - - it('invalid.zip', function (done) { - if (!this.plugin.bsdtar_path) return done() - this.plugin.unarchive_recursive(this.connection, `${this.directory}/invalid.zip`, 'invalid.zip', (e, files) => { - // invalid zip is assumed to be just file, so error of bsdtar is ignored - assert.equal(e, null); - assert.equal(files.length, 0); - done() - }); - }) - - it('invalid-in-valid.zip', function (done) { - if (!this.plugin.bsdtar_path) return done() - this.plugin.unarchive_recursive(this.connection, `${this.directory}/invalid-in-valid.zip`, 'invalid-in-valid.zip', (e, files) => { - assert.equal(e, null); - assert.equal(files.length, 1); - done() - }); - }) - - it('password.zip', function (done) { - if (!this.plugin.bsdtar_path) return done() - this.plugin.unarchive_recursive(this.connection, `${this.directory}/password.zip`, 'password.zip', (e, files) => { - // we see files list in encrypted zip, but we can't extract so no error here - assert.equal(e, null); - assert.equal(files.length, 1); - done() - }); - }) - - it('valid.zip', function (done) { - if (!this.plugin.bsdtar_path) return done() - this.plugin.unarchive_recursive(this.connection, `${this.directory}/valid.zip`, 'valid.zip', (e, files) => { - assert.equal(e, null); - assert.equal(files.length, 1); - done() - }); - }) - - it('timeout', function (done) { - if (!this.plugin.bsdtar_path) return done() - this.plugin.cfg.timeout = 0; - this.plugin.unarchive_recursive(this.connection, `${this.directory}/encrypt-recursive.zip`, 'encrypt-recursive.zip', (e, files) => { - assert.ok(true, e.message.includes('timeout')); - assert.equal(files.length, 0); - done() - }); - }) -}) - -describe('start_attachment', function () { - beforeEach(_set_up) - - it('finds an message attachment', function (done) { - // const pi = this.plugin - const txn = this.connection.transaction - - this.plugin.hook_data(function () { - - // console.log(pi) - const msgPath = path.join(__dirname, 'fixtures', 'haraka-icon-attach.eml') - // console.log(`msgPath: ${msgPath}`) - const specimen = fs.readFileSync(msgPath, 'utf8'); + beforeEach(_set_up); + + it('3layers', function (done) { + if (!this.plugin.bsdtar_path) return done(); + this.plugin.unarchive_recursive( + this.connection, + `${this.directory}/3layer.zip`, + '3layer.zip', + (e, files) => { + assert.equal(e, null); + assert.equal(files.length, 3); - for (const line of specimen.split(/\r?\n/g)) { - txn.add_data(`${line}\r\n`); - } - - txn.end_data(); - txn.ensure_body() + done(); + }, + ); + }); + + it('empty.gz', function (done) { + if (!this.plugin.bsdtar_path) return done(); + this.plugin.unarchive_recursive( + this.connection, + `${this.directory}/empty.gz`, + 'empty.gz', + (e, files) => { + assert.equal(e, null); + assert.equal(files.length, 0); + done(); + }, + ); + }); + + it('encrypt.zip', function (done) { + if (!this.plugin.bsdtar_path) return done(); + this.plugin.unarchive_recursive( + this.connection, + `${this.directory}/encrypt.zip`, + 'encrypt.zip', + (e, files) => { + // we see files list in encrypted zip, but we can't extract so no error here + assert.equal(e, null); + assert.equal(files?.length, 1); + done(); + }, + ); + }); + + it('encrypt-recursive.zip', function (done) { + if (!this.plugin.bsdtar_path) return done(); + this.plugin.unarchive_recursive( + this.connection, + `${this.directory}/encrypt-recursive.zip`, + 'encrypt-recursive.zip', + (e, files) => { + // we can't extract encrypted file in encrypted zip so error here + assert.equal(true, e.message.includes('encrypted')); + assert.equal(files.length, 1); + done(); + }, + ); + }); + + it('gz-in-zip.zip', function (done) { + if (!this.plugin.bsdtar_path) return done(); + + this.plugin.unarchive_recursive( + this.connection, + `${this.directory}/gz-in-zip.zip`, + 'gz-in-zip.zip', + (e, files) => { + // gz is not listable in bsdtar + assert.equal(e, null); + assert.equal(files.length, 1); + done(); + }, + ); + }); + + it('invalid.zip', function (done) { + if (!this.plugin.bsdtar_path) return done(); + this.plugin.unarchive_recursive( + this.connection, + `${this.directory}/invalid.zip`, + 'invalid.zip', + (e, files) => { + // invalid zip is assumed to be just file, so error of bsdtar is ignored + assert.equal(e, null); + assert.equal(files.length, 0); + done(); + }, + ); + }); + + it('invalid-in-valid.zip', function (done) { + if (!this.plugin.bsdtar_path) return done(); + this.plugin.unarchive_recursive( + this.connection, + `${this.directory}/invalid-in-valid.zip`, + 'invalid-in-valid.zip', + (e, files) => { + assert.equal(e, null); + assert.equal(files.length, 1); + done(); + }, + ); + }); + + it('password.zip', function (done) { + if (!this.plugin.bsdtar_path) return done(); + this.plugin.unarchive_recursive( + this.connection, + `${this.directory}/password.zip`, + 'password.zip', + (e, files) => { + // we see files list in encrypted zip, but we can't extract so no error here + assert.equal(e, null); + assert.equal(files.length, 1); + done(); + }, + ); + }); + + it('valid.zip', function (done) { + if (!this.plugin.bsdtar_path) return done(); + this.plugin.unarchive_recursive( + this.connection, + `${this.directory}/valid.zip`, + 'valid.zip', + (e, files) => { + assert.equal(e, null); + assert.equal(files.length, 1); + done(); + }, + ); + }); + + it('timeout', function (done) { + if (!this.plugin.bsdtar_path) return done(); + this.plugin.cfg.timeout = 0; + this.plugin.unarchive_recursive( + this.connection, + `${this.directory}/encrypt-recursive.zip`, + 'encrypt-recursive.zip', + (e, files) => { + assert.ok(true, e.message.includes('timeout')); + assert.equal(files.length, 0); + done(); + }, + ); + }); +}); - // console.dir(txn.message_stream) - assert.deepEqual(txn.message_stream.idx['Apple-Mail=_65C16661-5FA8-4757-B627-13E55C40C8D7'], { start: 5232, end: 6384 }) - done() - }, - this.connection) - }) -}) \ No newline at end of file +describe('start_attachment', function () { + beforeEach(_set_up); + + it('finds an message attachment', function (done) { + // const pi = this.plugin + const txn = this.connection.transaction; + + this.plugin.hook_data(function () { + // console.log(pi) + const msgPath = path.join( + __dirname, + 'fixtures', + 'haraka-icon-attach.eml', + ); + // console.log(`msgPath: ${msgPath}`) + const specimen = fs.readFileSync(msgPath, 'utf8'); + + for (const line of specimen.split(/\r?\n/g)) { + txn.add_data(`${line}\r\n`); + } + + txn.end_data(); + txn.ensure_body(); + + // console.dir(txn.message_stream) + assert.deepEqual( + txn.message_stream.idx[ + 'Apple-Mail=_65C16661-5FA8-4757-B627-13E55C40C8D7' + ], + { start: 5232, end: 6384 }, + ); + done(); + }, this.connection); + }); +});