...
 
Commits (12)
{
"parser": "babel-eslint",
"plugins": [
"flowtype",
"react"
"flowtype"
],
"extends": [
"eslint:recommended",
"plugin:flowtype/recommended",
"plugin:react/recommended"
"plugin:flowtype/recommended"
],
"parserOptions": {
"ecmaVersion": 8,
......@@ -28,6 +26,7 @@
"rules": {
"flowtype/space-after-type-colon": "off",
"no-console": "off",
"no-unused-vars": "warn"
"no-unused-vars": "warn",
"no-prototype-builtins": "off"
}
}
......@@ -3,20 +3,21 @@
"flow-typed": true
},
"cSpell.words": [
"asyncify",
"authed",
"cleanbuild",
"cleantypes",
"cntinue",
"davidcash",
"devseed",
"haschk's",
"event",
"frame",
"handler",
"haschk's",
"hash",
"interruptible",
"newstring",
"nonauthed",
"origindomain",
"pluginville",
"ravenben",
"wpdevserv"
......
......@@ -18,29 +18,28 @@ import { transformSync as babel } from '@babel/core'
import { relative as relPath, join as joinPath } from 'path'
import webpack from 'webpack'
import WebpackDevServer from 'webpack-dev-server'
// flow-disable-line
import config from './webpack.config'
// flow-disable-line
import pkg from './package'
require('dotenv').config();
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
const {
WEBPACK_PORT,
DEV_ENDPOINT,
HASHING_OUTPUT_LENGTH
HASHING_ALGORITHM
} = process.env;
const configured = config({ NODE_ENV: process.env.NODE_ENV });
if(typeof WEBPACK_PORT !== 'string')
throw new TypeError('WEBPACK_PORT is improperly defined. Did you copy dist.env -> .env ?');
if(typeof DEV_ENDPOINT !== 'string')
throw new TypeError('DEV_ENDPOINT is improperly defined. Did you copy dist.env -> .env ?');
if(typeof HASHING_OUTPUT_LENGTH !== 'string')
throw new TypeError('HASHING_OUTPUT_LENGTH is improperly defined. Did you copy dist.env -> .env ?');
if(typeof HASHING_ALGORITHM !== 'string')
throw new TypeError('HASHING_ALGORITHM is improperly defined. Did you copy dist.env -> .env ?');
const DEV_PORT = parseInt(WEBPACK_PORT, 10);
......@@ -70,6 +69,7 @@ const CLI_BANNER = `/**
*/\n\n`;
const readFileAsync = promisify(readFile);
const generateWebpackConfig = () => config({ NODE_ENV: process.env.NODE_ENV });
// * CLEANTYPES
......@@ -115,12 +115,14 @@ regenerate.description = 'Invokes babel on the files in config, transpiling them
export const build = (): Promise<void> => {
process.env.NODE_ENV = 'production';
const configured = generateWebpackConfig();
return new Promise(resolve => {
webpack(configured, (err, stats) => {
if(err)
{
const details = err.details ? `\n\t${err.details}` : '';
throw `WEBPACK FATAL BUILD ERROR: ${err}${details}`;
throw `WEBPACK FATAL BUILD ERROR: ${err.toString()}${details}`;
}
const info = stats.toJson();
......@@ -151,6 +153,8 @@ bundleZip.description = 'Bundles the build directory into a zip archive for uplo
// * WPDEVSERV
export const wpdevserv = () => {
const configured = generateWebpackConfig();
Object.keys(configured.entry).forEach(entryKey => configured.entry[entryKey] = [
`webpack-dev-server/client?http://${DEV_ENDPOINT}:${DEV_PORT}`,
'webpack/hot/dev-server'
......
......@@ -3,17 +3,18 @@
// flow-disable-line
import pkg from './package'
import webpack from 'webpack'
import { join as joinPath } from 'path'
import { CleanWebpackPlugin } from 'clean-webpack-plugin'
import CopyWebpackPlugin from 'copy-webpack-plugin'
import HtmlWebpackPlugin from 'html-webpack-plugin'
import WriteFileWebpackPlugin from 'write-file-webpack-plugin'
import parseGitIgnore from 'parse-gitignore'
import { readFileSync } from 'fs'
require('dotenv').config();
const { HASHING_OUTPUT_LENGTH } = process.env;
const {
HASHING_ALGORITHM,
APPLICATION_LABEL,
MAX_REQUEST_HISTORY,
} = process.env;
const paths = {};
......@@ -25,9 +26,12 @@ paths.manifest = `${paths.src}/manifest.json`;
paths.components = `${paths.src}/components`;
paths.universe = `${paths.src}/universe`;
paths.assets = `${paths.src}/assets`;
const assetExtensions = ['jpg', 'jpeg', 'png', `gif`, "eot", 'otf', 'svg', 'ttf', 'woff', 'woff2'];
const configure = (NODE_ENV: ?string) => {
NODE_ENV = NODE_ENV || 'production';
const DEV_ENV = NODE_ENV === 'development';
const options = {};
......@@ -75,10 +79,10 @@ const configure = (NODE_ENV: ?string) => {
// ? Expose desired environment variables in the packed bundle
new webpack.DefinePlugin({
'process': {
NODE_ENV: JSON.stringify(NODE_ENV),
HASHING_OUTPUT_LENGTH: JSON.stringify(HASHING_OUTPUT_LENGTH),
},
_NODE_ENV: JSON.stringify(NODE_ENV),
_HASHING_ALGORITHM: JSON.stringify(HASHING_ALGORITHM || 'SHA-256'),
_APPLICATION_LABEL: JSON.stringify(APPLICATION_LABEL || '_haschk'),
_MAX_REQUEST_HISTORY: JSON.stringify(MAX_REQUEST_HISTORY || 1000),
}),
new CopyWebpackPlugin([{
......@@ -120,7 +124,8 @@ const configure = (NODE_ENV: ?string) => {
new HtmlWebpackPlugin({
template: `${paths.src}/welcome.html`,
filename: 'welcome.html'
filename: 'welcome.html',
chunks: []
}),
new WriteFileWebpackPlugin()
......
# The port webpack-dev-server will listen on
WEBPACK_PORT=9300
WEBPACK_PORT=9000
# Where your webpack-dev-server lives (typically localhost or 127.0.0.1)
DEV_ENDPOINT=localhost
# Where your webpack-dev-server lives
DEV_ENDPOINT=127.0.0.1
# The algorithm used to hash RI and file data
HASHING_ALGORITHM=sha256
HASHING_OUTPUT_LENGTH=64
# Options: SHA-256 (<- default), SHA-384, SHA-512
HASHING_ALGORITHM=SHA-256
# The Application Label (AL) is a well-defined string used in BD requests
APPLICATION_LABEL=_haschk
# The maximum number of requests we'll keep around to associate with downloads
MAX_REQUEST_HISTORY=1000
This diff is collapsed.
This diff is collapsed.
......@@ -40,7 +40,13 @@
},
"testPathIgnorePatterns": [
"/node_modules/"
]
],
"globals": {
"_NODE_ENV": "test",
"_HASHING_ALGORITHM": "SHA-256",
"_APPLICATION_LABEL": "_haschk",
"_MAX_REQUEST_HISTORY": "1000"
}
},
"dependencies": {
"@babel/core": "^7.8.4",
......@@ -52,18 +58,23 @@
"@babel/plugin-proposal-throw-expressions": "^7.8.3",
"@babel/preset-env": "^7.8.4",
"@babel/preset-flow": "^7.8.3",
"base32-encode": "^1.1.1",
"axios": "^0.19.2",
"dotenv": "^8.2.0",
"eventemitter3": "^4.0.0",
"install": "^0.13.0",
"url-parse": "^1.4.7"
},
"devDependencies": {
"@babel/cli": "^7.8.4",
"@babel/node": "^7.8.4",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^25.1.0",
"babel-loader": "^8.0.6",
"babel-plugin-source-map-support": "^2.1.1",
"base32-encode": "^1.1.1",
"bluebird": "^3.7.2",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.4.2",
......@@ -72,7 +83,7 @@
"eslint-plugin-flowtype": "^4.6.0",
"fancy-log": "^1.3.3",
"file-loader": "^5.0.2",
"flow-bin": "^0.117.0",
"flow-bin": "^0.118.0",
"flow-typed": "^2.6.2",
"gulp": "^4.0.2",
"gulp-tap": "^2.0.0",
......@@ -93,7 +104,7 @@
"vinyl-fs": "^3.0.3",
"webpack": "^4.41.5",
"webpack-bundle-analyzer": "^3.6.0",
"webpack-dev-server": "^3.10.1",
"webpack-dev-server": "^3.10.3",
"webpack-extension-reloader": "^1.1.4",
"write-file-webpack-plugin": "^4.5.1"
}
......
This diff is collapsed.
/** @flow
* @description Most of the complex core HASCHK logic and functionality is here
* @description Most of the core HASCHK logic and functionality is implemented here
*/
import http from 'axios'
import ParsedUrl from 'url-parse'
import base32Encode from 'base32-encode'
import { EventFrameEmitter } from 'universe/events'
import {
bufferToHex,
GOOGLE_DNS_HTTPS_RI_FN,
////GOOGLE_DNS_HTTPS_RR_FN,
HASHING_OUTPUT_LENGTH,
DANGER_THRESHOLD,
Debug,
HASHING_ALGORITHM,
GOOGLE_DNS_HTTPS_BACKEND_QUERY,
extractAnswerDataFromResponse,
JUDGEMENT_UNKNOWN,
JUDGEMENT_UNSAFE,
JUDGEMENT_SAFE
JUDGEMENT_SAFE,
} from 'universe'
import {
DownloadEventFrame,
FrameworkEventEmitter
} from 'universe/events'
import type { Chrome } from 'components/background'
export default (oracle: FrameworkEventEmitter, chrome: Chrome, context: Object) => {
oracle.addListener('download.incoming', async (e, downloadItem) => {
const eventFrame = new DownloadEventFrame(oracle, context);
const startTime = (new Date(downloadItem.startTime)).getTime();
const tabLoadTime = context.timingData[downloadItem.referrer] || startTime;
// ! This step protects against certain timing attacks (see issue #3)
if(startTime - tabLoadTime <= DANGER_THRESHOLD)
await oracle.emit('download.suspiciousOrigin', eventFrame, downloadItem);
// ? If the event was ended prematurely, assume downloadItem was handled
// ? elsewhere in the event flow
if(!eventFrame.stopped)
eventFrame.finish();
setDefaultBadgeState,
setIncomingBadgeState,
setErrorBadgeState,
setSafeBadgeState,
setUnsafeBadgeState,
} from 'universe/ui'
import type { Chrome } from 'universe'
import type { EventFrame } from 'universe/events'
declare var crypto;
export default (oracle: EventFrameEmitter, chrome: Chrome, context: Object) => {
// ? These events update the context.downloadItems cache
const updateDownloadItemInContext = downloadItem => context.downloadItems[downloadItem.id] = downloadItem;
oracle.addListener('download.incoming', (e, downloadItem) => updateDownloadItemInContext(downloadItem));
oracle.addListener('download.paused', (e, downloadItem) => updateDownloadItemInContext(downloadItem));
oracle.addListener('download.resumed', (e, downloadItem) => updateDownloadItemInContext(downloadItem));
oracle.addListener('download.interrupted', (e, downloadItem) => updateDownloadItemInContext(downloadItem));
oracle.addListener('download.completed', (e, downloadItem) => updateDownloadItemInContext(downloadItem));
// ? This event fires whenever haschk decides a download is NOT safe
oracle.addListener(`judgement.${JUDGEMENT_UNSAFE}`, (e: EventFrame, downloadItem) => {
chrome.downloads.removeFile(downloadItem.id, () => {
if(chrome.runtime.lastError)
oracle.emit('error', chrome.runtime.lastError.message);
});
});
// ? This is the NAH vs AH core "judgement" logic
oracle.addListener('download.completed', (e, downloadItem) => {
if(context.handledDownloadItems.has(downloadItem.id))
return;
// ? This event sets the default badge state on startup
oracle.addListener('startup', () => setDefaultBadgeState());
(async (downloadItem) => {
let authedHash: ?string;
let nonauthedHash: ?string;
let authedHashRaw: ?string;
let completed = false;
try {
// ? Since it's finished downloading, grab the file's data
const $file = await http.get(`file://${downloadItem.filename}`, { responseType: 'arraybuffer' });
// ? Hash file data with proper algorithm
nonauthedHash = bufferToHex(await crypto.subtle.digest('SHA-256', $file.data));
// ? Determine resource identifier and prepare for DNS request
const resourcePath = (new ParsedUrl(downloadItem.url, {})).pathname;
const resourceIdentifier = bufferToHex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(resourcePath)));
const outputLength = parseInt(HASHING_OUTPUT_LENGTH);
if(!resourceIdentifier || resourceIdentifier.length != outputLength)
throw new Error('failed to hash resource identifier');
const [ riLeft, riRight ] = [
resourceIdentifier.slice(0, outputLength / 2),
resourceIdentifier.slice(outputLength / 2, outputLength)
];
// ? Make https-based DNS request
const targetDomain = downloadItem.originDomain;
const $authedHash = await http.get(GOOGLE_DNS_HTTPS_RI_FN(riLeft, riRight, targetDomain));
authedHashRaw = !$authedHash.data.Answer ? '<no answer>' : $authedHash.data.Answer.slice(-1)[0].data;
authedHash = authedHashRaw.replace(/[^0-9a-f]/gi, '');
completed = true;
}
catch(error) {
oracle.emit('error', error);
}
// ! We want to make sure we don't catch any errors from oracle.emit
if(completed) {
if(!authedHash || !authedHashRaw)
throw new TypeError('unexpected null type encountered');
// ? Compare DNS result (auth) with hashed local file data (nonauthed)
if(authedHash.length !== authedHashRaw.length - 2)
e.judgeUnknown(downloadItem);
else
authedHash !== nonauthedHash ? e.judgeUnsafe(downloadItem) : e.judgeSafe(downloadItem);
}
})(downloadItem);
// ? This event responds to any requests for the list of known download
// ? items (i.e. from another component of the extension)
oracle.addListener('request.updateDownloadItems', () => {
oracle.emit('response.updateDownloadItems', context.downloadItems);
});
oracle.addListener('judgement.unknown', downloadItem => {
context.judgedDownloadItems.push({
downloadItem: downloadItem,
judgement: JUDGEMENT_UNKNOWN
});
});
// ? These events update the badge state upon certain events happening
oracle.addListener('error', () => setErrorBadgeState());
oracle.addListener('download.incoming', () => setIncomingBadgeState());
oracle.addListener('download.paused', () => setDefaultBadgeState());
oracle.addListener('download.resumed', () => setIncomingBadgeState());
oracle.addListener('download.interrupted', () => setDefaultBadgeState());
oracle.addListener(`judgement.${JUDGEMENT_UNKNOWN}`, () => setDefaultBadgeState());
oracle.addListener(`judgement.${JUDGEMENT_SAFE}`, () => setSafeBadgeState());
oracle.addListener(`judgement.${JUDGEMENT_UNSAFE}`, () => setUnsafeBadgeState());
// ? This event is the heart of the extension where we implement the HASCHK
// ? protocol
oracle.addListener('download.completed', async (e: EventFrame, downloadItem) => {
if(downloadItem.judgement === JUDGEMENT_UNKNOWN) {
oracle.emit(`judgement.${JUDGEMENT_UNKNOWN}`, downloadItem);
return;
}
// ? Since it's finished downloading, grab the file's data
const file = await http.get(`file://${downloadItem.filename}`, { responseType: 'arraybuffer' });
oracle.addListener('judgement.safe', downloadItem => {
context.judgedDownloadItems.push({
downloadItem: downloadItem,
judgement: JUDGEMENT_SAFE
// ? Hash file data with proper algorithm
const base32FileHash = base32Encode(await crypto.subtle.digest(HASHING_ALGORITHM, file.data), 'Crockford', {
padding: false
});
});
oracle.addListener('judgement.unsafe', downloadItem => {
context.judgedDownloadItems.push({
downloadItem: downloadItem,
judgement: JUDGEMENT_UNSAFE
// ? Construct BASE32 encoded URN and slice it up to yield C1 and C2
const base32Urn = base32Encode((new TextEncoder()).encode(`urn:hash::sha256:${base32FileHash}`), 'Crockford', {
padding: true
});
Debug.if(() =>
base32Urn.length % 2 !== 0 && console.warn(`URN length is not an even number (${base32Urn.length})!`));
const [ C1, C2 ] = [
base32Urn.slice(0, base32Urn.length / 2),
base32Urn.slice(base32Urn.length / 2, base32Urn.length),
];
// ? Make https-based DNS request
const queryUri = GOOGLE_DNS_HTTPS_BACKEND_QUERY(C1, C2, downloadItem.backendDomain);
const data = extractAnswerDataFromResponse(await http.get(queryUri));
Debug.log(chrome, `C1: ${C1}`);
Debug.log(chrome, `C2: ${C2}`);
Debug.log(chrome, `backend domain: ${downloadItem.backendDomain}`);
Debug.log(chrome, `query response data: ${data}`);
// ? Compare DNS result with expected
oracle.emit(`judgement.${data === '"OK"' ? JUDGEMENT_SAFE : JUDGEMENT_UNSAFE}`, downloadItem);
});
};
/** @flow
* @description Most of the core HASCHK logic and functionality is implemented here
*/
import { EventFrameEmitter } from 'universe/events'
import {
Debug,
JUDGEMENT_UNKNOWN,
JUDGEMENT_UNSAFE,
JUDGEMENT_SAFE,
} from 'universe'
import type { Chrome } from 'universe'
import type { EventFrame } from 'universe/events'
declare var crypto;
export default (oracle: EventFrameEmitter, chrome: Chrome) => {
// ? This is our generic error handler that fires whenever an error occurs
oracle.addListener('error', (errorFrame, exception, errorArgs) => {
Debug.log(chrome, `ErrorFrame:`, errorFrame);
Debug.log(chrome, `Exception object:`, exception);
Debug.log(chrome, `ErrorArgs:`, errorArgs);
console.error(`HASCHK ERROR: ${exception}`);
});
// * Debug-only event listeners
Debug.if(() => oracle.addListener('startup', (e: EventFrame) => {
console.log(`[BACKGROUND EVENT] ${e.name}:`, e);
}));
const downloadLogTemplate = (e: EventFrame, downloadItem) => {
console.log(`[BACKGROUND EVENT] ${e.name}: ${downloadItem.finalUrl}`);
};
Debug.if(() => oracle.addListener('download.incoming', (e: EventFrame, downloadItem) => {
downloadLogTemplate(e, downloadItem);
}));
Debug.if(() => oracle.addListener('download.paused', (e: EventFrame, downloadItem) => {
downloadLogTemplate(e, downloadItem);
}));
Debug.if(() => oracle.addListener('download.resumed', (e: EventFrame, downloadItem) => {
downloadLogTemplate(e, downloadItem);
}));
Debug.if(() => oracle.addListener('download.interrupted', (e: EventFrame, downloadItem) => {
downloadLogTemplate(e, downloadItem);
}));
Debug.if(() => oracle.addListener('download.completed', (e: EventFrame, downloadItem) => {
console.log(`[BACKGROUND EVENT] ${e.name}:`, e, downloadItem);
}));
const judgementLogTemplate = (e: EventFrame, downloadItem) => {
console.log(`[BACKGROUND EVENT] ${e.name}: file "${downloadItem.filename}"`);
};
Debug.if(() => oracle.addListener(`judgement.${JUDGEMENT_UNKNOWN}`, (e: EventFrame, downloadItem) => {
judgementLogTemplate(e, downloadItem);
}));
Debug.if(() => oracle.addListener(`judgement.${JUDGEMENT_SAFE}`, (e: EventFrame, downloadItem) => {
judgementLogTemplate(e, downloadItem);
}));
Debug.if(() => oracle.addListener(`judgement.${JUDGEMENT_UNSAFE}`, (e: EventFrame, downloadItem) => {
judgementLogTemplate(e, downloadItem);
}));
};
/** @flow
* @description All HASCHK UI event hooks go here, including for popup/options
*/
// TODO: need to move this type of documentation into the wiki
// ? `oracle` emits the events that you should be hooking into. Feel free to add
// ? more events as they become necessary. Note that new `download.complete`
// ? events must be added with oracle::prependListener().
// ? Currently, events include:
// ? * core.init the extension is loaded by chrome (once) () => {}
// ? * download.incoming a new download has been observed async (e, downloadItem) => {}
// ? * download.suspiciousOrigin a new download has been observed async (e, downloadItem) => {}
// ? * download.completed a download has completed; must be prepended async (e, downloadItem) => {}
// ? * judgement.safe a resource's content is as expected (downloadItem) => {}
// ? * judgement.unsafe a resource's content is mutated/corrupted (downloadItem) => {}
// ? * judgement.unknown a resource's content cannot be judged (downloadItem) => {}
// ? * error a new error event has occurred (err) => {}
// ? Extension event flows:
// ? 1. Chrome enables extension (only once): => core.init
// ? 2. User triggered download: => download.incoming => download.suspiciousOrigin -?-> #5
// ? 3. Triggered (unhandled) download finishes: => download.completed => #5
// ? 4. Some error occurs: => error
// ? 5. A judgement is rendered: => judgement.safe|unsafe|unknown
// ? Note: at any point during flows #2/3, the flow can be cancelled (i.e.
// ? remaining event handlers not processed) after which one of the
// ? `judgement.*` events should be triggered. This is immediate (i.e. the rest
// ? of the event stack is skipped). All events triggered in flows #2/3 receive
// ? an EventFrame instance (e) that allows access to HASCHK's internals, i.e.
// ? via ::judgeUnsafe(), ::judgeSafe(), etc.
// ? All oracle events receive a modified downloadItem object with the following
// ? extra props:
// ? * downloadItem.originDomain the DNS lookup base domain
// ? Note that a `downloadItem` object is not guaranteed to have any properties
// ? of the DownloadItem extension API except ::OriginDomain. Do an existence
// ? check before trying to use them. See the Extensions API for more details.
import { setBadge } from 'universe/ui'
import { FrameworkEventEmitter } from 'universe/events'
import type { Chrome } from 'components/background'
export default (oracle: FrameworkEventEmitter, chrome: Chrome, context: Object) => {
// ? This is our generic error handler that fires whenever an error occurs
oracle.addListener('error', err => {
setBadge(chrome)('ERR', '#000');
console.error(`HASCHK ERROR: ${err}`);
});
// ? This event fires whenever a new download is observed
oracle.addListener('download.incoming', async (e, downloadItem) => {
// TODO: add the new download to popup UI (tell popup UI to update)
// ! Note how this function is async, which means you can await Promises
console.log(`file "${downloadItem.filename}" incoming`);
});
// ? This event fires whenever a suspicious originDomain needs to be
// ? verified manually by the user
oracle.addListener('download.suspiciousOrigin', async (e, downloadItem) => {
// TODO: must ask user to approve downloadItem.urlDomain via the UI
// ! Note how this function is async, i.e. means you can await Promises!
// ! You should use async/await syntax to ensure the extension waits for
// ! the UI to finish before continuing
console.log(`file "${downloadItem.filename}" flagged for suspicious origin`);
});
// ? This event fires whenever haschk decides it cannot judge a download
oracle.addListener('judgement.unknown', downloadItem => {
setBadge(chrome)(' ', '#D0D6B5');
console.log(`file "${downloadItem.filename}" judgement: UNKNOWN`);
});
// ? This event fires whenever haschk decides a download is safe
oracle.addListener('judgement.safe', downloadItem => {
setBadge(chrome)(' ', '#6EEB83');
console.log(`file "${downloadItem.filename}" judgement: SAFE`);
});
// ? This event fires whenever haschk decides a download is NOT safe
oracle.addListener('judgement.unsafe', downloadItem => {
setBadge(chrome)(' ', '#FF3C38');
console.log(`file "${downloadItem.filename}" judgement: UNSAFE`);
});
oracle.addListener('ui.clear', () => {
setBadge(chrome)('');
context.judgedDownloadItems = [];
console.clear();
});
};
......@@ -3,25 +3,52 @@
* @name Background
*/
import { FRAMEWORK_EVENTS } from 'universe'
import { EventEmitter } from 'universe/events'
import { Debug } from 'universe'
import { EventFrameEmitter } from 'universe/events'
import registerChromeEvents from 'components/background/events.chrome'
import registerCoreEvents from 'components/background/events.core'
import registerUIEvents from 'components/background/events.ui'
import registerDebugEvents from 'components/background/events.debug'
declare var chrome: any;
export type Chrome = chrome;
// ? `oracle` emits the events that you should be hooking into. Feel free to add
// ? more events as they become necessary.
// ? All extension events:
// ? * startup The extension is loaded by chrome (once) async (EventFrame, Details) => {}
// ? * download.incoming A new download has been observed async (EventFrame, DownloadItem*) => {}
// ? * download.completed A download has completed; must be prepended async (EventFrame, DownloadItem) => {}
// ? * judgement.safe A resource's content is as expected async (EventFrame, DownloadItem) => {}
// ? * judgement.unsafe A resource's content is mutated/corrupted async (EventFrame, DownloadItem) => {}
// ? * judgement.unknown A resource's content cannot be judged async (EventFrame, DownloadItem) => {}
// ? * error An error event occurred (NOT `await`-ed!) (EventFrame, Exception) => {}
// ? * popup.ui.clear Clear the entries in the popup ui async (EventFrame) => {}
// ? Main extension event flow (excluding UI updates, options, ports):
// ? 1. Chrome enables extension (only once): => startup
// ? 2. User triggered download: => download.incoming => #3
// ? 3. Triggered (unhandled) download finishes: => download.completed => #5
// ? 4. Some error occurs: => error
// ? 5. A judgement is rendered: => judgement.safe | judgement.unsafe | judgement.unknown
// ? * Note that, for listeners attached to the `download.incoming` event, the
// ? `DownloadItem` instance is missing many of its properties, including
// ? HASCHK-specific extensions (see `universe/index.js`). Do not expect them to
// ? exist until the `download.complete` event and others later in the flow.
const oracle = new EventEmitter(FRAMEWORK_EVENTS);
const oracle = new EventFrameEmitter();
const context = {
handledDownloadItems: new Set(),
judgedDownloadItems: [],
registeredPorts: [],
activePorts: [],
timingData: {}
// ? (download) id -> DownloadItem
downloadItems: {},
// ? requestId -> request stack [req n, ..., req 1]
// ! Limited to MAX_REQUEST_HISTORY requests! (FIFO eviction policy)
navHistory: new Map(),
};
declare var chrome: any;
Debug.if(() => console.warn('!! == HASCHK IS IN DEVELOPER MODE == !!'));
registerChromeEvents(oracle, chrome, context);
registerDebugEvents(oracle, chrome);
registerCoreEvents(oracle, chrome, context);
registerUIEvents(oracle, chrome, context);
/** @flow
* @description Options and configuration for HASCHK plugin
* @name Options
* @description Extension configuration options UI is implemented here
*/
import './index.css'
......
/** @flow
* @description HASCHK popup functionality
* @name Popup
* @description All HASCHK popup UI logic goes here
*/
import './index.css'
import {
HaschkEventPort,
guaranteeElementById
} from 'universe/ui';
EventFrameEmitter,
portMessageToEventFrame,
eventFrameToPortMessage,
} from 'universe/events'
import {
clearPopupUI,
redrawPopupUI,
} from 'universe/ui'
import {
Debug,
JUDGEMENT_UNKNOWN,
JUDGEMENT_UNSAFE,
JUDGEMENT_SAFE
} from 'universe';
JUDGEMENT_SAFE,
} from 'universe'
declare var chrome:any;
import type { EventFrame } from 'universe/events'
const bridge = new HaschkEventPort(chrome);
const downloadList = guaranteeElementById('downloadItems');
// TODO: ensure bugs are fixed and UI meets specification outlined at
// TODO: https://github.com/morty-c137-prime/HASCHK/issues/31
declare var chrome:any;
const appendDownloadToDownloadList = (downloadItem: any, judgement: string) => {
// flow-disable-line
let elem: HTMLElement = document.createElement('li');
elem.setAttribute('id', downloadItem.id);
elem.innerHTML = `#${downloadItem.id}: ${downloadItem.filename} <span class=${judgement}>[${
judgement == JUDGEMENT_UNKNOWN ? '?' : (judgement == JUDGEMENT_UNSAFE ? 'X' : '')
}]</span>`;
downloadList.insertBefore(elem, downloadList.childNodes[0]);
};
const oracle = new EventFrameEmitter();
const port = chrome.runtime.connect();
let dlItems = {};
window.onload = async () => {
await bridge.emit('fetch', 'judgedDownloadItems').then((res) => {
res.judgedDownloadItems.forEach((download) => {
appendDownloadToDownloadList(download.downloadItem, download.judgement);
});
});
const updateDlItemsAndRedrawPopupUI = downloadItem => {
dlItems[downloadItem.id] = downloadItem;
redrawPopupUI(dlItems);
};
bridge.on('judgement.unsafe', (downloadItem) => {
appendDownloadToDownloadList(downloadItem, JUDGEMENT_UNSAFE);
// ? This fires when we receive a message from another component of the
// ? extension. We translate the message into an event and emit it
port.onMessage.addListener(data => {
Debug.log(chrome, '[POPUP EVENT] (event and args received through port)', port, data);
const { eventFrame, args } = portMessageToEventFrame(data);
oracle.emitIgnoreGlobalListeners(eventFrame.name, eventFrame, ...args);
});
bridge.on('judgement.safe', (downloadItem) => {
appendDownloadToDownloadList(downloadItem, JUDGEMENT_SAFE);
// ? Whenever an event is triggered, we send it out for any other
// ? interested components in the system to pick up on
const globalListener = oracle.addGlobalListener((eventFrame: EventFrame, args: Array<any>) => {
Debug.log(chrome, '[POPUP EVENT] (event and args sent through port)', port, eventFrame, args);
port.postMessage(eventFrameToPortMessage(eventFrame, args));
});
bridge.on('judgement.unknown', (downloadItem) => {
appendDownloadToDownloadList(downloadItem, JUDGEMENT_UNKNOWN);
// ? When a port is destroyed for whatever reason (page refresh, popup
// ? closed, etc), remove the (dead) global listener associated with it
port.onDisconnect.addListener(() => {
Debug.log(chrome, '[POPUP EVENT] (port disconnected)', port);
oracle.removeGlobalListener(globalListener);
});
// ??
// ?? Demo/development UI components
// ??
// guaranteeElementById('fetchJudgedDownloadItems').addEventListener('click', (e: MouseEvent) => {
// e.preventDefault();
// downloadList.innerHTML = '';
// bridge.emit('fetch', 'judgedDownloadItems').then((res)=>{
// Object.keys(res.judgedDownloadItems).forEach((id) =>
// {
// let item = res.judgedDownloadItems[id];
// // flow-disable-line
// let download = document.createElement('li');
// download.setAttribute('id', item.downloadItem.id);
// download.innerHTML = `#${item.downloadItem.id}: ${item.downloadItem.filename} [${
// item.judgement == 'unknown' ? '?' : (item.judgement == 'unsafe' ? 'X' : '✓')
// }]`;
// downloadList.appendChild(download);
// });
// });
// });
// guaranteeElementById('unsafe_test').addEventListener('click', () => {
// bridge.emit('.judgement.unsafe', {
// id: Math.floor(Math.random() * 1000 + 1000),
// filename: "fake_unsafe.pdf"
// });
// });
// guaranteeElementById('safe_test').addEventListener('click', () => {
// bridge.emit('.judgement.safe', {
// id: Math.floor(Math.random() * 1000 + 1000),
// filename: "fake_safe.pdf"
// });
// });
// guaranteeElementById('unknown_test').addEventListener('click', () => {
// bridge.emit('.judgement.unknown', {
// id: Math.floor(Math.random() * 1000 + 1000),
// filename: "fake_unknown.pdf"
// });
// });
guaranteeElementById('clear').addEventListener('click', () => {
bridge.emit('.ui.clear');
downloadList.innerHTML = '';
// ? These events cause a snappy "live" UI redraw when the popup is open
oracle.addListener('download.incoming', (e: EventFrame, downloadItem) => updateDlItemsAndRedrawPopupUI(downloadItem));
oracle.addListener(`judgement.${JUDGEMENT_UNKNOWN}`, (e: EventFrame, downloadItem) => updateDlItemsAndRedrawPopupUI(downloadItem));
oracle.addListener(`judgement.${JUDGEMENT_SAFE}`, (e: EventFrame, downloadItem) => updateDlItemsAndRedrawPopupUI(downloadItem));
oracle.addListener(`judgement.${JUDGEMENT_UNSAFE}`, (e: EventFrame, downloadItem) => updateDlItemsAndRedrawPopupUI(downloadItem));
// ? This event fires when we received an updated DownloadItems map
oracle.once('response.updateDownloadItems', (e: EventFrame, updatedDlItems) => {
dlItems = updatedDlItems;
redrawPopupUI(dlItems);
});
// ? Now we request an updated DownloadItems map!
oracle.emit('request.updateDownloadItems');
// TODO: add events for DOM elements (including clearing the UI)
......@@ -2,7 +2,7 @@
"manifest_version": 2,
"background": {
"page": "background.html",
"persistent": false
"persistent": true
},
"browser_action": {
"default_popup": "popup.html",
......@@ -18,7 +18,7 @@
"<all_urls>",
"file://*/",
"downloads",
"tabs"
"webRequest"
],
"icons": {
"16": "assets/icon/16x16.png",
......
/* @flow */
import InterruptibleEventFrame from './InterruptibleEventFrame'
import type { FrameworkEventEmitter } from 'universe/events'
// TODO: document me!
export default class DownloadEventFrame extends InterruptibleEventFrame {
_context: any;
constructor(oracle: FrameworkEventEmitter, context: {}, callback: ?() => void) {
super(oracle, null, callback);
this._context = context;
}
async shortCircuitEventLoop(eventName: string, downloadItem: any, ...args: Array<any>) {
this._context.handledDownloadItems.add(downloadItem.id);
await super.shortCircuitEventLoop(eventName, downloadItem, ...args);
}
// ? Returns a Promise!
judgeUnsafe(downloadItem: any, ...args: Array<any>) {
return this.shortCircuitEventLoop('judgement.unsafe', downloadItem, ...args);
}
// ? Returns a Promise!
judgeSafe(downloadItem: any, ...args: Array<any>) {
return this.shortCircuitEventLoop('judgement.safe', downloadItem, ...args);
}
// ? Returns a Promise!
judgeUnknown(downloadItem: any, ...args: Array<any>) {
return this.shortCircuitEventLoop('judgement.unknown', downloadItem, ...args);
}
}
/* @flow */
// TODO: document me!
/**
* This class represents a default event frame. It is similar in intent to the
* DOM's event interface.
*
* * See also: https://developer.mozilla.org/en-US/docs/Web/API/Event
*/
export default class EventFrame {
name: string = '<unknown>';
stopped = false;
finished = false;
_finished: any;
_continueFn: HandlerFn;
_finishedFn: HandlerFn;
_continueFn: Function;
_finishedFn: Function;
constructor(continueFn: HandlerFn, finishedFn: HandlerFn) {
this._continueFn = continueFn;
this._finishedFn = finishedFn;
constructor(continueFn: ?Function, finishedFn: ?Function) {
this._continueFn = continueFn || (() => {});
this._finishedFn = finishedFn || (() => {});
}
continue(...args: Array<any>) {
this._continueFn(...args);
/**
* Interrupt the event loop. The `continue()` and `finish()` methods will
* not be called (but can be called manually).
*/
stop() {
return this.stopped = true;
}
stop() {
this.stopped = true;
/**
* This method is called after each registered listener is run so long as
* `stopped` remains false.
*
* @param {*} [args]
*/
continue(...args: Array<any>) {
return this._continueFn(...args);
}
// ? Should always be called eventually; is idempotent
/**
* This method is called automatically after all listeners are finished if
* `stopped` is false. Also, this method is idempotent.
*
* @param {*} [args]
*/
finish(...args: Array<any>) {
if(!this.finished)
{
......@@ -33,5 +53,3 @@ export default class EventFrame {
return this._finished;
}
}
export type HandlerFn = (...args: Array<any>) => any;
/* @flow */
import EventEmitter from 'eventemitter3'
import { EventFrame } from 'universe/events'
import type { ListenerFn } from 'universe/events'
/**
* Unwinds the event loop so that each event listener is await-ed
* "synchronously".
*
* @param {EventFrameEmitter} emitter Which emitter we're emitting with
* @param {Number} index Where we are in the array of listeners
* @param {Array<ListenerFn>} listeners Said array of listeners
* @param {EventFrame} eventFrame The EventFrame instance
* @param {*} [args] Any arguments passed on to the listeners
*/
const asyncEmit = async (emitter: EventFrameEmitter,
index: number,
listeners: Array<ListenerFn>,
eventFrame: EventFrame,
args: Array<any>) =>
{
const listener: ?ListenerFn = listeners[index];
if(listener)
{
if(listener.$once)
emitter.removeListener(eventFrame.name, listener, undefined, true);
await listener(eventFrame, ...args);
!eventFrame.stopped && await asyncEmit(emitter, index + 1, listeners, eventFrame, args);
}
};
/**
* Modify a listener to interrupt the event loop when EventFrames are
* `stop()`-ed
*
* @param {ListenerFn} listener
* @param {boolean} [once=false]
*/
const asyncifyListener = (listener: ListenerFn, once: ?boolean) => {
const listenerActual = listener;
listener = async (eventFrame: EventFrame, ...args: Array<any>) => {
await listenerActual(eventFrame, ...args);
!eventFrame.stopped && await eventFrame.continue();
};
listener.$once = once || false;
return listener;
};
/**
* An extension of `EventEmitter3`, based on the NodeJS EventEmitter, that
* additionally supports async functions as listeners and awaits them as one
* would expect. Also supports global listeners that react to all events.
*
* * See also: https://github.com/primus/eventemitter3
*/
export default class EventFrameEmitter extends EventEmitter {
_globalListeners: { before: Set<ListenerFn>, after: Set<ListenerFn> } = {
before: new Set(),
after: new Set(),
};
constructor(...args: Array<any>) {
super(...args);
}
async _emit(eventName: string, args: Array<any>, ignoreGlobalListeners: boolean) {
const eventFrame = args[0] instanceof EventFrame ? args.shift() : new EventFrame();
const listeners = this.listeners(eventName);
if(!listeners.length && (ignoreGlobalListeners || !(this._globalListeners.before.size || this._globalListeners.after.size)))
return false;
try {
eventFrame.name === '<unknown>' && (eventFrame.name = eventName);
if(!eventFrame.stopped && !ignoreGlobalListeners) {
for (const listener of this._globalListeners.before) {
await listener(eventFrame, args);
if(eventFrame.stopped)
return true;
}
}
await asyncEmit(this, 0, listeners, eventFrame, args);
if(!eventFrame.stopped && !ignoreGlobalListeners) {
for (const listener of this._globalListeners.after) {
await listener(eventFrame, args);
if(eventFrame.stopped)
return true;
}
}
!eventFrame.stopped && await eventFrame.finish();
}
catch(error) {
eventFrame.name = `[error] ${eventFrame.name}`;
eventFrame.stop();
super.emit('error', eventFrame, error, args);
}
return true;
}
/**
* "Synchronously" calls each of the async listeners registered for the
* event named eventName, in the order they were registered, passing the
* EventFrame and supplied arguments to each.
*
* Returns `true` if the event had listeners and `false` otherwise. If there
* are global listeners present, this method will always return true.
*
* It would be wise to `await` the resolution of this function.
*
* * For more details, see: https://nodejs.org/api/events.html
*
* @param {String} eventName
* @param {*} [args]
*/
async emit(eventName: string, ...args: Array<any>) {
return await this._emit(eventName, args, false);
}
/**
* Same as `emit()` except global listeners will not be called. Use this
* method to avoid infinite loops of event handlers calling each other.
*
* Returns `true` if the event had listeners and `false` otherwise. Global
* listeners do not factor into this calculation.
*
* It would be wise to `await` the resolution of this function.
*
* @param {String} eventName
* @param {*} [args]
*/
async emitIgnoreGlobalListeners(eventName: string, ...args: Array<any>) {
return await this._emit(eventName, args, true);
}
/**
* Adds a listener that will interrupt the event loop if the EventFrame is
* `stop()`-ed. Note that calls to `removeListener()` should pass in the
* return value of this function as the listener to be removed unless
* `eventName == 'error'`.
*
* Note that the first argument passed to the listener is an EventFrame
* instance followed by any additional arguments.
*
* * For more details, see: https://nodejs.org/api/events.html
*
* @param {String} eventName
* @param {ListenerFn} listener
* @param {object} context
*/
addListener(eventName: string | Symbol, listener: ListenerFn, context: ?{}) {
listener = eventName.toString() == 'error' ? listener : asyncifyListener(listener);
super.addListener(eventName, listener, context);
return listener;
}
/**
* Same functionality as `addListener()` except this event listener will be
* fired on every emission. Note that calls to `removeGlobalListener()`
* should pass in the return value of this function as the listener to be
* removed.
*
* Listeners should have the following function signature:
* (eventFrame: EventFrame, args: Array<any>) => { ... }
*
* @param {ListenerFn} listener
* @param {Boolean} [triggerEarly=false] listener will be called BEFORE non-globals if true, AFTER if false
*/
addGlobalListener(listener: ListenerFn, triggerEarly: ?boolean) {
listener = asyncifyListener(listener);
this._globalListeners[triggerEarly ? 'before' : 'after'].add(listener);
return listener;
}
/**
* Removes the specified listener from the global array of listeners. This
* method returns `true` if the listener was successfully removed and
* `false` otherwise. Only removes a single listener.
*
* @param {ListenerFn} listener
*/
removeGlobalListener(listener: ListenerFn) {
return this._globalListeners.before.delete(listener) || this._globalListeners.after.delete(listener);
}
/**
* Removes all currently registered global listeners.
*/
removeAllGlobalListeners() {
this._globalListeners.before.clear();
this._globalListeners.after.clear();
}
/**
* Same as `addListener()` except, after a single invocation, the listener
* is removed.
*
* @param {String} eventName
* @param {ListenerFn} listener
* @param {object} context
*/
once(eventName: string | Symbol, listener: ListenerFn, context: ?{}) {
listener = eventName.toString() == 'error' ? listener : asyncifyListener(listener, true);
super.once(eventName, listener, context);
return listener;
}
/**
* Alias of `addListener()`.
*
* @param {String} eventName
* @param {ListenerFn} listener
* @param {object} context
*/
on(...args: Array<any>) {
return this.addListener(...args);
}
}
/* @flow */
import EventEmitter from 'eventemitter3'
import { EventFrame } from 'universe/events'
import type { ListenerFn as SuperListenerFn } from 'eventemitter3'
import type { ListenerFn } from 'universe/events'
// TODO: document me!
const asyncEmit = async (index: number, listeners: Array<ListenerFn>, args: Array<any>) => {
const handler: ?ListenerFn = listeners[index];
if(!handler)
return Promise.resolve();
await Promise.resolve(handler(...args));
await asyncEmit(index + 1, listeners, args); // ? I've unwound the loop here and made each iteration async!
};
const checkEventFrame = (eventName: string, eventFrame: EventFrame) => {
if(typeof eventFrame.stopped !== 'boolean')
{
throw new TypeError(
`first argument (arg1) passed to handlers via emit('${eventName}', arg1, ...) `
+`must be an EventFrame instance or expose compatible interface, got ${eventFrame.toString()} instead`
);
}
}
export default class FrameworkEventEmitter extends EventEmitter {
_frameworkEvents = [];
constructor(frameworkEvents: ?Array<string>, ...args: Array<any>) {
super(...args);
this._frameworkEvents = frameworkEvents || [];
}
// ? Modify emit to handle async handlers and errors in handlers
// flow-disable-line
async emit(event: string, ...args: Array<any>) {
try {
await asyncEmit(0, ((this.listeners(event): any): Array<ListenerFn>), args);
}
catch(error) {
error.eventName = event;
error.eventArgs = args;
super.emit('error', error);
}
}
// ? Modify addListener to interrupt event loop when EventFrames are stop()ed
addListener(eventName: string | Symbol, eventHandler: ListenerFn, context: ?{}, prepend: ?boolean) {
if(this._frameworkEvents.includes(eventName)) {
const handlerActual = eventHandler;
eventHandler = async (eventFrame: EventFrame, ...args: Array<any>) => {
checkEventFrame(eventName.toString(), eventFrame);
await Promise.resolve(eventFrame.stopped || handlerActual(eventFrame, ...args));
}
}
super.addListener(eventName, ((eventHandler: any): SuperListenerFn), context);
// flow-disable-line
if(prepend && !this._events[eventName].fn)
{
// flow-disable-line
this._events[eventName] = [this._events[eventName].pop(), ...this._events[eventName]];
}
return this;
}
prependListener(eventName: string | Symbol, eventHandler: ListenerFn, context: ?{}) {
return this.addListener(eventName, eventHandler, context, true);
}
appendListener(...args: Array<any>) {
return this.addListener(...args);
}
on(...args: Array<any>) {
return this.addListener(...args);
}
}
/** @flow */
// TODO: document me
// * In hindsight, this class could be made skinnier. We could easily remove
// * the emit event functionality and this class would still do everything it
// * needs to. I will leave it in for now but know that this will probably get
// * removed unless someone finds a use for it.
// TODO: consider moving this from universe/ui to universe/events
export function portEvent(event: string, ...data: Array<mixed>)
{
if(data.length == 1) {
return {
event: event,
data: [data[0]]
}
}
return {
event: event,
data: data
}
}
export default class HaschkEventPort
{
#port;
handlers = {};
constructor(chrome: any) {
this.#port = chrome.runtime.connect();
this.#port.onMessage.addListener(message => {
if(message.event != undefined)
this.handlers[message.event](...message.data);
});
}
on(event: string, callback: (...data: Array<mixed>) => any) {
this.handlers[event] = callback;
}
async emit(_event: string,..._data: Array<mixed>) {
return await new Promise(resolve => {
this.#port.postMessage(portEvent(_event, ..._data));
this.#port.onMessage.addListener(event_response => {
this.#port.onMessage.removeListener();
resolve(event_response);
});
});
}
}
export {
HaschkEventPort
}
/* @flow */
import EventFrame from './EventFrame'
import type { FrameworkEventEmitter } from 'universe/events'
// TODO: document me!
export default class InterruptibleEventFrame extends EventFrame {
_oracle: FrameworkEventEmitter;
constructor(oracle: FrameworkEventEmitter, cnt: ?Function, fin: ?Function) {
super(
cnt || (() => {}),
fin || (() => {})
);
this._oracle = oracle;
}
async shortCircuitEventLoop(eventName: string, ...args: Array<any>) {
this.stop();
await Promise.resolve(this.finish(...args));
await this._oracle.emit(eventName, ...args);
}
}
/** @flow
* @description utility EventEmitter that works on browsers and in node
* @description These are utility functions and constants for our event system
*/
import EventFrame from './EventFrame'
import InterruptibleEventFrame from './InterruptibleEventFrame'
import DownloadEventFrame from './DownloadEventFrame'
import FrameworkEventEmitter from './FrameworkEventEmitter'
import EventFrameEmitter from './EventFrameEmitter'
export type ListenerFn = (...args: Array<any>) => (Promise<void> | void);
export type { ListenerFn } from 'eventemitter3';
/**
* Translates an eventFrame and associated data into a message to be sent over a
* Port.
*
* * See also: https://developer.chrome.com/extensions/runtime#type-Port
*
* @param {EventFrame} eventFrame
* @param {*} [args]
*/
export const eventFrameToPortMessage = (eventFrame: EventFrame, args: Array<any>) => ({ eventFrame, args });
/**
* Translates the message data received through a port into an EventFrame. Any
* associated data (i.e. args) is also unserialized.
*
* Returns an object of the form `{ eventFrame: EventFrame, args: Array<any> }`
*
* @param {Object} data
*/
export const portMessageToEventFrame = ({ eventFrame, args }: { eventFrame: {}, args: Array<any> }) => {
return {
eventFrame: Object.assign(new EventFrame, eventFrame),
args
};
};
export {
EventFrame,
InterruptibleEventFrame,
DownloadEventFrame,
FrameworkEventEmitter,
FrameworkEventEmitter as EventEmitter,
EventFrameEmitter,
};
/** @flow
* @description global utility functions and constants