├── bin └── .gitkeep ├── lib64 ├── .gitkeep └── pkgconfig │ └── .gitkeep ├── index.js ├── .jshintignore ├── test ├── resources │ └── EICAR-AV-Test ├── scanFile.js └── util │ ├── downloadFileFromHttp.js │ ├── downloadClamscanDbFiles.js │ ├── downloadFileFromUrl.js │ └── downloadFileFromBucket.js ├── .editorconfig ├── src ├── scanFile.js ├── index.js ├── util │ ├── notifySns.js │ ├── index.js │ ├── downloadFileFromBucket.js │ ├── downloadFileFromUrl.js │ └── downloadFileFromHttp.js ├── downloadClamscanDbFiles.js └── handler.js ├── .jscsrc ├── config ├── test.yaml └── default.yaml ├── .gitignore ├── package.json ├── README.md └── .jshintrc /bin/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib64/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib64/pkgconfig/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src'); 2 | 3 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | bin 4 | lib64 -------------------------------------------------------------------------------- /test/resources/EICAR-AV-Test: -------------------------------------------------------------------------------- 1 | X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | [*] 3 | end_of_line = lf 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | indent_style = space 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /src/scanFile.js: -------------------------------------------------------------------------------- 1 | var config = require('config'); 2 | var Clamscan = require('clamscan'); 3 | var clamscan = new Clamscan(config.get('clamscan')); 4 | 5 | module.exports = clamscan.is_infected.bind(clamscan); 6 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "node-style-guide", 3 | "requireCamelCaseOrUpperCaseIdentifiers": false, 4 | "requireTrailingComma": false, 5 | "excludeFiles": [ 6 | "node_modules/**", 7 | "coverage/**", 8 | "bin/**", 9 | "lib64/**" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | var downloadClamscanDbFiles = require('./downloadClamscanDbFiles'); 2 | var handler = require('./handler'); 3 | var scanFile = require('./scanFile'); 4 | 5 | module.exports = { 6 | downloadClamscanDbFiles: downloadClamscanDbFiles, 7 | handler: handler, 8 | scanFile: scanFile 9 | }; 10 | -------------------------------------------------------------------------------- /config/test.yaml: -------------------------------------------------------------------------------- 1 | sns-topic-arn: arn:aws:sns:test:test:test 2 | 3 | db-files: 4 | - test://test.test/test/test.1 5 | - test://test.test/test/test.2 6 | - test://test.test/test/test.3 7 | - test://test.test/test/test.4 8 | - test://test.test/test/test.5 9 | 10 | clamscan: 11 | testing_mode: true 12 | -------------------------------------------------------------------------------- /src/util/notifySns.js: -------------------------------------------------------------------------------- 1 | var AWS = require('aws-sdk'); 2 | var sns = new AWS.SNS(); 3 | 4 | module.exports = 5 | function(topicArn, message, callback) { 6 | sns 7 | .publish( 8 | { 9 | TopicArn: topicArn, 10 | Message: message 11 | }, 12 | callback 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | var downloadFileFromBucket = require('./downloadFileFromBucket'); 2 | var downloadFileFromHttp = require('./downloadFileFromHttp'); 3 | var downloadFileFromUrl = require('./downloadFileFromUrl'); 4 | var notifySns = require('./notifySns'); 5 | 6 | module.exports = { 7 | downloadFileFromBucket: downloadFileFromBucket, 8 | downloadFileFromHttp: downloadFileFromHttp, 9 | downloadFileFromUrl: downloadFileFromUrl, 10 | notifySns: notifySns 11 | }; 12 | -------------------------------------------------------------------------------- /test/scanFile.js: -------------------------------------------------------------------------------- 1 | var scanFile = require('../src/scanFile'); 2 | var path = require('path'); 3 | 4 | describe('scanFile', function() { 5 | describe('when given EICAR-AV-Test', function() { 6 | it('should return a virus', function(done) { 7 | var eicarAvTest = path.join(__dirname, 'resources', 'EICAR-AV-Test'); 8 | this.timeout(15000); 9 | scanFile(eicarAvTest, function(err, file, isInfected) { 10 | if (err) { 11 | return done(err); 12 | } 13 | file.should.be.exactly(eicarAvTest); 14 | isInfected.should.be.exactly(true); 15 | done(); 16 | }); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/downloadClamscanDbFiles.js: -------------------------------------------------------------------------------- 1 | var async = require('async'); 2 | var fs = require('fs'); 3 | var config = require('config'); 4 | var path = require('path'); 5 | var url = require('url'); 6 | var util = require('./util'); 7 | 8 | module.exports = function(callback) { 9 | async.each(config.get('db-files'), function(dbFile, next) { 10 | var urlPath = url.parse(dbFile); 11 | var filename = path.basename(urlPath.pathname); 12 | var file = path.join('/tmp', filename); 13 | fs.exists(file, function(exists) { 14 | console.log('%s exists: %s', file, exists); 15 | if (exists) { 16 | next(); 17 | } else { 18 | util.downloadFileFromUrl(dbFile, file, next); 19 | } 20 | }); 21 | }, callback); 22 | }; 23 | -------------------------------------------------------------------------------- /src/util/downloadFileFromBucket.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var fs = require('fs'); 3 | var AWS = require('aws-sdk'); 4 | var s3 = new AWS.S3({signatureVersion: 'v4'}); 5 | 6 | module.exports = function(bucket, key, file, callback) { 7 | if (!bucket.length || !key.length) { 8 | return callback(new Error(util.format( 9 | 'Error! Bucket: %s and Key: %s must be defined', 10 | bucket, 11 | key 12 | ))); 13 | } 14 | 15 | key = decodeURIComponent(key); 16 | 17 | s3 18 | .getObject({ 19 | Bucket: bucket, 20 | Key: key 21 | }) 22 | .createReadStream() 23 | .pipe(fs.createWriteStream(file)) 24 | .on('error', function(error) { 25 | console.log('Error downloading s3://%s/%s to %s', bucket, key, file); 26 | callback(error); 27 | }) 28 | .on('close', function() { 29 | console.log('Downloaded s3://%s/%s to %s', bucket, key, file); 30 | callback(null, file); 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/util/downloadFileFromUrl.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var url = require('url'); 3 | var validUrl = require('valid-url'); 4 | var downloadFileFromBucket = require('./downloadFileFromBucket'); 5 | var downloadFileFromHttp = require('./downloadFileFromHttp'); 6 | 7 | module.exports = function(downloadUrl, file, callback) { 8 | /*jscs:disable*/ 9 | if (!validUrl.is_uri(downloadUrl)) { 10 | /*jscs:enable*/ 11 | return callback(new Error(util.format('Error: Invalid uri %s', downloadUrl))); 12 | } 13 | 14 | console.log('Downloading %s to %s', downloadUrl, file); 15 | 16 | var parsedUrl = url.parse(downloadUrl); 17 | 18 | switch (parsedUrl.protocol) { 19 | case 's3:': { 20 | return downloadFileFromBucket( 21 | parsedUrl.hostname, 22 | parsedUrl.pathname.slice(1), 23 | file, 24 | callback 25 | ); 26 | } 27 | 28 | default: { 29 | return downloadFileFromHttp( 30 | downloadUrl, 31 | file, 32 | callback 33 | ); 34 | } 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /test/util/downloadFileFromHttp.js: -------------------------------------------------------------------------------- 1 | var sinon = require('sinon'); 2 | var request = require('request'); 3 | var config = require('config'); 4 | var fs = require('fs'); 5 | var temp = require('temp'); 6 | var downloadFileFromHttp = require('../../src/util/downloadFileFromHttp'); 7 | 8 | describe('downloadFileFromHttp', function() { 9 | var requestGetStub; 10 | 11 | beforeEach(function() { 12 | requestGetStub = sinon.stub(request, 'get') 13 | .returns(fs.createReadStream(config.get('clamscan.clamscan.path'))); 14 | }); 15 | 16 | afterEach(function() { 17 | requestGetStub.restore(); 18 | }); 19 | 20 | describe('when files do not exist', function() { 21 | it('should download files', function(done) { 22 | downloadFileFromHttp( 23 | 'test://test.test/test/test', 24 | temp.path(), 25 | function(err) { 26 | if (err) { 27 | return done(err); 28 | } 29 | 30 | requestGetStub.calledOnce.should.be.exactly(true); 31 | done(); 32 | } 33 | ); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/util/downloadFileFromHttp.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var fs = require('fs'); 3 | var fse = require('fs-extra'); 4 | var request = require('request'); 5 | 6 | module.exports = function(downloadUrl, file, callback) { 7 | var fileStream = fs.createWriteStream(file); 8 | 9 | function closeStream(err) { 10 | if (err) { 11 | return fse.remove(file, function(error) { 12 | if (error) { 13 | return callback(error); 14 | } 15 | callback(err); 16 | }); 17 | } 18 | 19 | callback(null, file); 20 | } 21 | 22 | fileStream.on('finish', closeStream); 23 | 24 | request.get(downloadUrl) 25 | .on('response', function(response) { 26 | console.log('Finished downloading %s. Response %j', file, response); 27 | if (response.statusCode !== 200) { 28 | throw new Error( 29 | util.format('Error: status code %d', response.statusCode) 30 | ); 31 | } 32 | }) 33 | .on('error', function(err) { 34 | console.error('Error downloading %s: %s', downloadUrl, err); 35 | fileStream.removeListener('finish', closeStream.bind(null, err)); 36 | callback(err); 37 | }) 38 | .pipe(fileStream); 39 | }; 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Configuration 2 | config/local* 3 | config/production 4 | 5 | # Local clamscan files 6 | *.zip 7 | bin/ 8 | etc/ 9 | include/ 10 | lib64/ 11 | sbin/ 12 | share/ 13 | 14 | # node-lambda configuration 15 | context.json 16 | event.json 17 | event_sources.json 18 | deploy.env 19 | 20 | # Logs 21 | logs 22 | *.log 23 | npm-debug.log* 24 | 25 | # Runtime data 26 | pids 27 | *.pid 28 | *.seed 29 | 30 | # Directory for instrumented libs generated by jscoverage/JSCover 31 | lib-cov 32 | 33 | # Coverage directory used by tools like istanbul 34 | coverage 35 | 36 | # nyc test coverage 37 | .nyc_output 38 | 39 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 40 | .grunt 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (http://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories 49 | node_modules 50 | jspm_packages 51 | 52 | # Yarn lock file 53 | yarn.lock 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional REPL history 59 | .node_repl_history 60 | 61 | # Project files 62 | .idea 63 | *.iml 64 | 65 | # OS files 66 | .DS_Store 67 | 68 | # Compiled resources 69 | dist 70 | 71 | # Environment variables 72 | .env 73 | -------------------------------------------------------------------------------- /config/default.yaml: -------------------------------------------------------------------------------- 1 | sns-topic-arn: DEFINE-ME 2 | 3 | db-files: 4 | - DEFINE-ME 5 | - DEFINE-ME 6 | - DEFINE-ME 7 | 8 | clamscan: 9 | # If true removes infected files 10 | remove_infected: false 11 | 12 | # False: Don't quarantine 13 | # Path: Moves files to this place. 14 | quarantine_infected: false 15 | 16 | # Path to a writeable log file to write scan results into 17 | scan_log: null 18 | 19 | # Whether or not to log info/debug/error msgs to the console 20 | debug_mode: true 21 | 22 | # Path to file containing list of files to scan (for scan_files method) 23 | file_list: null 24 | 25 | # If true deep scan folders recursively 26 | scan_recursively: false 27 | 28 | # If true use test mode 29 | testing_mode: false 30 | 31 | clamscan: 32 | # Path to clamscan binary on your server 33 | path: /var/task/bin/clamscan 34 | # Path to a custom virus definition database 35 | db: /tmp 36 | # If true scan archives (ex. zip, rar, tar, dmg, iso, etc...) 37 | scan_archives: true 38 | # If true this module will consider using the clamscan binary 39 | active: true 40 | 41 | clamdscan: 42 | # Path to clamscand binary on your server 43 | path: null 44 | # If true this module will consider using the clamscand binary 45 | active: false 46 | 47 | # If clamdscan is found and active it will be used by default 48 | preference: clamscan 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slamscan", 3 | "description": "Use lambda to scan s3 files on upload. S3 LAMbda clamSCAN", 4 | "author": "Patrick McAndrew ", 5 | "license": "MIT", 6 | "version": "1.0.0", 7 | "main": "index.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+ssh://git@github.com:PeerJ/slamscan.git" 11 | }, 12 | "dependencies": { 13 | "app-root-path": "^2.0.1", 14 | "async": "^2.4.0", 15 | "aws-sdk": "^2.48.0", 16 | "clamscan": "^0.8.4", 17 | "config": "^1.26.1", 18 | "fs-extra": "^3.0.0", 19 | "js-yaml": "^3.8.3", 20 | "request": "^2.81.0", 21 | "temp": "^0.8.3", 22 | "valid-url": "^1.0.9" 23 | }, 24 | "devDependencies": { 25 | "istanbul": "^0.4.5", 26 | "jscs": "^3.0.7", 27 | "jshint": "^2.9.4", 28 | "mocha": "^3.3.0", 29 | "rewire": "^2.5.2", 30 | "should": "^11.2.1", 31 | "sinon": "^2.2.0" 32 | }, 33 | "scripts": { 34 | "jshint": "./node_modules/.bin/jshint */**.js", 35 | "jscs": "./node_modules/.bin/jscs */**.js", 36 | "lint": "npm run jshint && npm run jscs", 37 | "pretest": "NODE_ENV=test npm run lint", 38 | "test": "NODE_ENV=test ./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --recursive -R spec -r should", 39 | "posttest": "NODE_ENV=test ./node_modules/.bin/istanbul check-coverage" 40 | }, 41 | "engine": "node >= 4.3.2" 42 | } 43 | -------------------------------------------------------------------------------- /test/util/downloadClamscanDbFiles.js: -------------------------------------------------------------------------------- 1 | var sinon = require('sinon'); 2 | var config = require('config'); 3 | var fs = require('fs'); 4 | var util = require('../../src/util/index'); 5 | var downloadClamscanDbFiles = require('../../src/downloadClamscanDbFiles'); 6 | 7 | describe('downloadClamscanDbFiles', function() { 8 | var downloadFileFromUrlStub; 9 | 10 | beforeEach(function() { 11 | downloadFileFromUrlStub = sinon.stub(util, 'downloadFileFromUrl') 12 | .yields('file') 13 | .callsArg(2); 14 | }); 15 | 16 | afterEach(function() { 17 | downloadFileFromUrlStub.restore(); 18 | }); 19 | 20 | describe('when files do not exist', function() { 21 | beforeEach(function() { 22 | sinon.stub(fs, 'exists') 23 | .yields(false); 24 | }); 25 | 26 | afterEach(function() { 27 | fs.exists.restore(); 28 | }); 29 | 30 | it('should download files', function(done) { 31 | downloadClamscanDbFiles(function(err) { 32 | if (err) { 33 | return done(err); 34 | } 35 | downloadFileFromUrlStub.callCount 36 | .should.be.exactly(config.get('db-files').length); 37 | done(); 38 | }); 39 | }); 40 | }); 41 | 42 | describe('when files do exist', function() { 43 | beforeEach(function() { 44 | sinon.stub(fs, 'exists') 45 | .yields(true); 46 | }); 47 | 48 | afterEach(function() { 49 | fs.exists.restore(); 50 | }); 51 | 52 | it('shouldn\'t download files', function(done) { 53 | downloadClamscanDbFiles(function(err) { 54 | if (err) { 55 | return done(err); 56 | } 57 | fs.exists.callCount 58 | .should.be.exactly(config.get('db-files').length); 59 | downloadFileFromUrlStub.notCalled 60 | .should.be.exactly(true); 61 | done(); 62 | }); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/handler.js: -------------------------------------------------------------------------------- 1 | var async = require('async'); 2 | var util = require('util'); 3 | var config = require('config'); 4 | var temp = require('temp'); 5 | var path = require('path'); 6 | var appUtil = require('./util'); 7 | var downloadFileFromBucket = appUtil.downloadFileFromBucket; 8 | var notifySns = appUtil.notifySns; 9 | var downloadClamscanDbFiles = require('./downloadClamscanDbFiles'); 10 | var scanFile = require('./scanFile'); 11 | 12 | module.exports = function(event, context, callback) { 13 | console.log('Reading options from event: %j', 14 | util.inspect(event, {depth: 5}) 15 | ); 16 | 17 | if (typeof (event.Records) === 'undefined') { 18 | return callback(new Error( 19 | util.format('Unable to find event. Records event: %j', event) 20 | )); 21 | } 22 | 23 | async.parallel({ 24 | downloadClamscanDbFiles: downloadClamscanDbFiles 25 | }, function(err) { 26 | if (err) { 27 | console.log('Failed to download clamscandb files. Exiting'); 28 | return callback(err); 29 | } 30 | 31 | async.each(event.Records, function(record, callback) { 32 | var bucket = record.s3.bucket.name; 33 | var key = record.s3.object.key; 34 | 35 | async.waterfall([ 36 | downloadFileFromBucket.bind( 37 | null, 38 | bucket, 39 | key, 40 | temp.path({suffix: path.extname(key)}) 41 | ), 42 | scanFile, 43 | function(details, isInfected, next) { 44 | if (isInfected) { 45 | notifySns( 46 | config.get('sns-topic-arn'), 47 | JSON.stringify({ 48 | Bucket: bucket, 49 | uri: util.format('s3://%s/%s', bucket, key), 50 | isInfected: isInfected, 51 | details: details 52 | }), 53 | next 54 | ); 55 | } else { 56 | next(); 57 | } 58 | } 59 | ], callback); 60 | }, callback); 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /test/util/downloadFileFromUrl.js: -------------------------------------------------------------------------------- 1 | var sinon = require('sinon'); 2 | var rewire = require('rewire'); 3 | var temp = require('temp'); 4 | var downloadFileFromBucket = require('../../src/util/downloadFileFromBucket'); 5 | var downloadFileFromHttp = require('../../src/util/downloadFileFromHttp'); 6 | var downloadFileFromUrl = rewire('../../src/util/downloadFileFromUrl'); 7 | 8 | describe('downloadFileFromUrl', function() { 9 | var downloadFileFromBucketStub; 10 | var downloadFileFromHttpStub; 11 | 12 | beforeEach(function() { 13 | downloadFileFromBucketStub = sinon.stub() 14 | .yields(null, 's3'); 15 | downloadFileFromHttpStub = sinon.stub() 16 | .yields(null, 'http'); 17 | downloadFileFromUrl.__set__({ 18 | downloadFileFromBucket: downloadFileFromBucketStub, 19 | downloadFileFromHttp: downloadFileFromHttpStub 20 | }); 21 | }); 22 | 23 | afterEach(function() { 24 | downloadFileFromUrl.__set__({ 25 | downloadFileFromBucket: downloadFileFromBucket, 26 | downloadFileFromHttp: downloadFileFromHttp 27 | }); 28 | }); 29 | 30 | it('should delegate to the correct function (http)', function(done) { 31 | downloadFileFromUrl( 32 | 'http://test.test/test/test', 33 | temp.path(), 34 | function(error, file) { 35 | if (error) { 36 | return done(error); 37 | } 38 | 39 | file.should.be.exactly('http'); 40 | downloadFileFromHttpStub.calledOnce.should.be.exactly(true); 41 | 42 | done(); 43 | } 44 | ); 45 | }); 46 | 47 | it('should delegate to the correct function (https)', function(done) { 48 | downloadFileFromUrl( 49 | 'https://test.test/test/test', 50 | temp.path(), 51 | function(error, file) { 52 | if (error) { 53 | return done(error); 54 | } 55 | 56 | file.should.be.exactly('http'); 57 | downloadFileFromHttpStub.calledOnce.should.be.exactly(true); 58 | 59 | done(); 60 | } 61 | ); 62 | }); 63 | 64 | it('should delegate to the correct function (s3)', function(done) { 65 | downloadFileFromUrl( 66 | 's3://test.test/test/test', 67 | temp.path(), 68 | function(error, file) { 69 | if (error) { 70 | return done(error); 71 | } 72 | 73 | file.should.be.exactly('s3'); 74 | downloadFileFromBucketStub.calledOnce.should.be.exactly(true); 75 | downloadFileFromBucketStub.calledWith( 76 | 'test.test', 77 | 'test/test' 78 | ).should.be.exactly(true); 79 | 80 | done(); 81 | } 82 | ); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /test/util/downloadFileFromBucket.js: -------------------------------------------------------------------------------- 1 | var sinon = require('sinon'); 2 | var fs = require('fs'); 3 | var temp = require('temp'); 4 | var rewire = require('rewire'); 5 | var downloadFileFromBucket = rewire('../../src/util/downloadFileFromBucket'); 6 | 7 | describe('downloadFileFromBucket', function() { 8 | var s3GetObjectStub = null; 9 | 10 | beforeEach(function() { 11 | s3GetObjectStub = sinon.stub() 12 | .returns({ 13 | createReadStream: function() { 14 | return fs.createReadStream(__filename); 15 | } 16 | }); 17 | downloadFileFromBucket.__set__('s3', { 18 | getObject: s3GetObjectStub 19 | }); 20 | }); 21 | 22 | describe('when missing a bucket', function() { 23 | it('should callback with error', function(done) { 24 | downloadFileFromBucket('', '', temp.path(), function(err) { 25 | err.should.match(/Bucket: /); 26 | s3GetObjectStub.called.should.be.exactly(false); 27 | done(); 28 | }); 29 | }); 30 | }); 31 | 32 | describe('when missing a key', function() { 33 | it('should callback with error', function(done) { 34 | downloadFileFromBucket('bucket', '', temp.path(), function(err) { 35 | err.should.match(/Key: /); 36 | s3GetObjectStub.called.should.be.exactly(false); 37 | done(); 38 | }); 39 | }); 40 | }); 41 | 42 | describe('when given a valid bucket and key', function() { 43 | it('should return a temp file', function(done) { 44 | var stubBucketAndKey = { 45 | Bucket: 'bucket', 46 | Key: 'key' 47 | }; 48 | downloadFileFromBucket( 49 | stubBucketAndKey.Bucket, 50 | stubBucketAndKey.Key, 51 | temp.path(), 52 | function(err) { 53 | if (err) { 54 | return done(err); 55 | } 56 | 57 | s3GetObjectStub.calledOnce.should.be.exactly(true); 58 | s3GetObjectStub.calledWith(stubBucketAndKey).should.be.exactly(true); 59 | 60 | done(); 61 | } 62 | ); 63 | }); 64 | 65 | it('should `decodeURIComponent` on the key', function(done) { 66 | var stubBucketAndKey = { 67 | Bucket: 'bucket', 68 | Key: 'folder/file%3Awith%3Acolons' 69 | }; 70 | downloadFileFromBucket( 71 | stubBucketAndKey.Bucket, 72 | stubBucketAndKey.Key, 73 | temp.path(), 74 | function(err) { 75 | if (err) { 76 | return done(err); 77 | } 78 | 79 | s3GetObjectStub.calledOnce.should.be.exactly(true); 80 | s3GetObjectStub.calledWith({ 81 | Bucket: stubBucketAndKey.Bucket, 82 | Key: decodeURIComponent(stubBucketAndKey.Key) 83 | }).should.be.exactly(true); 84 | 85 | done(); 86 | } 87 | ); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ```plaintext 2 | _______ ___ _______ __ __ _______ _______ _______ __ _ 3 | | | | | | _ || |_| | | || || _ || | | | 4 | | _____| | | | |_| || | | _____|| || |_| || |_| | 5 | | |_____ | | | || | | |_____ | || || | 6 | |_____ | | |___ | || | |_____ || _|| || _ | 7 | _____| | | || _ || ||_|| | _____| || |_ | _ || | | | 8 | |_______|3 |_______||__| |__||_| |_|bda Clam|_______||_______||__| |__||_| |__| 9 | ``` 10 | 11 | The goal of this project is to efficiently virus scan files that are uploaded to a S3 bucket and notify the results of the scan. 12 | 13 | This can be achieved in a reasonably cost effictive manner using Lambda, `node` and `clamscan`. 14 | 15 | S3 is configured to call a `node` handler when a S3 `PUT` event is received. The `node` handler calls out to `clamscan` and then publishes to SNS with the results. SNS can be configured to `POST` to a webhook or `PUT` in a SQS queue for later processing. 16 | 17 | Unfortunately due to size limitations, its not possible to keep the virus definitions in the package, but rather they need to be uploaded to S3 where the Lambda process can then download. If you're processing files quite closely together, the Lambda container may still be around and so the virus definitions won't need to be re-downloaded. 18 | 19 | 20 | ## Installation 21 | 22 | ### Build the `clamscan` binaries 23 | `clamscan` is currently the only supported virus scanning engine and need to be configured to 24 | run under Lambda. There are a couple of ways to do this: 25 | 26 | #### Build the binaries on the `amazonlinux` Docker image 27 | 1. `docker pull amazonlinux` 28 | 1. `docker run -it amazonlinux` 29 | 1. Follow the build/update instructions below. 30 | 31 | #### Build the binaries on an actual EC2 instance 32 | 33 | 1. Spin up an EC2 instance and assume the `ec2-user`. 34 | 1. `sudo yum groupinstall "Development Tools"` 35 | 1. `sudo yum install openssl openssl-devel wget` 36 | 1. `wget https://www.clamav.net/downloads/production/clamav-0.99.2.tar.gz` 37 | 1. `tar -xvf clamav-0.99.2.tar.gz` 38 | 1. `cd clamav-0.99.2` 39 | 1. `./configure --enable-static=yes --enable-shared=no --disable-unrar --prefix=/var/task` 40 | 1. `make` 41 | 1. `sudo make install` 42 | 43 | #### Update the virus definition files 44 | 1. `sudo chown -R ec2-user /var/task` 45 | 1. `touch /var/task/etc/freshclam.conf` 46 | * or `cp /var/task/etc/freshclam.conf.sample /var/task/etc/freshclam.conf` and follow the instruction to `#Comment or remove the line below` 47 | 1. `mkdir /var/task/share/clamav` 48 | 1. `/var/task/bin/freshclam` 49 | 1. `/var/task/bin/clamscan /var/task/test/resources/EICAR-AV-Test` 50 | * Should return `/var/task/test/resources/EICAR-AV-Test: Eicar-Test-Signature FOUND` 51 | 52 | #### Copy the files to where they need to be 53 | 1. You'll need to upload the virus definition files from `/var/task/share/clamav` to a location in S3, or some HTTP/HTTPS accessible location. 54 | * Make sure that the `lambda_exec` IAM role has `read` permissions on these files. 55 | * Add the URIs to the files to your configuration under `db-files` 56 | 1. You'll need to copy the `clamscan` binary in `/var/task/bin/clamscan` to the `bin` directory in this project 57 | 1. You'll need to copy the `lib64` libraries in `/var/task/lib64` to the `lib64` directory in this project 58 | 59 | 60 | ### Set up your AWS Infrastructure 61 | 1. S3 Bucket with files to scan 62 | 1. SNS Topic to notify on infected file discovery 63 | 1. IAM Resources 64 | 1. An IAM user with an access key/secret so you can `node-lambda run` and `node-lambda deploy` 65 | * Quick Start: 66 | * `AWSLambdaFullAccess` 67 | 1. A `lambda_exec` role (Lambda Service type role) 68 | * Quick Start: 69 | * `AmazonS3ReadOnlyAccess` 70 | * `AmazonSNSFullAccess` 71 | * Better Security: 72 | * S3 Bucket: `ListBuckets` & `GetObject` 73 | * SNS Topic: Publish access 74 | 1. CloudWatch is very useful for debugging - you will need to add permissions for that as well if desired. 75 | 76 | 77 | ### Configure and deploy the Lambda function 78 | ```sh 79 | # Install dependencies 80 | brew install node 81 | npm install -g node-lambda 82 | 83 | 84 | # Setup the package 85 | npm install # or `yarn install` 86 | 87 | 88 | # Provide your `slamscan` configuration by changing the `DEFINE-ME` values in default.yaml to the relevant ones for you 89 | cp config/test.yaml config/local.yaml 90 | emacs config/local.yaml # or `vim` or `nano` or whatever. 91 | 92 | 93 | # Initialize and provide some `node-lambda` configuration 94 | node-lambda setup 95 | 96 | emacs .env # or `vim` or `nano` or whatever. 97 | # You'll want to set the following, but experimentation is encouraged 98 | # AWS_MEMORY_SIZE=1024 # `clamscan` is pretty RAM hungry these days, per https://github.com/widdix/aws-s3-virusscan/issues/12 99 | # AWS_TIMEOUT=120 # `clamscan` takes a bit of time to spin up, plus downloading your virus definitions & files to scan might take a while 100 | # AWS_RUN_TIMEOUT=120 101 | # AWS_PROFILE= 102 | 103 | cp test/resources/event.json ./event.json 104 | # Might as well use the skeleton that's already there and just change the `DEFINE-ME`s 105 | emacs event.json # or `vim` or `nano` or whatever. 106 | 107 | 108 | # Run your lambda locally 109 | node-lambda run 110 | 111 | 112 | # Deploy your lambda 113 | node-lambda deploy 114 | 115 | ``` 116 | 117 | ### Configure your Lambda function triggers 118 | Login to the AWS Console and add the appropriate (S3 `PUT`) triggers [here](https://console.aws.amazon.com/lambda/home#/functions/slamscan?tab=triggers). 119 | 120 | 121 | ## Contributing 122 | Pull requests are welcome. Please ensure existing standards and tests pass by running `npm test`. 123 | 124 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | // JSHint Default Configuration File (as on JSHint website) 3 | // See http://jshint.com/docs/ for more details 4 | 5 | "maxerr" : 50, // {int} Maximum error before stopping 6 | 7 | // Enforcing 8 | "bitwise" : true, // true: Prohibit bitwise operators (&, |, ^, etc.) 9 | "camelcase" : false, // true: Identifiers must be in camelCase 10 | "curly" : true, // true: Require {} for every new block or scope 11 | "eqeqeq" : true, // true: Require triple equals (===) for comparison 12 | "forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty() 13 | "freeze" : true, // true: prohibits overwriting prototypes of native objects such as Array, Date etc. 14 | "immed" : false, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());` 15 | "latedef" : false, // true: Require variables/functions to be defined before being used 16 | "newcap" : true, // true: Require capitalization of all constructor functions e.g. `new F()` 17 | "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee` 18 | "noempty" : true, // true: Prohibit use of empty blocks 19 | "nonbsp" : true, // true: Prohibit "non-breaking whitespace" characters. 20 | "nonew" : false, // true: Prohibit use of constructors for side-effects (without assignment) 21 | "plusplus" : false, // true: Prohibit use of `++` and `--` 22 | "quotmark" : "single", // Quotation mark consistency: 23 | // false : do nothing (default) 24 | // true : ensure whatever is used is consistent 25 | // "single" : require single quotes 26 | // "double" : require double quotes 27 | "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks) 28 | "unused" : true, // Unused variables: 29 | // true : all variables, last function parameter 30 | // "vars" : all variables only 31 | // "strict" : all variables, all function parameters 32 | "strict" : false, // true: Requires all functions run in ES5 Strict Mode 33 | "maxparams" : false, // {int} Max number of formal params allowed per function 34 | "maxdepth" : false, // {int} Max depth of nested blocks (within functions) 35 | "maxstatements" : false, // {int} Max number statements per function 36 | "maxcomplexity" : false, // {int} Max cyclomatic complexity per function 37 | "maxlen" : false, // {int} Max number of characters per line 38 | "varstmt" : false, // true: Disallow any var statements. Only `let` and `const` are allowed. 39 | 40 | // Relaxing 41 | "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons) 42 | "boss" : false, // true: Tolerate assignments where comparisons would be expected 43 | "debug" : false, // true: Allow debugger statements e.g. browser breakpoints. 44 | "eqnull" : false, // true: Tolerate use of `== null` 45 | "esversion" : 5, // {int} Specify the ECMAScript version to which the code must adhere. 46 | "moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features) 47 | // (ex: `for each`, multiple try/catch, function expression…) 48 | "evil" : false, // true: Tolerate use of `eval` and `new Function()` 49 | "expr" : false, // true: Tolerate `ExpressionStatement` as Programs 50 | "funcscope" : false, // true: Tolerate defining variables inside control statements 51 | "globalstrict" : false, // true: Allow global "use strict" (also enables 'strict') 52 | "iterator" : false, // true: Tolerate using the `__iterator__` property 53 | "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block 54 | "laxbreak" : false, // true: Tolerate possibly unsafe line breakings 55 | "laxcomma" : false, // true: Tolerate comma-first style coding 56 | "loopfunc" : false, // true: Tolerate functions being defined in loops 57 | "multistr" : false, // true: Tolerate multi-line strings 58 | "noyield" : false, // true: Tolerate generator functions with no yield statement in them. 59 | "notypeof" : false, // true: Tolerate invalid typeof operator values 60 | "proto" : false, // true: Tolerate using the `__proto__` property 61 | "scripturl" : false, // true: Tolerate script-targeted URLs 62 | "shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;` 63 | "sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation 64 | "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;` 65 | "validthis" : false, // true: Tolerate using this in a non-constructor function 66 | 67 | // Environments 68 | "browser" : true, // Web Browser (window, document, etc) 69 | "browserify" : false, // Browserify (node.js code in the browser) 70 | "couch" : false, // CouchDB 71 | "devel" : false, // Development/debugging (alert, confirm, etc) 72 | "dojo" : false, // Dojo Toolkit 73 | "jasmine" : false, // Jasmine 74 | "jquery" : false, // jQuery 75 | "mocha" : true, // Mocha 76 | "mootools" : false, // MooTools 77 | "node" : true, // Node.js 78 | "nonstandard" : false, // Widely adopted globals (escape, unescape, etc) 79 | "phantom" : false, // PhantomJS 80 | "prototypejs" : false, // Prototype and Scriptaculous 81 | "qunit" : false, // QUnit 82 | "rhino" : false, // Rhino 83 | "shelljs" : false, // ShellJS 84 | "typed" : false, // Globals for typed array constructions 85 | "worker" : false, // Web Workers 86 | "wsh" : false, // Windows Scripting Host 87 | "yui" : false, // Yahoo User Interface 88 | 89 | // Custom Globals 90 | "globals" : {} // additional predefined global variables 91 | } 92 | --------------------------------------------------------------------------------