@contrast/protect 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +12 -0
  2. package/README.md +9 -0
  3. package/lib/cli-rewriter.js +20 -0
  4. package/lib/error-handlers/constants.js +5 -0
  5. package/lib/error-handlers/index.js +13 -0
  6. package/lib/error-handlers/install/fastify3.js +88 -0
  7. package/lib/error-handlers/install/fastify3.test.js +142 -0
  8. package/lib/esm-loader.mjs +2 -0
  9. package/lib/esm-loader.test.mjs +11 -0
  10. package/lib/index.d.ts +36 -0
  11. package/lib/index.js +89 -0
  12. package/lib/index.test.js +32 -0
  13. package/lib/input-analysis/handlers.js +462 -0
  14. package/lib/input-analysis/handlers.test.js +898 -0
  15. package/lib/input-analysis/index.js +16 -0
  16. package/lib/input-analysis/index.test.js +28 -0
  17. package/lib/input-analysis/install/fastify3.js +79 -0
  18. package/lib/input-analysis/install/fastify3.test.js +71 -0
  19. package/lib/input-analysis/install/http.js +185 -0
  20. package/lib/input-analysis/install/http.test.js +315 -0
  21. package/lib/input-tracing/constants.js +5 -0
  22. package/lib/input-tracing/handlers/index.js +117 -0
  23. package/lib/input-tracing/handlers/index.test.js +395 -0
  24. package/lib/input-tracing/handlers/nosql-injection-mongo.js +48 -0
  25. package/lib/input-tracing/index.js +32 -0
  26. package/lib/input-tracing/install/README.md +1 -0
  27. package/lib/input-tracing/install/child-process.js +45 -0
  28. package/lib/input-tracing/install/child-process.test.js +112 -0
  29. package/lib/input-tracing/install/fs.js +107 -0
  30. package/lib/input-tracing/install/fs.test.js +118 -0
  31. package/lib/input-tracing/install/mysql.js +57 -0
  32. package/lib/input-tracing/install/mysql.test.js +108 -0
  33. package/lib/input-tracing/install/postgres.js +61 -0
  34. package/lib/input-tracing/install/postgres.test.js +125 -0
  35. package/lib/input-tracing/install/sequelize.js +51 -0
  36. package/lib/input-tracing/install/sequelize.test.js +79 -0
  37. package/lib/input-tracing/install/sqlite3.js +45 -0
  38. package/lib/input-tracing/install/sqlite3.test.js +88 -0
  39. package/lib/make-response-blocker.js +35 -0
  40. package/lib/make-response-blocker.test.js +88 -0
  41. package/lib/make-source-context.js +130 -0
  42. package/lib/make-source-context.test.js +298 -0
  43. package/lib/security-exception.js +12 -0
  44. package/lib/throw-security-exception.js +30 -0
  45. package/lib/throw-security-exception.test.js +50 -0
  46. package/lib/utils.js +88 -0
  47. package/lib/utils.test.js +40 -0
  48. package/package.json +32 -0
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const { isString } = require('@contrast/common');
4
+ const { patchType } = require('../constants');
5
+
6
+ module.exports = function(core) {
7
+ const {
8
+ scopes: { sources, instrumentation },
9
+ patcher,
10
+ depHooks,
11
+ captureStacktrace,
12
+ protect: { inputTracing }
13
+ } = core;
14
+
15
+ function getQueryFromArgs([value]) {
16
+ const query = value?.query || value;
17
+ if (query && isString(query)) return query;
18
+ }
19
+
20
+ function install() {
21
+ depHooks.resolve({ name: 'sequelize' }, sequelize => {
22
+ const name = 'sequelize.prototype.query';
23
+ patcher.patch(sequelize.prototype, 'query', {
24
+ name,
25
+ patchType,
26
+ pre({ args, hooked, name }) {
27
+ if (instrumentation.isLocked()) return;
28
+
29
+ const sourceContext = sources.getStore()?.protect;
30
+ if (!sourceContext) return;
31
+
32
+ const value = getQueryFromArgs(args);
33
+ if (!value) return;
34
+
35
+ const sinkContext = captureStacktrace(
36
+ { name, value },
37
+ { constructorOpt: hooked }
38
+ );
39
+ inputTracing.handleSqlInjection(sourceContext, sinkContext);
40
+ }
41
+ });
42
+ });
43
+ }
44
+
45
+ const sequelizeInstrumentation = inputTracing.sequelizeInstrumentation = {
46
+ getQueryFromArgs,
47
+ install
48
+ };
49
+
50
+ return sequelizeInstrumentation;
51
+ };
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const sinon = require('sinon');
5
+
6
+ describe('protect input-tracing installs: sequelize interfaces', function() {
7
+ const store = { protect: {} };
8
+
9
+ let core;
10
+ let inputTracing;
11
+
12
+ beforeEach(function() {
13
+ const mocks = require('../../../../test/mocks');
14
+ const patcher = require('@contrast/patcher');
15
+
16
+ core = mocks.core();
17
+ core.logger = mocks.logger();
18
+ core.patcher = patcher(core);
19
+ core.scopes = mocks.scopes();
20
+ core.depHooks = mocks.depHooks();
21
+ core.protect = mocks.protect();
22
+ ({ protect: { inputTracing } } = core);
23
+
24
+ sinon.stub(core.scopes.sources, 'getStore').returns(store);
25
+ });
26
+
27
+ describe('sequelize', function() {
28
+ let sequelize;
29
+
30
+ beforeEach(function() {
31
+ sequelize = function() {};
32
+ sequelize.prototype.query = sinon.stub();
33
+ core.depHooks.resolve.yields(sequelize);
34
+ require('./sequelize')(core).install();
35
+ });
36
+
37
+ describe('instruments sequelize.query()', function() {
38
+ it('handleSqlInjection() is called with valid expected values', function() {
39
+ const conn = new sequelize();
40
+ const value = 'SELECT "foo"';
41
+ conn.query(value);
42
+ conn.query({ query: value });
43
+
44
+ expect(inputTracing.handleSqlInjection).to.have.been.calledTwice;
45
+ expect(inputTracing.handleSqlInjection).to.have.been.calledWith(
46
+ {},
47
+ { name: 'sequelize.prototype.query', value }
48
+ );
49
+ });
50
+
51
+ it('handleSqlInjection() is not called if instrumentation is locked', function() {
52
+ core.scopes.instrumentation.isLocked.returns(true);
53
+ const conn = new sequelize();
54
+ const value = 'SELECT "foo"';
55
+ conn.query(value);
56
+
57
+ expect(inputTracing.handleSqlInjection).to.have.not.been.called;
58
+ });
59
+
60
+ it('handleSqlInjection() is not called if there is no sourceContext', function() {
61
+ core.scopes.sources.getStore.returns({ protect: undefined });
62
+ const conn = new sequelize();
63
+ const value = 'SELECT "foo"';
64
+ conn.query(value);
65
+
66
+ expect(inputTracing.handleSqlInjection).to.have.not.been.called;
67
+ });
68
+
69
+ it('handleSqlInjection() is not called if the sql value is empty or not a string', function() {
70
+ const conn = new sequelize();
71
+ conn.query('');
72
+ conn.query(100);
73
+ conn.query({ query: '' });
74
+
75
+ expect(inputTracing.handleSqlInjection).to.have.not.been.called;
76
+ });
77
+ });
78
+ });
79
+ });
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ const { isString } = require('@contrast/common');
4
+ const { patchType } = require('../constants');
5
+
6
+ module.exports = function(core) {
7
+ const {
8
+ scopes: { sources, instrumentation },
9
+ patcher,
10
+ depHooks,
11
+ captureStacktrace,
12
+ protect: { inputTracing }
13
+ } = core;
14
+
15
+ function install() {
16
+ depHooks.resolve({ name: 'sqlite3' }, sqlite3 => {
17
+ ['all', 'run', 'get', 'each', 'exec', 'prepare'].forEach((method) => {
18
+ const name = `sqlite3.Database.prototype.${method}`;
19
+ patcher.patch(sqlite3.Database.prototype, method, {
20
+ name,
21
+ patchType,
22
+ pre({ args, hooked, name }) {
23
+ if (instrumentation.isLocked()) return;
24
+
25
+ const sourceContext = sources.getStore()?.protect;
26
+ if (!sourceContext) return;
27
+
28
+ const value = args[0];
29
+ if (!value || !isString(value)) return;
30
+
31
+ const sinkContext = captureStacktrace(
32
+ { name, value },
33
+ { constructorOpt: hooked }
34
+ );
35
+ inputTracing.handleSqlInjection(sourceContext, sinkContext);
36
+ }
37
+ });
38
+ });
39
+ });
40
+ }
41
+
42
+ const sqlite3Instrumentation = inputTracing.sqlite3Instrumentation = { install };
43
+
44
+ return sqlite3Instrumentation;
45
+ };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const sinon = require('sinon');
5
+
6
+ describe('protect input-tracing installs: sqlite3 interfaces', function() {
7
+ const store = { protect: {} };
8
+
9
+ let core;
10
+ let inputTracing;
11
+
12
+ beforeEach(function() {
13
+ const mocks = require('../../../../test/mocks');
14
+ const patcher = require('@contrast/patcher');
15
+
16
+ core = mocks.core();
17
+ core.logger = mocks.logger();
18
+ core.patcher = patcher(core);
19
+ core.scopes = mocks.scopes();
20
+ core.depHooks = mocks.depHooks();
21
+ core.protect = mocks.protect();
22
+ ({ protect: { inputTracing } } = core);
23
+
24
+ sinon.stub(core.scopes.sources, 'getStore').returns(store);
25
+ });
26
+
27
+ describe('sqlite3', function() {
28
+ let sqlite3;
29
+
30
+ beforeEach(function() {
31
+ sqlite3 = {
32
+ // eslint-disable-next-line object-shorthand
33
+ Database: function() {}
34
+ };
35
+ sqlite3.Database.prototype = {
36
+ all: sinon.stub(),
37
+ run: sinon.stub(),
38
+ get: sinon.stub(),
39
+ each: sinon.stub(),
40
+ exec: sinon.stub(),
41
+ prepare: sinon.stub()
42
+ };
43
+ core.depHooks.resolve.yields(sqlite3);
44
+ require('./sqlite3')(core).install();
45
+ });
46
+
47
+ ['all', 'run', 'get', 'each', 'exec', 'prepare'].forEach((method) => {
48
+ describe(`instruments connection.${method}()`, function() {
49
+ it('handleSqlInjection() is called with valid expected values', function() {
50
+ const db = new sqlite3.Database();
51
+ const value = 'SELECT "foo"';
52
+ db[method](value);
53
+
54
+ expect(inputTracing.handleSqlInjection).to.have.been.calledWith(
55
+ {},
56
+ { name: `sqlite3.Database.prototype.${method}`, value }
57
+ );
58
+ });
59
+
60
+ it('handleSqlInjection() is not called if instrumentation is locked', function() {
61
+ core.scopes.instrumentation.isLocked.returns(true);
62
+ const db = new sqlite3.Database();
63
+ const value = 'SELECT "foo"';
64
+ db[method](value);
65
+
66
+ expect(inputTracing.handleSqlInjection).to.have.not.been.called;
67
+ });
68
+
69
+ it('handleSqlInjection() is not called if there is no sourceContext', function() {
70
+ core.scopes.sources.getStore.returns({ protect: undefined });
71
+ const db = new sqlite3.Database();
72
+ const value = 'SELECT "foo"';
73
+ db[method](value);
74
+
75
+ expect(inputTracing.handleSqlInjection).to.have.not.been.called;
76
+ });
77
+
78
+ it('handleSqlInjection() is not called if the sql value is empty or not a string', function() {
79
+ const db = new sqlite3.Database();
80
+ db[method]('');
81
+ db[method](100);
82
+
83
+ expect(inputTracing.handleSqlInjection).to.have.not.been.called;
84
+ });
85
+ });
86
+ });
87
+ });
88
+ });
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ module.exports = function(core) {
4
+ // i think this should be a weakset. we don't want to accumulate
5
+ // blocked requests for the entire life of a program. the req
6
+ // object is not exactly tiny.
7
+ const blocked = new WeakSet();
8
+ const { patcher, logger } = core;
9
+
10
+ function makeResponseBlocker(res) {
11
+ return function block(mode, ruleId) {
12
+ if (blocked.has(res)) return;
13
+
14
+ blocked.add(res);
15
+ mode = mode.toUpperCase();
16
+ const end = patcher.unwrap(res.end);
17
+ const writeHead = patcher.unwrap(res.writeHead);
18
+
19
+ try {
20
+ if (!res.headersSent) writeHead.call(res, 403);
21
+ end.call(res, '');
22
+ logger.info({ mode, ruleId }, 'Request blocked');
23
+ } catch (err) {
24
+ logger.error({ err, mode, ruleId }, 'Error blocking request');
25
+ }
26
+ };
27
+ }
28
+
29
+ // expose. for testing?
30
+ makeResponseBlocker.blocked = blocked;
31
+
32
+ core.protect.makeResponseBlocker = makeResponseBlocker;
33
+
34
+ return makeResponseBlocker;
35
+ };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const sinon = require('sinon');
4
+ const { expect } = require('chai');
5
+ const mocks = require('../../test/mocks');
6
+
7
+ describe('protect make-response-blocker', function() {
8
+
9
+ let res, core, makeResponseBlocker;
10
+ beforeEach(function() {
11
+
12
+ res = {
13
+ end: sinon.stub(),
14
+ headersSent: false,
15
+ writeHead: sinon.stub()
16
+ };
17
+
18
+ core = mocks.core();
19
+ core.logger = mocks.logger();
20
+ core.patcher = mocks.patcher();
21
+ core.protect = {};
22
+
23
+ makeResponseBlocker = require('./make-response-blocker')(core);
24
+ });
25
+
26
+ it('Sends a blocked response', function() {
27
+ const block = makeResponseBlocker(res);
28
+ block('block', 'sql-injection');
29
+ expect(core.patcher.unwrap).to.have.been.calledWith(res.writeHead);
30
+ expect(core.patcher.unwrap).to.have.been.calledWith(res.end);
31
+ expect(res.writeHead).to.have.been.calledWith(403);
32
+ expect(res.end).to.have.been.calledWith('');
33
+ expect(core.logger.info).to.have.been.calledWith(
34
+ { mode: 'BLOCK', ruleId: 'sql-injection' },
35
+ 'Request blocked'
36
+ );
37
+ });
38
+
39
+ it('Sets blocked property', function() {
40
+ const block = makeResponseBlocker(res);
41
+ block('block', 'sql-injection');
42
+ expect(makeResponseBlocker.blocked.has(res)).equal(true);
43
+ });
44
+
45
+ it('Set core.protect.makeResponseBlocker', function() {
46
+ expect(core.protect.makeResponseBlocker).to.be.equal(makeResponseBlocker);
47
+ });
48
+
49
+ it('Blocks on the same "res" object only once', function() {
50
+ const block = makeResponseBlocker(res);
51
+ block('block', 'sql-injection');
52
+ block('block', 'cmd-injection');
53
+ expect(makeResponseBlocker.blocked.has(res));
54
+ expect(res.end).to.have.been.calledOnce;
55
+ expect(res.writeHead).to.have.been.calledOnce;
56
+ expect(core.logger.info).to.have.been.calledOnce;
57
+ });
58
+
59
+ it('Adds multiple responses to blocked set', function() {
60
+ const res1 = Object.assign({}, res);
61
+ const res2 = Object.assign({}, res);
62
+ const block1 = makeResponseBlocker(res1);
63
+ const block2 = makeResponseBlocker(res2);
64
+ block1('block', 'sql-injection');
65
+ block2('block', 'cmd-injection');
66
+ expect(makeResponseBlocker.blocked.has(res1));
67
+ expect(makeResponseBlocker.blocked.has(res2));
68
+ });
69
+
70
+ it('Does not call writeHead when headers already sent', function() {
71
+ const block = makeResponseBlocker(res);
72
+ res.headersSent = true;
73
+ block('block_at_perimeter', 'reflected-xss');
74
+ expect(res.writeHead).to.not.have.been.called;
75
+ expect(res.end).to.have.been.calledWith('');
76
+ });
77
+
78
+ it('Logs error when exception thrown', function() {
79
+ const err = new Error('error');
80
+ res.end.throws(err);
81
+ const block = makeResponseBlocker(res);
82
+ block('block', 'path-traversal');
83
+ expect(core.logger.error).to.have.been.calledWith(
84
+ { err, mode: 'BLOCK', ruleId: 'path-traversal' },
85
+ 'Error blocking request'
86
+ );
87
+ });
88
+ });
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ module.exports = function(core) {
4
+
5
+ function makeSourceContext(req, res) {
6
+ // make the abstract request. it is an abstraction of a request that
7
+ // contains only the pieces of the request required by handlers. this
8
+ // is done to make an explicit contract for data that is required by
9
+ // the handlers. additional data that is discovered to be required by
10
+ // handlers should be added here. the goal is not to pass the raw req
11
+ // or res objects so that all data coupling is all defined here.
12
+
13
+ // separate path and search params
14
+ const ix = req.url.indexOf('?');
15
+ let uriPath, queries;
16
+ if (ix >= 0) {
17
+ uriPath = req.url.slice(0, ix);
18
+ queries = req.url.slice(ix + 1);
19
+ } else {
20
+ uriPath = req.url;
21
+ queries = '';
22
+ }
23
+ // lowercase header keys and capture content-type
24
+ let contentType = '';
25
+ const headers = Array(req.rawHeaders.length);
26
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
27
+ headers[i] = req.rawHeaders[i].toLowerCase();
28
+ headers[i + 1] = req.rawHeaders[i + 1];
29
+ if (headers[i] === 'content-type') {
30
+ contentType = headers[i + 1].toLowerCase();
31
+ }
32
+ }
33
+
34
+ // if it can be determined that qs-type parsing is not being done then set
35
+ // standardUrlParsing to true. if it is true, then the query params and bodies
36
+ // that are form-url-encoded will be parsed by agent-lib and will not need to
37
+ // be parsed separately.
38
+ //
39
+ // the code that scans the dependencies is probably the best place to make the
40
+ // determination.
41
+ const standardUrlParsing = false;
42
+
43
+ // contains request data and information derived from request data. it's
44
+ // possible for any derived information to be derived later, but doing
45
+ // so here is typically better; it makes clear what information is used to
46
+ // make decisions by different handlers.
47
+ const reqData = {
48
+ method: req.method,
49
+ headers,
50
+ uriPath,
51
+ queries,
52
+ contentType,
53
+ standardUrlParsing,
54
+ };
55
+
56
+ //
57
+ // build the protect object that contains all
58
+ //
59
+ const protectStore = {
60
+ reqData,
61
+ // block closure captures res so it isn't exposed to beyond here
62
+ block: core.protect.makeResponseBlocker(res),
63
+
64
+ // this should be changed to capture only the rules applicable to this
65
+ // particular request (if any route exclusions, etc.)
66
+ rules: core.protect.rules,
67
+ exclusions: [],
68
+ virtualPatches: [],
69
+
70
+ // maybe better as result, findings... but my bad naming choice is
71
+ // past the point of return.
72
+ findings: {
73
+ trackRequest: false,
74
+ securityException: undefined,
75
+ // bodyType is set to a body type if handlers.parseRawBody() parsed it
76
+ // successfully.
77
+ bodyType: undefined,
78
+ resultsMap: Object.create(null),
79
+ },
80
+
81
+ /*
82
+ findings: {
83
+ trackRequest: true,
84
+ resultsList: [
85
+ // Example 2
86
+ {
87
+ // return value from agent-lib
88
+ value: 'kill -9 1',
89
+ type: 'PARAMETER_VALUE',
90
+ ruleId: 'cmd-injection',
91
+ path: ['path', 'to', 'val'],
92
+ // other data added during lifecycle
93
+ // could we add these by mutating agent-lib return values?
94
+ // What if there are multiple injections for the same value? The `details` value
95
+ // could be an array in that case, or is this too complicated.
96
+ blocked: false,
97
+ details: [
98
+ {
99
+ context: {
100
+ id: 'child_process.exec',
101
+ get stack() {}, // lazy
102
+ command: 'sudo kill -9 1',
103
+ index: 5,
104
+ }
105
+ },
106
+ {
107
+ sinkId: 'child_process.exec',
108
+ get stack() {}, // lazy
109
+ command: 'sudo kill -9 1',
110
+ index: 5,
111
+ }]
112
+ },
113
+ ]
114
+ }
115
+ // (scoreAtom() returns only the ruleId and score because the caller supplied
116
+ // the input and type; no key or path is known to scoreAtom(). code calling
117
+ // scoreAtom() will need to augment the finding to match the above.)
118
+ //
119
+ // each finding is augmented with additional properties
120
+ // - blocked: false // set to true if the finding causes the request to be blocked
121
+ // - mappedId: ruleId // normalized ruleId, e.g., nosql-injection-mongo => nosql-injection
122
+ // -
123
+ // */
124
+ };
125
+
126
+ return protectStore;
127
+ }
128
+
129
+ core.protect.makeSourceContext = makeSourceContext;
130
+ };