Files
nginx-proxy-manager/backend/internal/nginx-openappsec.js
Rami Winestock ce84a865f1 Notify policy updates to open-appsec container.
UI labels in log.
2023-12-13 23:41:50 +02:00

320 lines
12 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const util = require('util');
const execPromise = util.promisify(require('child_process').exec);
const { exec } = require('child_process');
const _ = require('lodash');
const fs = require('fs');
const logger = require('../logger').nginx;
const config = require('../lib/config');
const yaml = require('js-yaml');
const path = require('path');
const constants = require('../lib/constants');
const internalNginxOpenappsec = {
// module constants
CONFIG_TEMPLATE_FILE_NAME: 'local-policy-open-appsec-enabled-for-proxy-host.yaml',
CONFIG_TEMPLATE_DIR: '/app/templates',
// module variables
config: null,
configTemplate: null,
/**
* Generate an open-appsec config file for a proxy host.
*
* @param {Object} access
* @param {Object} row
* @param {Object} data
* @returns {Promise}
*/
generateConfig: (access, row, data) => {
return access.can('settings:update', row.id)
.then(() => {
if (config.debug()) {
logger.info('Generating openappsec config:', JSON.stringify(data, null, 2));
}
const openappsecMode = data.use_openappsec == false ? 'inactive' : data.openappsec_mode;
const configTemplateFilePath = path.join(internalNginxOpenappsec.CONFIG_TEMPLATE_DIR, internalNginxOpenappsec.CONFIG_TEMPLATE_FILE_NAME)
const configFilePath = path.join(constants.APPSEC_EXT_DIR, constants.APPSEC_CONFIG_FILE_NAME);
let openappsecConfig = yaml.load(fs.readFileSync(configFilePath, 'utf8'));
let openappsecConfigTemplate = yaml.load(fs.readFileSync(configTemplateFilePath, 'utf8'));
internalNginxOpenappsec.config = openappsecConfig;
internalNginxOpenappsec.configTemplate = openappsecConfigTemplate;
const specificRuleName = 'npm-managed-specific-rule-proxyhost-' + row.id;
const logTriggerName = 'npm-managed-log-trigger-proxyhost-' + row.id;
const practiceName = 'npm-managed-practice-proxyhost-' + row.id;
_.remove(openappsecConfig.policies['specific-rules'], rule => rule.name === specificRuleName || rule.name.startsWith(`${specificRuleName}.`));
data.domain_names.forEach((domain, index) => {
let ruleName = index > 0 ? `${specificRuleName}.${index}` : specificRuleName;
let specificRuleNode = {
host: domain,
name: ruleName,
triggers: [logTriggerName],
mode: openappsecMode,
practices: [practiceName]
};
internalNginxOpenappsec.updateNode('policies', 'specific-rules', specificRuleNode, openappsecMode);
});
internalNginxOpenappsec.updateNode('', 'practices', { name: practiceName, 'web-attacks.override-mode': openappsecMode, 'web-attacks.minimum-confidence': data.minimum_confidence }, openappsecMode);
internalNginxOpenappsec.updateNode('', 'log-triggers', { name: logTriggerName }, openappsecMode);
// remove all openappsec managed location config nodes for a proxy host.
let pattern = new RegExp(`^npm-managed.*-${row.id}-.*`);
internalNginxOpenappsec.removeMatchingNodes(openappsecConfig, pattern);
// for each data.location, create location config nodes
data.locations.forEach((location, index) => {
let locationSpecificRuleName = 'npm-managed-specific-rule-proxyhost-' + row.id + '-' + index;
let locationLogTriggerName = 'npm-managed-log-trigger-proxyhost-' + row.id + '-' + index;
let locationPracticeName = 'npm-managed-practice-proxyhost-' + row.id + '-' + index;
let locationOpenappsecMode = location.use_openappsec == false ? 'inactive' : location.openappsec_mode;
_.remove(openappsecConfig.policies['specific-rules'], rule => rule.name === locationSpecificRuleName || rule.name.startsWith(`${locationSpecificRuleName}.`));
data.domain_names.forEach((domain, index) => {
let locationUrl = domain + location.path;
let ruleName = index > 0 ? `${locationSpecificRuleName}.${index}` : locationSpecificRuleName;
let domainSpecificRuleNode = {
host: locationUrl,
name: ruleName,
triggers: [locationLogTriggerName],
mode: locationOpenappsecMode,
practices: [locationPracticeName]
};
internalNginxOpenappsec.updateNode('policies', 'specific-rules', domainSpecificRuleNode, locationOpenappsecMode, 'location', openappsecMode);
});
internalNginxOpenappsec.updateNode('', 'practices', { name: locationPracticeName, 'web-attacks.override-mode': locationOpenappsecMode, 'web-attacks.minimum-confidence': location.minimum_confidence }, locationOpenappsecMode, 'location', openappsecMode);
internalNginxOpenappsec.updateNode('', 'log-triggers', { name: locationLogTriggerName }, locationOpenappsecMode, 'location', openappsecMode);
});
fs.writeFileSync(configFilePath, yaml.dump(openappsecConfig));
},
(err) => {
logger.error('Error generating openappsec config:', err);
return Promise.reject(err);
})
.then(() => {
// Return the notifyPolicyUpdate promise chain
// notify openappsec to apply the policy
return internalNginxOpenappsec.notifyPolicyUpdate().catch((errorMessage) => {
console.error('Error:', errorMessage);
const errorMessageForUI = `Error: Policy couldnt be applied, open-appsec-agent container is not responding.
Check if open-appec-agent container is running, then apply open-appsec Configuration
again by clicking here:
<br>Settings -> open-appsec Advanced -> Save Settings`;
return Promise.reject(new Error(errorMessageForUI));
});
})
.catch((err) => {
logger.error('Error generating openappsec config:', err);
throw err; // Propagate the error to the caller
});
},
/**
* Remove all openappsec managed config nodes for a proxy host.
*
* @param {Object} access
* @param {Object} row
* @returns {Promise}
*
*/
deleteConfig: (access, row) => {
return access.can('settings:update', row.id)
.then(() => {
const configFilePath = path.join(constants.APPSEC_EXT_DIR, constants.APPSEC_CONFIG_FILE_NAME);
let openappsecConfig = yaml.load(fs.readFileSync(configFilePath, 'utf8'));
// remove all openappsec managed location config nodes for a proxy host.
let pattern = new RegExp(`^npm-managed.*-${row.id}`);
internalNginxOpenappsec.removeMatchingNodes(openappsecConfig, pattern);
fs.writeFileSync(configFilePath, yaml.dump(openappsecConfig));
})
.then(() => {
// Return the notifyPolicyUpdate promise chain
// notify openappsec to apply the policy
return internalNginxOpenappsec.notifyPolicyUpdate().catch((errorMessage) => {
console.error('---Error:', errorMessage);
const errorMessageForUI = `Error: Policy couldnt be applied, open-appsec-agent container is not responding.
Check if open-appec-agent container is running, then apply open-appsec Configuration
again by clicking here:
<br>Settings -> open-appsec Advanced -> Save Settings`;
return Promise.reject(new Error(errorMessageForUI));
});
})
.catch((err) => {
logger.error('Error deleting openappsec config:', err);
throw err; // Propagate the error to the caller
});
},
/**
* Update a node in the openappsec config.
* - if the node does not exist, create it.
* - if the node exists, update it.
* - if openappsecMode is 'inactive', delete the node.
*
* @param {String} parentNodePath - path to the parent node. e.g. 'policies'.
* @param {String} nodeName - name of the node. e.g. 'specific-rules', 'practices', 'log-triggers'.
* @param {Object} nodeItemProperties
* @param {String} openappsecMode
* @param {String} nodeType - 'host' or 'location'
* @param {String} hostAppsecMode - to check if the host of a location is inactive.
*/
updateNode: function (parentNodePath, nodeName, nodeItemProperties, openappsecMode, nodeType = 'host', hostAppsecMode = '') {
// if no parent node path is specified, use the root of the config object.
const parent = parentNodePath ? _.get(this.config, parentNodePath, this.config) : this.config;
if (!parent) {
console.log('parent is not defined');
return;
}
let nodeItems = _.find(parent[nodeName], { name: nodeItemProperties.name });
if (openappsecMode == 'inactive' && nodeItems) {
_.remove(parent[nodeName], { name: nodeItemProperties.name });
}
if (openappsecMode !== 'inactive' || nodeType === 'location' && hostAppsecMode !== 'inactive') {
if (!nodeItems) {
// create the node from the template if it does not exist.
let templateSearchPath = parentNodePath ? `${parentNodePath}.${nodeName}[0]` : `${nodeName}[0]`;
nodeItems = _.cloneDeep(_.get(this.configTemplate, templateSearchPath));
// update the node with the nodeItemProperties. if the nodeType is 'location' and the openappsecMode is 'inactive', only update the name, host, and the (inactive) mode.
if (nodeType === 'location' && openappsecMode === 'inactive') {
nodeItemProperties = _.pick(nodeItemProperties, ['name', 'host', 'triggers', 'practices', 'mode', 'web-attacks.override-mode']);
}
Object.keys(nodeItemProperties).forEach(key => {
_.set(nodeItems, key, nodeItemProperties[key]);
});
parent[nodeName] = parent[nodeName] || [];
parent[nodeName].push(nodeItems);
} else {
// update the node if it exists.
Object.keys(nodeItemProperties).forEach(key => {
_.set(nodeItems, key, nodeItemProperties[key]);
});
}
}
},
notifyPolicyUpdate: async function() {
if (!constants.USE_NOTIFY_POLICY) {
console.log('USE_NOTIFY_POLICY is false');
return;
}
let ports = constants.PORTS;
console.log(`Notifying openappsec to apply the policy on ports ${ports}`);
let lastError = null;
for (let port of ports) {
try {
const command = `curl -s -o /dev/null -w "%{http_code}" ${constants.HOSTURL}:${port}/openappsec/apply-policy`;
console.log(`command: ${command}`);
let { stdout } = await execPromise(command);
if (stdout === '200') {
console.log(`Policy applied successfully on port ${port}`);
return;
} else {
console.log(`Policy Unexpected response code: ${stdout}`);
lastError = new Error(`Unexpected response code: ${stdout}`);
}
} catch (error) {
console.log(`Error notifying openappsec to apply the policy on port ${port}: ${error.message}`);
lastError = error;
}
}
if (lastError) {
throw lastError;
}
},
/**
* Recursively removes nodes from a JavaScript object based on a pattern.
*
* @param {Object|Array} obj - The object or array to remove nodes from.
* @param {RegExp} pattern - The pattern to match against node names.
*/
removeMatchingNodes: function (obj, pattern) {
_.forEach(obj, (value, key) => {
if (_.isPlainObject(value)) {
if (pattern.test(key)) {
delete obj[key];
} else {
this.removeMatchingNodes(value, pattern);
}
} else if (_.isArray(value)) {
_.remove(value, function (item) {
return _.isPlainObject(item) && pattern.test(item.name);
});
value.forEach(item => {
if (_.isPlainObject(item)) {
this.removeMatchingNodes(item, pattern);
}
});
}
});
},
/**
* Get the openappsec mode, use_openappsec and minimum_confidence for a proxy host.
*
* @param {Object} openappsecConfig - openappsec config object
* @param {Number} rowId - proxy host id
* @returns {Object} { mode, use_openappsec, minimum_confidence }
*/
getOpenappsecFields: (openappsecConfig, rowId) => {
const specificRuleName = 'npm-managed-specific-rule-proxyhost-' + rowId;
const specificRule = _.find(openappsecConfig?.policies['specific-rules'], { name: specificRuleName });
const mode = specificRule?.mode || 'inactive';
const use_openappsec = mode !== 'inactive' && mode !== undefined;
const practiceName = 'npm-managed-practice-proxyhost-' + rowId;
const practice = _.find(openappsecConfig?.practices, { name: practiceName });
const minimum_confidence = practice?.['web-attacks']['minimum-confidence'] || 'high';
return { mode, use_openappsec, minimum_confidence };
},
/**
* get the openappsec config file path.
*/
getConfigFilePath: () => {
const configFilePath = path.join(constants.APPSEC_EXT_DIR, constants.APPSEC_CONFIG_FILE_NAME);
return configFilePath;
},
/**
* A simple wrapper around unlinkSync that writes to the logger
*
* @param {String} filename
*/
deleteFile: (filename) => {
logger.debug('Deleting file: ' + filename);
try {
fs.unlinkSync(filename);
} catch (err) {
logger.debug('Could not delete file:', JSON.stringify(err, null, 2));
}
}
};
module.exports = internalNginxOpenappsec;