Skip to content

Commit

Permalink
feat: capture binds, support minArgsToTrigger
Browse files Browse the repository at this point in the history
  • Loading branch information
gabidobo authored and andreimarinescu committed Sep 1, 2022
1 parent c8a91cf commit 29cb0c6
Show file tree
Hide file tree
Showing 7 changed files with 147 additions and 57 deletions.
2 changes: 1 addition & 1 deletion dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/module.js
Expand Up @@ -35,7 +35,7 @@ export const setPermissions = (newPermissions = []) => {
};

export const setAllowsAll = (library) => {
const allExplicitPermissions = library.map(({name}) => name);
const allExplicitPermissions = ['bind', ...library.map(({name}) => name)];
permissions = [
{module: /.*/, permissions: allExplicitPermissions},
{module: 'root', permissions: allExplicitPermissions},
Expand Down
106 changes: 62 additions & 44 deletions src/patch.js
Expand Up @@ -13,64 +13,82 @@ function create(constructor, ...args) {
return new Factory();
}

const buildPatch = (family, method, track = () => {}) =>
// eslint-disable-next-line func-names
function (...args) {
let allowed;
const {
name: module,
stack,
directCaller,
lastModuleCaller,
error,
} = getCurrentModuleInfo({
allowURLs: true,
});

if (
typeof method.minArgsToTrigger === 'number' &&
(args?.length || 0) < method.minArgsToTrigger
) {
allowed = true;
} else {
logger.debug(`${module} called ${family.name}.${method.name}`);
allowed = isModuleAllowedToExecute({
module,
family,
method,
directCaller,
lastModuleCaller,
});
track({
module,
family: family.name,
method: method.name,
args,
allowed,
stack,
error,
});
}

if (allowed) {
if (method.isConstructor) {
return create(method.original, ...args);
}
return method.original.apply(this, args);
}

logger.error(`${module} was blocked from calling ${family.name}.${method.name} with`, args);

throw new SandwormError(
`Sandworm: access denied (${module} called ${family.name}.${method.name})`,
);
};

export default ({family, track = () => {}}) => {
if (family.available) {
family.methods.forEach((method) => {
// eslint-disable-next-line no-param-reassign
method.original = family.originalRoot()[method.name];
if (method.original) {
logger.debug(`installing ${family.name}.${method.name}`);
// eslint-disable-next-line no-inner-declarations
function replacement(...args) {
const {
name: module,
stack,
directCaller,
lastModuleCaller,
error,
} = getCurrentModuleInfo({
allowURLs: true,
});
logger.debug(`${module} called ${family.name}.${method.name}`);
const allowed = isModuleAllowedToExecute({
module,
family,
method,
directCaller,
lastModuleCaller,
});
track({
module,
family: family.name,
method: method.name,
args,
allowed,
stack,
error,
});
if (allowed) {
if (method.isConstructor) {
return create(method.original, ...args);
}
return method.original.apply(this, args);
}

logger.error(
`${module} was blocked from calling ${family.name}.${method.name} with`,
args,
);

throw new SandwormError(
`Sandworm: access denied (${module} called ${family.name}.${method.name})`,
);
}
// eslint-disable-next-line no-param-reassign
method.originalBind = method.original.bind;
// eslint-disable-next-line func-names
const replacement = buildPatch(family, method, track);
// eslint-disable-next-line no-restricted-syntax
for (const prop in method.original) {
if (Object.prototype.hasOwnProperty.call(method.original, prop)) {
replacement[prop] = method.original[prop];
}
}
replacement.prototype = method.original.prototype;
replacement.bind = buildPatch(
{name: 'bind'},
{name: 'args', original: method.originalBind, minArgsToTrigger: 2},
track,
);
// eslint-disable-next-line no-param-reassign
family.originalRoot()[method.name] = replacement;
}
Expand Down
16 changes: 16 additions & 0 deletions tests/node/bind.capture.test.js
@@ -0,0 +1,16 @@
const http = require('http');
const Sandworm = require('../../dist/index');
const {expectNoCall, loadSandworm, expectCallToMatch} = require('../utils');

describe('bind', () => {
beforeAll(loadSandworm);
afterEach(() => Sandworm.clearHistory());

test('args', async () => {
http.request.bind(this);
expectNoCall();

http.request.bind(this, []);
expectCallToMatch({family: 'bind', method: 'args'});
});
});
11 changes: 11 additions & 0 deletions tests/node/bind.enforce.test.js
@@ -0,0 +1,11 @@
const http = require('http');
const {loadSandwormInProductionMode, expectCallToThrow} = require('../utils');

describe('enforce: bind', () => {
beforeAll(loadSandwormInProductionMode);

test('args', () => {
http.request.bind(this);
expectCallToThrow(() => http.request.bind(this, {}));
});
});
52 changes: 44 additions & 8 deletions tests/unit/patch.test.js
@@ -1,7 +1,10 @@
const moduleLib = require('../../src/module');
const {default: patch, SandwormError} = require('../../src/patch');

const getCurrentModuleInfoMock = jest.fn(() => ({name: 'root', stack: []}));
const getCurrentModuleInfoMock = jest.fn(() => ({
name: 'root',
stack: [],
}));
const isModuleAllowedToExecuteMock = (allowed = true) => jest.fn(() => allowed);

const testClassSpy = jest.fn();
Expand All @@ -11,10 +14,13 @@ class TestClass {
this.property = 'test';
}
}
const original = jest.fn();
original.additionalProp = 12;
const originalTest = jest.fn();
originalTest.additionalProp = 12;
originalTest.bind = jest.fn();
const originalTestArgsLimit = jest.fn();
let mod = {
test: original,
test: originalTest,
testArgsLimit: originalTestArgsLimit,
TestClass,
};

Expand All @@ -28,7 +34,11 @@ describe('patch', () => {
patch({
family: {
name: 'test',
methods: [{name: 'test'}, {name: 'TestClass', isConstructor: true}],
methods: [
{name: 'test'},
{name: 'testArgsLimit', minArgsToTrigger: 2},
{name: 'TestClass', isConstructor: true},
],
originalRoot: () => mod,
available: true,
},
Expand All @@ -39,7 +49,8 @@ describe('patch', () => {
[moduleLib.getCurrentModuleInfo] = moduleOriginals;
[, moduleLib.isModuleAllowedToExecute] = moduleOriginals;
mod = {
test: original,
test: originalTest,
testArgsLimit: originalTestArgsLimit,
TestClass,
};
});
Expand All @@ -51,10 +62,24 @@ describe('patch', () => {
mod.test();

expect(getCurrentModuleInfoMock).toBeCalledTimes(1);
expect(original).toBeCalledTimes(1);
expect(moduleLib.isModuleAllowedToExecute).toBeCalledTimes(1);
expect(originalTest).toBeCalledTimes(1);

mod.test.bind(this);

const testClass = new mod.TestClass();
expect(getCurrentModuleInfoMock).toBeCalledTimes(2);
expect(moduleLib.isModuleAllowedToExecute).toBeCalledTimes(1);
expect(originalTest.bind).toBeCalledTimes(1);

mod.test.bind(this, {});

expect(getCurrentModuleInfoMock).toBeCalledTimes(3);
expect(moduleLib.isModuleAllowedToExecute).toBeCalledTimes(2);
expect(originalTest.bind).toBeCalledTimes(2);

const testClass = new mod.TestClass();
expect(getCurrentModuleInfoMock).toBeCalledTimes(4);
expect(moduleLib.isModuleAllowedToExecute).toBeCalledTimes(3);
expect(testClass.property).toBe('test');
expect(testClassSpy).toBeCalledTimes(1);
expect(testClass).toBeInstanceOf(TestClass);
Expand All @@ -64,4 +89,15 @@ describe('patch', () => {
moduleLib.isModuleAllowedToExecute = isModuleAllowedToExecuteMock(false);
expect(() => mod.test()).toThrowError(SandwormError);
});

test('argument count limit', () => {
moduleLib.isModuleAllowedToExecute = isModuleAllowedToExecuteMock(false);

mod.testArgsLimit(1);
expect(getCurrentModuleInfoMock).toBeCalledTimes(1);
expect(moduleLib.isModuleAllowedToExecute).not.toBeCalled();
expect(originalTestArgsLimit).toBeCalledTimes(1);

expect(() => mod.testArgsLimit(1, 2)).toThrowError(SandwormError);
});
});
15 changes: 12 additions & 3 deletions tests/utils.js
Expand Up @@ -29,18 +29,26 @@ const callExpects = ({call, family, method, firstArg, secondArg}) => {
}
};

const expectCallToMatch = ({family, method, firstArg, secondArg, index = 0, fromRoot = false}) => {
// console.log(Sandworm.getHistory().map((call) => `${call.module}: ${call.family}.${call.method}`));
const call = Sandworm.getHistory().filter(({module}) =>
const getCall = (index, fromRoot) =>
Sandworm.getHistory().filter(({module}) =>
(fromRoot
? ['root']
: ['jest-cli>@jest/core>jest-runner>jest-circus', 'jest-runner>jest-circus', 'jest-circus']
).includes(module),
)[index];

const expectCallToMatch = ({family, method, firstArg, secondArg, index = 0, fromRoot = false}) => {
// console.log(Sandworm.getHistory().map((call) => `${call.module}: ${call.family}.${call.method}`));
const call = getCall(index, fromRoot);

callExpects({call, family, method, firstArg, secondArg});
};

const expectNoCall = (index = 0, fromRoot = false) => {
const call = getCall(index, fromRoot);
expect(call).toBeUndefined();
};

const expectCallToThrow = (call) => {
expect(call).toThrow(Sandworm.Error);
};
Expand Down Expand Up @@ -175,6 +183,7 @@ const webWorkerHasFeature = async (feature, page) => {
const testif = (condition) => (condition ? test : test.skip);

module.exports = {
expectNoCall,
expectCallToMatch,
expectWebCallToMatch,
expectWebWorkerCallToMatch,
Expand Down

0 comments on commit 29cb0c6

Please sign in to comment.