diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 22a3de5b..12a3fab9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -24,6 +24,15 @@ jobs: run: npm test - name: Grunt run: grunt + - name: Write secrets + env: + VIRUS_TOTAL: ${{ secrets.VIRUS_TOTAL }} + run: | + mkdir keys + echo "$VIRUS_TOTAL" > keys/virus-total.json + - name: Check on VirusTotal + run: grunt virustotal + if: ${{ github.repository == 'keeweb/keeweb' }} - name: Upload artifact uses: actions/upload-artifact@v1 with: diff --git a/Gruntfile.js b/Gruntfile.js index ec5a2f7d..2fdc180b 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -759,6 +759,16 @@ module.exports = function (grunt) { headless: true }, default: 'test/runner.html' + }, + virustotal: { + options: { + prefix: `keeweb.v${pkg.version}-${sha}.`, + timeout: 10 * 60 * 1000, + get apiKey() { + return require('./keys/virus-total.json').apiKey; + } + }, + html: 'dist/index.html' } }); }; diff --git a/app/scripts/plugins/theme-vars.js b/app/scripts/plugins/theme-vars.js index 677ddce8..ca4662dc 100644 --- a/app/scripts/plugins/theme-vars.js +++ b/app/scripts/plugins/theme-vars.js @@ -5,6 +5,8 @@ import ThemeDefaults from '!!raw-loader!../../styles/themes/_theme-defaults.scss const ThemeVars = { themeDefaults: null, + newLineRegEx: /[\n\s]+/g, // don't inline it, see #1656 + themeVarsRegEx: /([\w\-]+):([^:]+),(\$)?/g, init() { if (this.themeDefaults) { @@ -24,7 +26,7 @@ const ThemeVars = { apply(cssStyle) { this.init(); - const matches = ThemeVarsScss.replace(/[\n\s]+/g, '').matchAll(/([\w\-]+):([^:]+),(\$)?/g); + const matches = ThemeVarsScss.replace(this.newLineRegEx, '').matchAll(this.themeVarsRegEx); for (let [, name, def, last] of matches) { if (last && def.endsWith(')')) { // definitions are written like this: diff --git a/app/scripts/storage/storage-base.js b/app/scripts/storage/storage-base.js index c54ccb5b..55ee23ff 100644 --- a/app/scripts/storage/storage-base.js +++ b/app/scripts/storage/storage-base.js @@ -397,7 +397,7 @@ class StorageBase { skipAuth: true, data: UrlFormat.buildFormData({ 'client_id': config.clientId, - 'client_secret': config.clientSecret, + ...(config.clientSecret ? { 'client_secret': config.clientSecret } : null), 'grant_type': 'authorization_code', 'code': result.code, 'redirect_uri': session.redirectUri, @@ -430,7 +430,7 @@ class StorageBase { skipAuth: true, data: UrlFormat.buildFormData({ 'client_id': config.clientId, - 'client_secret': config.clientSecret, + ...(config.clientSecret ? { 'client_secret': config.clientSecret } : null), 'grant_type': 'refresh_token', 'refresh_token': refreshToken }), @@ -447,9 +447,12 @@ class StorageBase { if (xhr.status === 400) { delete this.runtimeData[this.name + 'OAuthToken']; this._oauthToken = null; + this.logger.error('Error exchanging refresh token, trying to authorize again'); + this._oauthAuthorize(callback); + } else { + this.logger.error('Error exchanging refresh token', err); + callback?.('Error exchanging refresh token'); } - this.logger.error('Error exchanging refresh token', err); - callback?.('Error exchanging refresh token'); } }); } diff --git a/build/tasks/grunt-virustotal.js b/build/tasks/grunt-virustotal.js new file mode 100644 index 00000000..94b5af97 --- /dev/null +++ b/build/tasks/grunt-virustotal.js @@ -0,0 +1,97 @@ +module.exports = function (grunt) { + grunt.registerMultiTask('virustotal', 'Checks if a file has issues on VirusTotal', function () { + const done = this.async(); + const opt = this.options(); + + const path = require('path'); + const fs = require('fs'); + const fetch = require('node-fetch'); + const FormData = require('form-data'); + + Promise.all( + this.files[0].src.map((file) => + checkFile(opt, file).catch((err) => { + grunt.warn('VirusTotal check failed: ' + err); + }) + ) + ).then(done); + + async function checkFile(opt, file) { + grunt.log.writeln(`Uploading to VirusTotal: ${file}...`); + + const timeStarted = Date.now(); + + const { apiKey, prefix, timeout = 60 * 1000 } = opt; + const interval = 5000; + + const headers = { 'x-apikey': apiKey }; + + const fileData = fs.readFileSync(file); + const fileName = (prefix || '') + path.basename(file); + + const form = new FormData(); + form.append('file', fileData, fileName); + const fileUploadResp = await fetch('https://www.virustotal.com/api/v3/files', { + method: 'POST', + headers, + body: form + }); + const fileUploadRespData = await fileUploadResp.json(); + if (fileUploadRespData.error) { + const errStr = JSON.stringify(fileUploadRespData.error); + throw new Error(`File upload error: ${errStr}`); + } + + const id = fileUploadRespData.data.id; + if (!id) { + throw new Error('File upload error: empty id'); + } + + grunt.log.writeln(`Uploaded ${file} to VirusTotal, id: ${id}`); + + let elapsed; + do { + const checkResp = await fetch(`https://www.virustotal.com/api/v3/analyses/${id}`, { + headers + }); + const checkRespData = await checkResp.json(); + if (checkRespData.error) { + const errStr = JSON.stringify(checkRespData.error); + throw new Error(`File check error: ${errStr}`); + } + const { attributes } = checkRespData.data; + if (attributes.status === 'completed') { + const { stats } = attributes; + if (stats.malicious > 0) { + throw new Error( + `File ${file} reported as malicious ${stats.malicious} time(s)` + ); + } + if (stats.suspicious > 0) { + throw new Error( + `File ${file} reported as malicious ${stats.suspicious} time(s)` + ); + } + const statsStr = Object.entries(stats) + .map(([k, v]) => `${k}=${v}`) + .join(', '); + grunt.log.writeln(`VirusTotal check OK: ${file}, stats:`, statsStr); + return; + } + + elapsed = Date.now() - timeStarted; + grunt.log.writeln( + `VirusTotal check status: ${attributes.status}, elapsed ${elapsed}ms` + ); + + await wait(interval); + } while (elapsed < timeout); + + throw new Error(`Timed out after ${timeout}ms`); + } + + function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + }); +}; diff --git a/desktop/package-lock.json b/desktop/package-lock.json index f1245004..73beea56 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,6 +1,6 @@ { "name": "KeeWeb", - "version": "1.16.3", + "version": "1.16.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/desktop/package.json b/desktop/package.json index 4d070012..416b3c72 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "KeeWeb", - "version": "1.16.3", + "version": "1.16.4", "description": "Free cross-platform password manager compatible with KeePass", "main": "main.js", "homepage": "https://keeweb.info", diff --git a/package-lock.json b/package-lock.json index 0f3be11d..0b73f14d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "keeweb", - "version": "1.16.3", + "version": "1.16.4", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -6829,12 +6829,12 @@ "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz", + "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==", "requires": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", + "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, @@ -14294,6 +14294,16 @@ "uuid": "^3.3.2" }, "dependencies": { + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", diff --git a/package.json b/package.json index 11631099..584756da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keeweb", - "version": "1.16.3", + "version": "1.16.4", "description": "Free cross-platform password manager compatible with KeePass", "main": "Gruntfile.js", "private": true, @@ -45,6 +45,7 @@ "eslint-plugin-promise": "4.2.1", "eslint-plugin-standard": "4.1.0", "exports-loader": "1.1.1", + "form-data": "^3.0.0", "fs-extra": "^9.0.1", "grunt": "1.3.0", "grunt-chmod": "^1.1.1", @@ -73,6 +74,7 @@ "mini-css-extract-plugin": "^1.3.1", "mocha": "^8.2.1", "morphdom": "^2.6.1", + "node-fetch": "^2.6.1", "node-sass": "^5.0.0", "node-stream-zip": "1.12.0", "normalize.css": "8.0.1", diff --git a/release-notes.md b/release-notes.md index c142a1f9..5e8f322c 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,5 +1,9 @@ Release notes ------------- +##### v1.16.4 (2020-12-17) +`-` fix #1656: false positive report on VirusTotal +`+` #1629: possibility to use OneDrive as SPA + ##### v1.16.3 (2020-12-10) `-` fix #1650: keyfiles stored in the app can't be used diff --git a/util/bump-version.js b/util/bump-version.js old mode 100644 new mode 100755 index 0b6422bb..c4181e22 --- a/util/bump-version.js +++ b/util/bump-version.js @@ -1,3 +1,5 @@ +#!/usr/bin/env node + /* eslint-disable no-console */ const fs = require('fs');