@contrast/protect
Advanced tools
Comparing version 1.7.0 to 1.8.0
@@ -18,3 +18,9 @@ /* | ||
const { BLOCKING_MODES, simpleTraverse, Rule, isString } = require('@contrast/common'); | ||
const { | ||
BLOCKING_MODES, | ||
simpleTraverse, | ||
Rule, | ||
isString, | ||
ProtectRuleMode: { OFF }, | ||
} = require('@contrast/common'); | ||
const address = require('ipaddr.js'); | ||
@@ -61,2 +67,10 @@ | ||
const jsonInputTypes = { | ||
keyType: agentLib.InputType.JsonKey, inputType: agentLib.InputType.JsonValue | ||
}; | ||
const parameterInputTypes = { | ||
keyType: agentLib.InputType.ParameterKey, inputType: agentLib.InputType.ParameterValue | ||
}; | ||
// all handlers will be invoked with two arguments: | ||
@@ -117,4 +131,2 @@ // 1) sourceContext object containing: | ||
inputAnalysis.handleConnect = function handleConnect(sourceContext, connectInputs) { | ||
if (!sourceContext || sourceContext.allowed) return; | ||
const { policy: { rulesMask } } = sourceContext; | ||
@@ -128,2 +140,3 @@ | ||
const findings = agentLib.scoreRequestConnect(rulesMask, connectInputs, preferWW); | ||
block = mergeFindings(sourceContext, findings); | ||
@@ -136,74 +149,2 @@ } | ||
/** | ||
* handleRequestEnd() | ||
* | ||
* Invoked when the request is complete. | ||
* | ||
* @param {Object} sourceContext | ||
*/ | ||
inputAnalysis.handleRequestEnd = function handleRequestEnd(sourceContext) { | ||
if (!config.protect.probe_analysis.enable) return; | ||
const { resultsMap } = sourceContext.findings; | ||
const probesRules = [Rule.CMD_INJECTION, Rule.PATH_TRAVERSAL, Rule.SQL_INJECTION, Rule.XXE]; | ||
const props = {}; | ||
// Detecting probes | ||
Object.values(resultsMap).forEach(resultsByRuleId => { | ||
resultsByRuleId.forEach((resultByRuleId) => { | ||
const { | ||
ruleId, | ||
blocked, | ||
details, | ||
value, | ||
inputType | ||
} = resultByRuleId; | ||
if (blocked || !blocked && details.length > 0 || !probesRules.some(rule => rule === ruleId)) return; | ||
const { policy: { rulesMask } } = sourceContext; | ||
const results = (agentLib.scoreAtom( | ||
rulesMask, | ||
value, | ||
agentLib.InputType[inputType], | ||
{ | ||
preferWorthWatching: false | ||
} | ||
) || []).filter(({ score }) => score >= 90); | ||
if (!results.length) return; | ||
results.forEach(result => { | ||
const isAlreadyBlocked = (resultsMap[result.ruleId] || []).some(element => | ||
element.blocked && element.inputType === inputType && element.value === value | ||
); | ||
if (isAlreadyBlocked) return; | ||
const probe = Object.assign({}, resultByRuleId, result, { | ||
mappedId: result.ruleId | ||
}); | ||
const key = [probe.ruleId, probe.inputType, ...probe.path, probe.value].join('|'); | ||
props[key] = probe; | ||
}); | ||
}); | ||
}); | ||
Object.values(props).forEach(prop => { | ||
if (!resultsMap[prop.ruleId]) { | ||
resultsMap[prop.ruleId] = []; | ||
} | ||
resultsMap[prop.ruleId].push(prop); | ||
}); | ||
}; | ||
const jsonInputTypes = { | ||
keyType: agentLib.InputType.JsonKey, inputType: agentLib.InputType.JsonValue | ||
}; | ||
const parameterInputTypes = { | ||
keyType: agentLib.InputType.ParameterKey, inputType: agentLib.InputType.ParameterValue | ||
}; | ||
/** | ||
* handleQueryParams() | ||
@@ -233,3 +174,2 @@ * | ||
inputAnalysis.handleVirtualPatches(sourceContext, { PARAMETERS: queryParams }); | ||
@@ -273,6 +213,9 @@ | ||
} | ||
const items = agentLib.scoreAtom(rulesMask, value, UrlParameter, preferWW); | ||
if (!items) { | ||
return; | ||
} | ||
for (const item of items) { | ||
@@ -322,2 +265,6 @@ resultsList.push({ | ||
inputAnalysis.handleVirtualPatches(sourceContext, { HEADERS: cookies }); | ||
const { policy: { rulesMask } } = sourceContext; | ||
const cookiesArr = Object.entries(cookies).reduce((acc, [key, value]) => { | ||
@@ -328,5 +275,2 @@ acc.push(key, value); | ||
inputAnalysis.handleVirtualPatches(sourceContext, { HEADERS: cookies }); | ||
const { policy: { rulesMask } } = sourceContext; | ||
const cookieFindings = agentLib.scoreRequestConnect(rulesMask, { cookies: cookiesArr }, preferWW); | ||
@@ -352,2 +296,3 @@ | ||
if (sourceContext.analyzedBody) return; | ||
sourceContext.analyzedBody = true; | ||
@@ -371,2 +316,3 @@ | ||
} | ||
const block = commonObjectAnalyzer(sourceContext, parsedBody, inputTypes); | ||
@@ -493,2 +439,66 @@ | ||
/** | ||
* handleRequestEnd() | ||
* | ||
* Invoked when the request is complete. | ||
* | ||
* @param {Object} sourceContext | ||
*/ | ||
inputAnalysis.handleRequestEnd = function handleRequestEnd(sourceContext) { | ||
if (!config.protect.probe_analysis.enable || sourceContext.allowed) return; | ||
const { resultsMap } = sourceContext.findings; | ||
const probesRules = [Rule.CMD_INJECTION, Rule.PATH_TRAVERSAL, Rule.SQL_INJECTION, Rule.XXE]; | ||
const props = {}; | ||
// Detecting probes | ||
Object.values(resultsMap).forEach(resultsByRuleId => { | ||
resultsByRuleId.forEach((resultByRuleId) => { | ||
const { | ||
ruleId, | ||
blocked, | ||
details, | ||
value, | ||
inputType | ||
} = resultByRuleId; | ||
if (blocked || !blocked && details.length > 0 || !probesRules.some(rule => rule === ruleId)) return; | ||
const { policy: { rulesMask } } = sourceContext; | ||
const results = (agentLib.scoreAtom( | ||
rulesMask, | ||
value, | ||
agentLib.InputType[inputType], | ||
{ | ||
preferWorthWatching: false | ||
} | ||
) || []).filter(({ score }) => score >= 90); | ||
if (!results.length) return; | ||
results.forEach(result => { | ||
const isAlreadyBlocked = (resultsMap[result.ruleId] || []).some(element => | ||
element.blocked && element.inputType === inputType && element.value === value | ||
); | ||
if (isAlreadyBlocked) return; | ||
const probe = Object.assign({}, resultByRuleId, result, { | ||
mappedId: result.ruleId | ||
}); | ||
const key = [probe.ruleId, probe.inputType, ...probe.path, probe.value].join('|'); | ||
props[key] = probe; | ||
}); | ||
}); | ||
}); | ||
Object.values(props).forEach(prop => { | ||
if (!resultsMap[prop.ruleId]) { | ||
resultsMap[prop.ruleId] = []; | ||
} | ||
resultsMap[prop.ruleId].push(prop); | ||
}); | ||
}; | ||
/** | ||
* commonObjectAnalyzer() walks an object supplied by the end-user and checks | ||
@@ -547,3 +557,5 @@ * it for vulnerabilities. | ||
} | ||
let items = agentLib.scoreAtom(rulesMask, value, itemType, preferWW); | ||
if (!items && !isMongoQueryType) { | ||
@@ -645,2 +657,62 @@ return; | ||
/** | ||
* Reads the source context's policy and compares to result item to check whether to ignore it. | ||
* @param {ProtectMessage} sourceContext | ||
* @param {Result} result | ||
* @returns {boolean} whether result should be excluded | ||
*/ | ||
function isResultExcluded(sourceContext, result) { | ||
const { policy: { exclusions } } = sourceContext; | ||
const { ruleId, path, inputType, value } = result; | ||
const inputName = path ? path[path.length - 1] : null; | ||
let checkCookiesInHeader = false; | ||
let inputExclusions; | ||
switch (inputType) { | ||
case 'JsonKey': | ||
case 'JsonValue': | ||
case 'MultipartName': { | ||
return exclusions.ignoreBody || exclusions.bodyPolicy?.[ruleId] === OFF; | ||
} | ||
case 'ParameterKey': | ||
case 'ParameterValue': { | ||
const qsExcluded = exclusions.ignoreQuerystring || exclusions.querystringPolicy?.[ruleId] === OFF; | ||
if (qsExcluded) return true; | ||
inputExclusions = exclusions.parameter; | ||
break; | ||
} | ||
case 'CookieValue': { | ||
inputExclusions = exclusions.cookie; | ||
break; | ||
} | ||
case 'HeaderKey': | ||
case 'HeaderValue': { | ||
if (path?.[0]?.toLowerCase() === 'cookie') { | ||
inputExclusions = exclusions.cookie; | ||
checkCookiesInHeader = true; | ||
} else { | ||
inputExclusions = exclusions.header; | ||
} | ||
break; | ||
} | ||
} | ||
if (!inputName || !inputExclusions) return false; | ||
for (const excl of inputExclusions) { | ||
let nameCheck = false; | ||
if (checkCookiesInHeader) { | ||
nameCheck = excl.checkCookiesInHeader(value); | ||
} else { | ||
nameCheck = excl.matchesInputName(inputName); | ||
} | ||
if (!nameCheck) continue; | ||
if (!excl.policy || excl.policy[ruleId] === OFF) { | ||
return true; | ||
} | ||
} | ||
return false; | ||
} | ||
/** | ||
* merge new findings into the existing findings | ||
@@ -658,2 +730,7 @@ * | ||
} | ||
newFindings.resultsList = newFindings.resultsList.filter( | ||
(result) => !isResultExcluded(sourceContext, result) | ||
); | ||
normalizeFindings(policy, newFindings); | ||
@@ -660,0 +737,0 @@ |
@@ -179,4 +179,9 @@ /* | ||
} | ||
store.protect = this.makeSourceContext(req, res); | ||
store.protect = this.makeSourceContext(req, res); | ||
if (store.protect.allowed) { | ||
setImmediate(() => method.call(instance, ...args)); | ||
return; | ||
} | ||
const { reqData } = store.protect; | ||
@@ -183,0 +188,0 @@ |
@@ -29,3 +29,3 @@ /* | ||
messages.on(Event.SERVER_SETTINGS_UPDATE, (serverUpdate) => { | ||
const virtualPatches = serverUpdate.settings?.defend.virtualPatches; | ||
const virtualPatches = serverUpdate.settings?.defend?.virtualPatches; | ||
if (virtualPatches) { | ||
@@ -32,0 +32,0 @@ buildVPEvaluators(virtualPatches, virtualPatchesEvaluators); |
@@ -19,3 +19,5 @@ /* | ||
module.exports = function(core) { | ||
const { protect } = core; | ||
const { | ||
protect: { getPolicy } | ||
} = core; | ||
@@ -31,4 +33,6 @@ function makeSourceContext(req, res) { | ||
// separate path and search params | ||
let uriPath, queries; | ||
const ix = req.url.indexOf('?'); | ||
let uriPath, queries; | ||
if (ix >= 0) { | ||
@@ -41,2 +45,10 @@ uriPath = req.url.slice(0, ix); | ||
} | ||
const policy = getPolicy({ uriPath }); | ||
// URL exclusions can disable all rules | ||
if (!policy) { | ||
return { allowed: true }; | ||
} | ||
// lowercase header keys and capture content-type | ||
@@ -86,3 +98,3 @@ let contentType = ''; | ||
policy: protect.getPolicy(), | ||
policy, | ||
@@ -89,0 +101,0 @@ exclusions: [], |
@@ -30,10 +30,68 @@ /* | ||
module.exports = function(core) { | ||
const { config, logger, messages, protect } = core; | ||
const policy = protect.policy = {}; | ||
const { | ||
config, | ||
logger, | ||
messages, | ||
protect, | ||
protect: { agentLib } | ||
} = core; | ||
const compiled = { | ||
url: [], | ||
querystring: [], | ||
header: [], | ||
body: [], | ||
cookie: [], | ||
parameter: [], | ||
}; | ||
const policy = protect.policy = { | ||
exclusions: compiled | ||
}; | ||
function regExpCheck(str) { | ||
return str.indexOf('*') > 0 || | ||
str.indexOf('.') > 0 || | ||
str.indexOf('+') > 0 || | ||
str.indexOf('?') > 0 || | ||
str.indexOf('\\') > 0; | ||
} | ||
function buildUriPathRegExp(urls) { | ||
let regExpNeeded = false; | ||
for (const url of urls) { | ||
if (regExpCheck(url)) { | ||
regExpNeeded = true; | ||
} | ||
} | ||
if (regExpNeeded) { | ||
const rx = new RegExp(`^${urls.join('|')}$`); | ||
return (uriPath) => rx ? rx.test(uriPath) : false; | ||
} | ||
return (uriPath) => urls.some((url) => url === uriPath); | ||
} | ||
function createUriPathMatcher(urls) { | ||
if (urls.length) { | ||
return buildUriPathRegExp(urls); | ||
} else { | ||
return () => true; | ||
} | ||
} | ||
function createInputNameMatcher(dtmInputName) { | ||
if (regExpCheck(dtmInputName)) { | ||
const rx = new RegExp(`^${dtmInputName}$`); | ||
return (inputName) => rx ? rx.test(inputName) : false; | ||
} | ||
return (inputName) => inputName === dtmInputName; | ||
} | ||
function getModeFromConfig(ruleId) { | ||
if (config.protect.disabled_rules.includes(ruleId)) { | ||
return 'off'; | ||
return OFF; | ||
} | ||
@@ -88,3 +146,11 @@ return config.protect.rules?.[ruleId]?.mode; | ||
let rulesMask = 0; | ||
for (const [ruleId, mode] of Object.entries(policy)) { | ||
for (const entry of Object.entries(policy)) { | ||
let [ruleId] = entry; | ||
const [, mode] = entry; | ||
if (ruleId === 'nosql-injection') { | ||
ruleId = 'nosql-injection-mongo'; | ||
} | ||
if (protect.agentLib.RuleType[ruleId] && mode !== OFF) { | ||
@@ -94,2 +160,3 @@ rulesMask = rulesMask | protect.agentLib.RuleType[ruleId]; | ||
} | ||
policy.rulesMask = rulesMask; | ||
@@ -102,9 +169,79 @@ } | ||
*/ | ||
function getPolicy() { | ||
return { ...policy }; | ||
function getPolicy({ uriPath } = {}) { | ||
const requestPolicy = { | ||
exclusions: { | ||
ignoreQuerystring: false, | ||
querystringPolicy: null, | ||
ignoreBody: false, | ||
bodyPolicy: null, | ||
header: [], | ||
cookie: [], | ||
parameter: [], | ||
}, | ||
rulesMask: policy.rulesMask, | ||
}; | ||
for (const ruleId of Object.values(Rule)) { | ||
requestPolicy[ruleId] = policy[ruleId]; | ||
} | ||
// handle exclusions | ||
for (const [inputType, exclusions] of Object.entries(compiled)) { | ||
for (const e of exclusions) { | ||
if (!e.matchesUriPath(uriPath)) continue; | ||
// url exclusions | ||
if (inputType === 'url') { | ||
// if applies to all rules, there is no policy for the request i.e. disable protect | ||
if (!e.policy) { | ||
return null; | ||
} | ||
// merge exclusion's policy into the request's policy | ||
for (const key of Object.keys(e.policy)) { | ||
const value = e.policy[key]; | ||
if (key === 'rulesMask') { | ||
// this is how to disable rules bitwise | ||
requestPolicy.rulesMask = requestPolicy.rulesMask & ~value; | ||
} else { | ||
requestPolicy[key] = value; | ||
} | ||
} | ||
} else if (inputType === 'querystring') { | ||
if (!e.policy) { | ||
requestPolicy.exclusions.ignoreQuerystring = true; | ||
} else { | ||
// merge exclusion's policy into the querystring's policy | ||
requestPolicy.exclusions.querystringPolicy = requestPolicy.exclusions.querystringPolicy || {}; | ||
for (const key of Object.keys(e.policy)) { | ||
const value = e.policy[key]; | ||
if (key !== 'rulesMask') { | ||
requestPolicy.exclusions.querystringPolicy[key] = value; | ||
} | ||
} | ||
} | ||
} else if (inputType === 'body') { | ||
if (!e.policy) { | ||
requestPolicy.exclusions.ignoreBody = true; | ||
} else { | ||
// merge exclusion's policy into the querystring's policy | ||
requestPolicy.exclusions.bodyPolicy = requestPolicy.exclusions.bodyPolicy || {}; | ||
for (const key of Object.keys(e.policy)) { | ||
const value = e.policy[key]; | ||
if (key !== 'rulesMask') { | ||
requestPolicy.exclusions.bodyPolicy[key] = value; | ||
} | ||
} | ||
} | ||
} else { | ||
// copy matching input exclusions into request policy | ||
requestPolicy.exclusions[inputType].push(e); | ||
} | ||
} | ||
} | ||
return requestPolicy; | ||
} | ||
initPolicy(); | ||
messages.on(SERVER_SETTINGS_UPDATE, (remoteSettings) => { | ||
function updateGlobalPolicy(remoteSettings) { | ||
let update; | ||
@@ -135,5 +272,77 @@ | ||
} | ||
} | ||
function updateExclusions(serverUpdate) { | ||
const exclusions = [ | ||
...(serverUpdate.settings?.exceptions?.inputExceptions || []), | ||
...(serverUpdate.settings?.exceptions?.urlExceptions || []) | ||
].filter((exclusion) => exclusion.modes.includes('defend')); | ||
if (!exclusions.length) return; | ||
for (const exclusionDtm of exclusions) { | ||
exclusionDtm.inputType = exclusionDtm.inputType || 'URL'; | ||
const { name, rules, inputName, urls, inputType } = exclusionDtm; | ||
const key = inputType.toLowerCase(); | ||
if (!compiled[key]) continue; | ||
try { | ||
const e = { name }; | ||
e.matchesUriPath = createUriPathMatcher(urls); | ||
if (inputName) { | ||
e.matchesInputName = createInputNameMatcher(inputName); | ||
} | ||
if (rules.length) { | ||
let rulesMask = 0; | ||
const exclusionPolicy = {}; | ||
for (let ruleId of rules) { | ||
// todo: this doesn't seem to make a difference? | ||
if (ruleId === 'nosql-injection') { | ||
ruleId = 'nosql-injection-mongo'; | ||
} | ||
if (agentLib.RuleType[ruleId]) { | ||
Object.assign(exclusionPolicy, { [ruleId]: OFF }); | ||
if (inputType === 'URL') { | ||
rulesMask = rulesMask | agentLib.RuleType[ruleId]; | ||
exclusionPolicy.rulesMask = rulesMask; | ||
} | ||
} | ||
} | ||
e.policy = exclusionPolicy; | ||
} | ||
if (key === 'cookie') { | ||
e.checkCookieInHeader = (cookieHeader) => { | ||
for (const cookiePair of cookieHeader.split(';')) { | ||
const cookieKey = cookiePair.split('=')[0]; | ||
if (e.matchesInputName(cookieKey)) { | ||
return true; | ||
} | ||
} | ||
return false; | ||
}; | ||
} | ||
compiled[key].push(e); | ||
} catch (err) { | ||
logger.error({ err, exclusionDtm }, 'failed to process exclusion'); | ||
} | ||
} | ||
} | ||
messages.on(SERVER_SETTINGS_UPDATE, (msg) => { | ||
updateGlobalPolicy(msg); | ||
updateExclusions(msg); | ||
}); | ||
initPolicy(); | ||
return protect.getPolicy = getPolicy; | ||
}; |
{ | ||
"name": "@contrast/protect", | ||
"version": "1.7.0", | ||
"version": "1.8.0", | ||
"description": "Contrast service providing framework-agnostic Protect support", | ||
@@ -5,0 +5,0 @@ "license": "SEE LICENSE IN LICENSE", |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
187766
4855