diff --git a/.env b/.env index af27a4c..dbbc136 100644 --- a/.env +++ b/.env @@ -1,3 +1,4 @@ CWD=`dirname $0:A` -alias mocli="${CWD}/mocli.sh" \ No newline at end of file +alias mocli="${CWD}/mocli.sh" +mocli gen-cmp diff --git a/Dockerfile b/Dockerfile index 220f138..95cb345 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,28 @@ FROM node:11-alpine -LABEL version="1.0" +LABEL version="1.2" LABEL description="Linux alpine with node:11 and chromium browser" RUN set -x \ && apk update \ && apk upgrade \ && echo @edge http://nl.alpinelinux.org/alpine/edge/community >> /etc/apk/repositories \ - && echo @edge http://nl.alpinelinux.org/alpine/edge/main >> /etc/apk/repositories \ - && apk add --no-cache \ + && echo @edge http://nl.alpinelinux.org/alpine/edge/main >> /etc/apk/repositories + +RUN apk add --no-cache \ + bash \ + python3 + +RUN apk add --no-cache \ chromium@edge \ harfbuzz@edge \ nss@edge \ freetype@edge \ - ttf-freefont@edge \ - && rm -rf /var/cache/* \ - && mkdir /var/cache/apk + ttf-freefont@edge + +RUN pip3 install awscli + +RUN rm -rf /var/cache/* && mkdir /var/cache/apk WORKDIR /opt/mo diff --git a/README.md b/README.md index 5d66486..096a9a9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # MetaDefender browser extension +[![metadefender-browser-extension](https://david-dm.org/opswat/metadefender-browser-extension.svg)](https://david-dm.org/opswat/metadefender-browser-extension) + ## Intro OPSWAT File Security for Chrome, provides the ability to quickly scan a file for malware prior to download directly from the browser. At the moment this only works with google chrome, but can be extended to any other browser. @@ -167,6 +169,8 @@ To rebuild the docker image run: ```bash IMG=opswat/metadefender-browser-extension && VER=1.0 docker build -f Dockerfile . -t ${IMG}:${VER} -t ${IMG}:latest +docker push ${IMG}:${VER} +docker push ${IMG}:latest ``` ## Contributing diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index ae10cfc..299bc37 100755 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -122,6 +122,13 @@ "message": "Save clean files
Download clean files on my computer after they are scanned.
", "description": "" }, + "safeUrl": { + "message": "Enable safe URL
Browse the web in safety and alert me when accessing harmful websites.
", + "description": "" + }, + "safeUrlSub": { + "message": "Read more about Safe URL Redirect" + }, "searcHistory": { "message": "Search history" }, diff --git a/app/config/common.json b/app/config/common.json index 0a86985..58d0e0f 100755 --- a/app/config/common.json +++ b/app/config/common.json @@ -56,5 +56,7 @@ "g.files.metadefender.com" ], - "browserNotificationTimeout": 5000 + "browserNotificationTimeout": 5000, + + "maxCleanUrls": 1000 } \ No newline at end of file diff --git a/app/html/extension/settings.html b/app/html/extension/settings.html index e7240ac..8fe5635 100755 --- a/app/html/extension/settings.html +++ b/app/html/extension/settings.html @@ -15,6 +15,10 @@
  • +
  • + +
    +
  • \ No newline at end of file diff --git a/app/scripts/background/background-task.js b/app/scripts/background/background-task.js index 2f137b2..d6d1155 100644 --- a/app/scripts/background/background-task.js +++ b/app/scripts/background/background-task.js @@ -15,6 +15,7 @@ import FileProcessor from '../common/file-processor'; import cookieManager from './cookie-manager'; import DownloadsManager from './download-manager'; import { goToTab } from './navigation'; +import SafeUrl from './safe-url'; const MCL_CONFIG = MCL.config; @@ -54,6 +55,7 @@ class BackgroundTask { chrome.notifications.onClosed.addListener(() => { }); browserMessage.addListener(this.messageListener.bind(this)); + } async init() { @@ -82,6 +84,8 @@ class BackgroundTask { this.setApikey(cookie.value); } }); + + SafeUrl.toggle(settings.safeUrl); } /** @@ -222,6 +226,9 @@ class BackgroundTask { if (settings.saveCleanFiles !== saveCleanFiles) { this.updateContextMenu(); } + + SafeUrl.toggle(settings.safeUrl); + break; } case BROWSER_EVENT.SCAN_FILES_UPDATED: { diff --git a/app/scripts/background/safe-url.js b/app/scripts/background/safe-url.js new file mode 100644 index 0000000..ec7024c --- /dev/null +++ b/app/scripts/background/safe-url.js @@ -0,0 +1,131 @@ +import '../common/config'; + +import xhr from 'xhr'; + +/** + * Metadefender Cloud Endpoint for url check + */ +const safeRedirectEndpoint = `${MCL.config.mclDomain}/safe-redirect/`; + +/** + * A list of urls that are currently redirecting. + */ +const activeRedirects = new Set(); + +/** + * A list of urls that are infected. + */ +const infectedUrls = new Set(); + +/** + * A list of urls that are not infected. + * Used to speed-up navigation after fist check. + */ +const cleanUrls = new Set(); + +/** + * Removes old urls that were marked as clean + */ +const removeOldUrls = () => { + if (cleanUrls.size > MCL.config.maxCleanUrls) { + const firstValue = cleanUrls.values().next().value; + cleanUrls.delete(firstValue); + } +}; + +/** + * Save the url as infected or not. + * @param {string} testUrl the url to be tested + * @param {string} urlValidator test endpoint + */ +const handleUrlValidatorResponse = (testUrl, err, res) => { + if (err) { + cleanUrls.add(testUrl); + return; + } + + try { + if (res.headers.status === '400') { + infectedUrls.add(testUrl); + } + else { + cleanUrls.add(testUrl); + } + } + catch (e) { + cleanUrls.add(testUrl); + } + removeOldUrls(); +}; + +const isSafeUrl = (testUrl, urlValidator) => { + xhr.get(urlValidator, {sync: true, headers: {noredirect: true}}, (err, res) => handleUrlValidatorResponse(testUrl, err, res)); +}; + +/** + * Intercept web request before the request is made + * and redirect valid urls to safe-redirect endpoint. + * + * @param {string} safeRedirectEndpoint + * @param {*} details chrome.webRequest event details + * @returns a BlockingResponse https://developer.chrome.com/extensions/webRequest + */ +const doSafeRedirect = (details) => { + const tabUrl = details.url || ''; + + if (!tabUrl.startsWith(safeRedirectEndpoint)) { + const shortUrl = tabUrl.split('?')[0]; + if (!activeRedirects.has(shortUrl) && details.initiator !== 'null') { + if (!cleanUrls.has(shortUrl)) { + const safeUrl = safeRedirectEndpoint + encodeURIComponent(tabUrl); + isSafeUrl(shortUrl, safeUrl); + if (infectedUrls.has(shortUrl)) { + activeRedirects.add(shortUrl); + return { + redirectUrl: safeUrl + }; + } + } + } + activeRedirects.delete(shortUrl); + if (infectedUrls.has(shortUrl)) { + infectedUrls.delete(shortUrl); + } + } +}; + +/** + * Verifies urls using metadefender cloud safe-redirect feature. + */ +class SafeUrl { + constructor() { + this.enabled = false; + this.toggle = this.toggle.bind(this); + + this._infectedUrls = infectedUrls; + this._cleanUrls = cleanUrls; + this._doSafeRedirect = doSafeRedirect; + this._handleUrlValidatorResponse = handleUrlValidatorResponse; + } + + toggle(enable) { + if (this.enabled === enable) { + return; + } + + this.enabled = enable; + if (this.enabled) { + chrome.webRequest.onBeforeRequest.addListener(doSafeRedirect, { + urls: ['http://*/*', 'https://*/*'], + types: ['main_frame'] + }, ['blocking']); + } + else { + chrome.webRequest.onBeforeRequest.removeListener(doSafeRedirect); + } + + return this.enabled; + } +} + +export default new SafeUrl(); \ No newline at end of file diff --git a/app/scripts/background/safe-url.spec.js b/app/scripts/background/safe-url.spec.js new file mode 100644 index 0000000..4fe342e --- /dev/null +++ b/app/scripts/background/safe-url.spec.js @@ -0,0 +1,122 @@ +import chrome from 'sinon-chrome'; +import sinon from 'sinon'; +import xhr from 'xhr'; +import SafeUrl from './safe-url'; + +describe('app/scripts/background/safe-url.js', () => { + + beforeAll(() => { + global.chrome = chrome; + }); + + describe('enabled', () => { + + it('should be disabled by default', () => { + expect(SafeUrl.enabled).toBeFalsy(); + }); + }); + + describe('toggle', () => { + + it('should enable safe url', () => { + chrome.webRequest.onBeforeRequest.addListener.resetHistory(); + + SafeUrl.enabled = false; + const enabled = SafeUrl.toggle(true); + const callArgs = chrome.webRequest.onBeforeRequest.addListener.getCall(0).args; + + expect(enabled).toBeTruthy(); + expect(callArgs.length).toEqual(3); + expect(typeof callArgs[0]).toBe('function'); + expect(callArgs[1]).toEqual({urls: ['http://*/*', 'https://*/*'], types: ['main_frame']}); + expect(callArgs[2]).toEqual(['blocking']); + }); + + it('shold not change enabled state', () => { + SafeUrl.enabled = false; + expect(typeof SafeUrl.toggle(false)).toBe('undefined'); + }); + + it('should disable safe url', () => { + chrome.webRequest.onBeforeRequest.removeListener.resetHistory(); + + SafeUrl.enabled = true; + const enabled = SafeUrl.toggle(false); + const callArgs = chrome.webRequest.onBeforeRequest.removeListener.getCall(0).args; + + expect(enabled).toBeFalsy(); + expect(callArgs.length).toEqual(1); + expect(typeof callArgs[0]).toBe('function'); + }); + + }); + + describe('_handleUrlValidatorResponse', () => { + + it('should mark url as infected', () => { + SafeUrl._handleUrlValidatorResponse('a1', 'error', {}); + expect(SafeUrl._cleanUrls.has('a1')).toBeTruthy(); + + SafeUrl._handleUrlValidatorResponse('a2', '', {headers: {status: '200'}}); + expect(SafeUrl._cleanUrls.has('a2')).toBeTruthy(); + + SafeUrl._handleUrlValidatorResponse('a3', '', {headers: {status: '404'}}); + expect(SafeUrl._cleanUrls.has('a3')).toBeTruthy(); + }); + + it('should mark the url as infected', () => { + SafeUrl._handleUrlValidatorResponse('b1', '', {headers: {status: '400'}}); + expect(SafeUrl._infectedUrls.has('b1')).toBeTruthy(); + }); + + }); + + describe('_doSafeRedirect', () => { + + it('should not redirect metadefender safe redirect endpoint', () => { + expect(typeof SafeUrl._doSafeRedirect({type: 'main_frame', url: `${MCL.config.mclDomain}/safe-redirect/`})).toBe('undefined'); + }); + + it('should not redirect clean URLs', () => { + const testUrl = 'http://metadefender.opswat.com'; + const details = { + url: testUrl + }; + sinon.stub(xhr, 'get').callsFake(() => SafeUrl._handleUrlValidatorResponse(testUrl, 'error', {})); + + const redirect = SafeUrl._doSafeRedirect(details); + expect(typeof redirect).toBe('undefined'); + + xhr.get.restore(); + }); + + it('should redirect infected URLs', () => { + const testUrl = 'http://infected.url'; + const details = { + url: testUrl + }; + sinon.stub(xhr, 'get').callsFake(() => SafeUrl._handleUrlValidatorResponse(testUrl, '', {headers: {status: '400'}})); + + const redirect = SafeUrl._doSafeRedirect(details); + expect(typeof redirect.redirectUrl).toBe('string'); + expect(redirect.redirectUrl.startsWith(`${MCL.config.mclDomain}/safe-redirect/`)).toBeTruthy(); + expect(redirect.redirectUrl.endsWith(encodeURIComponent(testUrl))).toBeTruthy(); + + xhr.get.restore(); + }); + + it('should not redirect infected URLs on second access', () => { + const testUrl = 'http://infected.url/access'; + const details = { + url: testUrl + }; + sinon.stub(xhr, 'get').callsFake(() => SafeUrl._handleUrlValidatorResponse(testUrl, '', {headers: {status: '400'}})); + + SafeUrl._doSafeRedirect(details); + expect(typeof SafeUrl._doSafeRedirect(details)).toBe('undefined'); + + xhr.get.restore(); + }); + }); + +}); \ No newline at end of file diff --git a/app/scripts/common/metascan-client.js b/app/scripts/common/metascan-client.js index 6a1e972..9798341 100755 --- a/app/scripts/common/metascan-client.js +++ b/app/scripts/common/metascan-client.js @@ -1,7 +1,7 @@ 'use strict'; import 'chromereload/devonly'; -import { SANITIZATION } from '../constants/workflows'; +import { SANITIZATION, UNARCHIVE } from '../constants/workflows'; import browserMessage from '../common/browser/browser-message'; import { BROWSER_EVENT } from '../common/browser/browser-message-event'; @@ -169,6 +169,7 @@ function fileUpload({fileName, fileData, sampleSharing, password, canBeSanitized 'Content-Type': 'application/octet-stream', 'samplesharing': sampleSharing, 'filename': fileName, + 'rule': UNARCHIVE, 'x-source': 'chrome_extension' }; @@ -177,7 +178,7 @@ function fileUpload({fileName, fileData, sampleSharing, password, canBeSanitized } if (canBeSanitized) { - additionalHeaders.rule = SANITIZATION; + additionalHeaders.rule += ',' + SANITIZATION; } let options = { diff --git a/app/scripts/common/persistent/settings.js b/app/scripts/common/persistent/settings.js index 81dd4b6..8be04b7 100644 --- a/app/scripts/common/persistent/settings.js +++ b/app/scripts/common/persistent/settings.js @@ -19,6 +19,7 @@ function Settings() { shareResults: true, showNotifications: true, saveCleanFiles: false, + safeUrl: false, // methods init: init, @@ -61,7 +62,8 @@ async function save(){ scanDownloads: this.scanDownloads, shareResults: this.shareResults, showNotifications: this.showNotifications, - saveCleanFiles: this.saveCleanFiles + saveCleanFiles: this.saveCleanFiles, + safeUrl: this.safeUrl }}); await BrowserMessage.send({event: BROWSER_EVENT.SETTINGS_UPDATED}); } diff --git a/app/scripts/constants/workflows.js b/app/scripts/constants/workflows.js index 1bca674..0fe213f 100644 --- a/app/scripts/constants/workflows.js +++ b/app/scripts/constants/workflows.js @@ -1 +1,2 @@ export const SANITIZATION = 'sanitize'; +export const UNARCHIVE = 'unarchive'; diff --git a/karma.conf.js b/karma.conf.js index 2fab9a5..069c594 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -6,6 +6,11 @@ module.exports = function(config) { frameworks: [ 'jasmine' ], + client: { + jasmine: { + random: false + } + }, files: [ 'test/karma.js' ], diff --git a/mocli.sh b/mocli.sh index ac37a40..ac511ac 100755 --- a/mocli.sh +++ b/mocli.sh @@ -6,35 +6,58 @@ cd ${CWD} ENVS=('local' 'dev' 'qa' 'prod') ENV="qa" +declare -A CMDS_DESC +CMDS_DESC=( + [developer]="Setup your developer environment" + [config]="Overwrite common configuration file with specified environment. Usage: mocli config " + [watch]="Starts a livereload server and watches all assets" + [test]="Runns unit tests and generates code coverage reports" + [release]="Create release versions using git-flow. Usage: mocli release [start|finish] " + [release-p]="Create release version and increases patch number" + [hotfix]="Create a qa release from current branch and uploads it to bitbucket" + [pack]="Zips the dist/ directory. Usage: mocli pack [--production] [--vendor=firefox] [--env=qa]" + [help]="Show this message" +) + function printHelp() { -cat << HELPDOC - ______________________________________________ - | .__ .__ | - | _____ ____ ____ | | |__| | - | / \ / _ \_/ ___\| | | | | - | | Y Y \| |_) ) \___| |_| | | - | |__|_| /|____/ \_____>____/__| | - | \/ Metadefender Cloud CLI | - | | - |______________________________________________| - - I am your development wizard (0_0) - Your commands are: - - developer - Setup your developer environment - config - Overwrite common configuration file with specified environment - Usage: mocli config - watch - Starts a livereload server and watches all assets - test - Runns unit tests and generates code coverage reports - release - Create release versions using git-flow - Usage: mocli release [start|finish] - release-p - Create release version and increases patch number - hotfix - Create a qa release from current branch and uploads it to bitbucket - pack - Zips the dist/ directory - Usage: mocli pack [--production] [--vendor=firefox] [--env=qa] - help - Show this message - -HELPDOC + echo " ______________________________________________ " + echo "| .__ .__ | " + echo "| _____ ____ ____ | | |__| | " + echo "| / \ / _ \_/ ___\| | | | | " + echo "| | Y Y \| |_) ) \___| |_| | | " + echo "| |__|_| /|____/ \_____>____/__| | " + echo "| \/ MetaDefender Cloud CLI | " + echo "| | " + echo "|______________________________________________| " + echo " " + echo "I am your development wizard (0_0)" + echo "Your commands are:" + echo "" + line=" " + for i in "${!CMDS_DESC[@]}" + do + printf "%s %s %s \n" "$i" "${line:${#i}}" "${CMDS_DESC[$i]}" + done +} + +function gen_cmp(){ + for i in "${!CMDS_DESC[@]}" + do + CMDS+=($i) + done + mkdir -p ~/.zsh/completion && touch ~/.zsh/completion/_mocli + echo -e "#compdef mocli.sh + _mocli(){ + local line + + _arguments -C \ + \"-h[Show help information]\" \ + \"--h[Show help information]\" \ + \"1: :("${CMDS[@]}")\" \ + \"*::arg:->args\" + } + + _mocli" > ~/.zsh/completion/_mocli } ### @@ -68,12 +91,18 @@ function copy_secrets() { echo -e "{\n\t\"googleAnalyticsId\": \"\"\n}" > ./secrets.json echo -e "\nFailed to copy secrets from s3. Please update ./secrets.json manually.\n" fi +} +function copy_envs() { echo "-> copy environment specific files" - aws s3 cp s3://mcl-artifacts/mcl-browser-extension/app/config/local.json ./app/config/ > /dev/null 2>&1 - aws s3 cp s3://mcl-artifacts/mcl-browser-extension/app/config/dev.json ./app/config/ > /dev/null 2>&1 - aws s3 cp s3://mcl-artifacts/mcl-browser-extension/app/config/qa.json ./app/config/ > /dev/null 2>&1 - aws s3 cp s3://mcl-artifacts/mcl-browser-extension/app/config/prod.json ./app/config/ > /dev/null 2>&1 + ENVS=(local dev qa prod) + if [[ "${1}" != "" ]]; then + ENVS=(${1}) + fi + for env in ${ENVS[@]}; do + echo s3://mcl-artifacts/mcl-browser-extension/app/config/${env}.json + aws s3 cp s3://mcl-artifacts/mcl-browser-extension/app/config/${env}.json ./app/config/ > /dev/null 2>&1 + done } if [[ $# == 0 ]]; then @@ -86,6 +115,7 @@ while [[ $# -gt 0 ]]; do developer) # copy the google analytics ID. Create this file manually if you don't have access copy_secrets + copy_envs echo "-> install gulp-cli" npm list -g --depth=0 gulp-cli > /dev/null 2>&1 || npm i -g gulp-cli > /dev/null @@ -99,9 +129,19 @@ while [[ $# -gt 0 ]]; do echo "-> generate config: prod" gulp config --prod > /dev/null + gen_cmp + echo -e "-> Done\n" exit 0 ;; + copy-secrets) + copy_secrets + exit 0 + ;; + copy-envs) + copy_envs ${2} + exit 0 + ;; config) TOKEN_OK=`in_array "${2}" "${ENVS[@]}"` if [[ ${TOKEN_OK} = 0 ]]; then @@ -233,6 +273,9 @@ while [[ $# -gt 0 ]]; do done ${CMD} ;; + gen-cmp) + gen_cmp + ;; help) printHelp exit 0 diff --git a/package.json b/package.json index ee05967..f6cecb9 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "karma-spec-reporter": "0.0.32", "karma-webpack": "3.0.5", "require-dir": "1.2.0", + "sinon": "^7.3.2", "sinon-chrome": "3.0.1", "standard": "12.0.1", "uglifyjs-webpack-plugin": "1.3.0",