/*
# templates/mod_security/views/commonController.js Copyright(c) 2020 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* global define */
/* ------------------------------------------------------------------------------
* DEVELOPER NOTES:
* 1) Put all common application functionality here, maybe
*-----------------------------------------------------------------------------*/
define(
'app/views/commonController',[
"angular",
"cjt/filters/wrapFilter",
"cjt/services/alertService",
"cjt/directives/alertList",
"uiBootstrap"
],
function(angular) {
var app;
try {
app = angular.module("App");
} catch (e) {
app = angular.module("App", ["ui.bootstrap", "ngSanitize"]);
}
var controller = app.controller(
"commonController",
["$scope", "$location", "$rootScope", "alertService", "PAGE",
function($scope, $location, $rootScope, alertService, PAGE) {
// Setup the installed bit...
$scope.isInstalled = PAGE.installed;
// Bind the alerts service to the local scope
$scope.alerts = alertService.getAlerts();
$scope.route = null;
/**
* Closes an alert and removes it from the alerts service
*
* @method closeAlert
* @param {String} index The array index of the alert to remove
*/
$scope.closeAlert = function(id) {
alertService.remove(id);
};
/**
* Determines if the current view matches the supplied pattern
*
* @method isCurrentView
* @param {String} view The path to the view to match
*/
$scope.isCurrentView = function(view) {
if ( $scope.route && $scope.route.$$route ) {
return $scope.route.$$route.originalPath === view;
}
return false;
};
// register listener to watch route changes
$rootScope.$on( "$routeChangeStart", function(event, next, current) {
$scope.route = next;
});
}
]);
return controller;
}
);
/* global define: false */
define(
'app/services/hitlistService',[
// Libraries
"angular",
// Application
// CJT
"cjt/util/locale",
"cjt/io/api",
"cjt/io/whm-v1-request",
"cjt/io/whm-v1", // IMPORTANT: Load the driver so its ready
// Angular components
"cjt/services/APIService"
],
function(angular, LOCALE, API, APIREQUEST, APIDRIVER) {
// Constants
var NO_MODULE = "";
// Fetch the current application
var app;
try {
app = angular.module("App"); // For runtime
} catch (e) {
app = angular.module("App", ["cjt2.services.api"]); // Fall-back for unit testing
}
/**
* Converts the response to our application data structure
* @private
* @param {Object} response
* @return {Object} Sanitized data structure.
*/
function _convertResponseToList(response) {
var items = [];
if (response.status) {
var data = response.data;
for (var i = 0, length = data.length; i < length; i++) {
var hitList = data[i];
items.push(
hitList
);
}
var meta = response.meta;
var totalItems = meta.paginate.total_records || data.length;
var totalPages = meta.paginate.total_pages || 1;
return {
items: items,
totalItems: totalItems,
totalPages: totalPages
};
} else {
return {
items: [],
totalItems: 0,
totalPages: 0
};
}
}
/**
* Setup the hitlist models API service
*/
app.factory("hitListService", ["$q", "APIService", function($q, APIService) {
// Set up the service's constructor and parent
var HitListService = function() {};
HitListService.prototype = new APIService({
transformAPISuccess: _convertResponseToList
});
// Extend the prototype with any class-specific functionality
angular.extend(HitListService.prototype, {
/**
* Get a list of mod_security rule hits that match the selection criteria passed in meta parameter
* @param {object} meta Optional meta data to control sorting, filtering and paging
* @param {string} meta.sortBy Name of the field to sort by
* @param {string} meta.sordDirection asc or desc
* @param {string} meta.sortType Optional name of the sort rule to apply to the sorting
* @param {string} meta.filterBy Name of the filed to filter by
* @param {string} meta.filterCompare Optional comparator to use when comparing for filter.
* If not provided, will default to ???.
* May be one of:
* TODO: Need a list of valid filter types.
* @param {string} meta.filterValue Expression/argument to pass to the compare method.
* @param {string} meta.pageNumber Page number to fetch.
* @param {string} meta.pageSize Size of a page, will default to 10 if not provided.
* @return {Promise} Promise that will fulfill the request.
*/
fetchList: function fetchList(meta) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_get_log");
if (meta) {
if (meta.sortBy && meta.sortDirection) {
apiCall.addSorting(meta.sortBy, meta.sortDirection, meta.sortType);
}
if (meta.pageNumber) {
apiCall.addPaging(meta.pageNumber, meta.pageSize || 10);
}
if (meta.filterBy && meta.filterValue) {
apiCall.addFilter(meta.filterBy, meta.filterCompare, meta.filterValue);
}
}
return this.deferred(apiCall).promise;
},
/**
* Retrieve an individual hit from the unique hit ID, which is the primary key in the modsec.hits table.
*
* @method fetchById
* @param {[type]} hitId [description]
* @return {[type]} [description]
*/
fetchById: function fetchById(hitId) {
var promise = this.fetchList({
filterBy: "id",
filterValue: hitId,
filterCompare: "eq"
}).then(function(response) {
// Check the length of the results to make sure we only have one hit
var length = response.items.length;
if (length === 1) {
return response;
} else if (length > 1) {
return $q.reject({
message: LOCALE.maketext("More than one hit matched hit ID “[_1]”.", hitId),
count: length
});
} else {
return $q.reject({
message: LOCALE.maketext("No hits matched ID “[_1]”.", hitId),
count: length
});
}
});
return promise;
},
/**
* Helper method that calls convertResponseToList to prepare the data structure
* @param {Object} response
* @return {Object} Sanitized data structure.
*/
prepareList: function prepareList(response) {
// Since this is coming from the backend, but not through the api.js layer,
// we need to parse it to the frontend format.
response = APIDRIVER.parse_response(response).parsedResponse;
return _convertResponseToList(response);
}
});
return new HitListService();
}]);
}
);
/*
# mod_security/services/ruleService.js Copyright(c) 2020 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* global define: false */
define(
'app/services/ruleService',[
// Libraries
"angular",
// CJT
"cjt/io/api",
"cjt/io/whm-v1-request",
"cjt/io/whm-v1",
"cjt/util/locale",
"cjt/util/parse",
// Angular components
"cjt/services/APIService"
],
function(angular, API, APIREQUEST, APIDRIVER, LOCALE, PARSE) {
// Constants
var NO_MODULE = "";
// Fetch the current application
var app;
try {
app = angular.module("App"); // For runtime
} catch (e) {
app = angular.module("App", ["cjt2.services.api"]); // Fall-back for unit testing
}
// CONSTANTS
// Directive chunks. - The API returns complete directive chunks, so page by chunks. This was
// selected arbitrary. Revise if we seem to be making too many requests.
var NUMBER_OF_DIRECTIVES_PER_BATCH = 500;
// Lines - To limit the need for a smart record parser, the UI just breaks up the batch by lines for saving.
// Since chunks are often made up of 2 to 3 lines, to make the load and save fairly balanced, batch by
// 3 x the number of directives. Not guaranteed to match, but on average not a bad guess.
var NUMBER_OF_LINES_PER_BATCH = 3 * NUMBER_OF_DIRECTIVES_PER_BATCH;
var LINE_REGEX = /\n/g; // Match expression used to break up the text into line buffers.
/**
* Setup the rule models API service
*/
app.factory("ruleService", ["$q", "APIService", function($q, APIService) {
/**
* Normalize the rule to account for any missing data, etc.
*
* @method normalizeRule
* @private
* @param {Object} rule Rule returned from the server.
* @return {Object} Rule with all the fields normalized and patched.
*/
function normalizeRule(rule) {
rule.config_active = PARSE.parsePerlBoolean(rule.config_active);
rule.disabled = PARSE.parsePerlBoolean(rule.disabled);
rule.staged = PARSE.parsePerlBoolean(rule.staged);
rule.vendor_active = PARSE.parsePerlBoolean(rule.vendor_active);
return rule;
}
/**
* Converts the response to our application data structure
*
* @method convertResponseToList
* @private
* @param {Object} response
* @return {Object} Sanitized data structure.
*/
function convertResponseToList(response) {
var items = [];
if (response.status) {
var data = response.data.chunks;
for (var i = 0, length = data.length; i < length; i++) {
items.push(normalizeRule(data[i]));
}
var meta = response.meta;
var totalItems = meta.paginate.total_records || data.length;
var totalPages = meta.paginate.total_pages || 1;
return {
items: items,
stagedChanges: PARSE.parsePerlBoolean(response.data.staged_changes),
totalItems: totalItems,
totalPages: totalPages,
status: response.status
};
} else {
return {
items: [],
stagedChanges: false,
totalItems: 0,
totalPages: 0,
status: response.status
};
}
}
/**
* Disable a rule.
*
* @method _disableRule
* @private
* @param {Deferred} deferred
* @param {String} config Config file for rule
* @param {Number} id Rule id.
* @param {Boolean} deploy if true, will deploy the change, if false will only stage the change.
* @param {Object} payload optional payload to pass to resolve.
* @return {Promise}
*/
var _disableRule = function(deferred, config, id, deploy, payload) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_disable_rule");
apiCall.addArgument("config", config);
apiCall.addArgument("id", id);
this.deferred(apiCall, {
context: this,
done: function(response) {
deferred.notify(LOCALE.maketext("You have successfully disabled the rule."));
// create items from the response
response = response.parsedResponse;
if (response.status) {
// Update the payload if exists
if (payload) {
payload.disabled = true;
}
// Move to the next step.
if (deploy) {
_deployRules.call(this, deferred, payload);
} else {
// keep the promise
deferred.resolve(payload);
}
} else {
// pass the error along
deferred.reject(response.error);
}
}
},
deferred);
// pass the promise back to the controller
return deferred.promise;
};
/**
* Enable a rule.
*
* @method _enabledRule
* @private
* @param {Deferred} deferred
* @param {String} config Config file for rule
* @param {Number} id Rule id
* @param {Boolean} deploy if true, will deploy the change, if false will only stage the change.
* @param {Object} payload optional payload to pass to resolve.
* @return {Promise}
*/
var _enableRule = function(deferred, config, id, deploy, payload) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_undisable_rule");
apiCall.addArgument("config", config);
apiCall.addArgument("id", id);
this.deferred(apiCall, {
context: this,
done: function(response) {
// create items from the response
response = response.parsedResponse;
if (response.status) {
deferred.notify(LOCALE.maketext("You have successfully enabled the rule."));
// Update the payload if it exists
if (payload) {
payload.disabled = false;
}
// Move on to the next step.
if (deploy) {
_deployRules.call(this, deferred, payload);
} else {
// keep the promise
deferred.resolve(payload);
}
} else {
// pass the error along
deferred.reject(response.error);
}
}
},
deferred);
// pass the promise back to the controller
return deferred.promise;
};
/**
* Deploy staged rules.
*
* @method _deployRule
* @private
* @param {Deferred} deferred
* @param {Object} payload optional payload to pass to resolve.
* @return {Promise}
*/
var _deployRules = function(deferred, payload) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_deploy_all_rule_changes");
this.deferred(apiCall, {
apiSuccess: function(response) {
deferred.notify(LOCALE.maketext("You have successfully deployed the staged rules to your custom [asis,ModSecurity™] configuration."));
deferred.resolve(payload);
}
}, deferred);
// pass the promise back to the controller
return deferred.promise;
};
// Set up the service's constructor and parent
var RulesService = function() {};
RulesService.prototype = new APIService();
// Extend the prototype with any class-specific functionality
angular.extend(RulesService.prototype, {
/**
* Get a list of custom mod_security rules that match the selection criteria passed in the meta parameter.
* At least one of the optional parameters must be provided as an argument.
*
* @method fetchRulesList
* @param {string} [vendorList] Optional array of one or more vendor ID strings.
* @param {object} [meta] Optional meta data to control sorting, filtering and paging
* @param {string} meta.sortBy Name of the field to sort by
* @param {string} meta.sordDirection asc or desc
* @param {string} meta.sortType Optional name of the sort rule to apply to the sorting
* @param {string} meta.filterBy Name of the filed to filter by
* @param {string} meta.filterCompare Optional comparator to use when comparing for filter.
* @param {string} meta.filterValue Expression/argument to pass to the compare method.
* @param {string} meta.pageNumber Page number to fetch.
* @param {string} meta.pageSize Size of a page, will default to 10 if not provided.
* @param {boolean} meta.advanced.showStagedDeployed
* @param {boolean} meta.advanced.showEnabledDisabled
* @param {boolean} meta.advanced.includeUserRules
* @return {Promise} Promise that will fulfill the request.
* @throws Error
*/
fetchRulesList: function(vendorList, meta) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_get_rules");
if (vendorList && vendorList.length) {
// Make the vendorList comma delimmited for the back-end
apiCall.addArgument("vendor_id", vendorList.join(","));
}
// Make sure we have something to do before going further
if ((!vendorList || !vendorList.length) && meta && meta.advanced && !angular.isDefined(meta.advanced.includeUserRules)) {
throw new Error("No vendor selected and user-defined rules were not requested. There is nothing to fetch.");
}
apiCall.addArgument("exclude_other_directives", 1);
apiCall.addArgument("exclude_bare_comments", 1);
if ( !angular.isDefined(meta.advanced.includeUserRules) ) {
apiCall.addFilter("vendor_active", "eq", 1); // modsec2.user.conf will have these fields forced to true
apiCall.addFilter("config_active", "eq", 1);
}
if (meta && meta.advanced) {
if (meta.advanced.showStagedDeployed === "staged") {
apiCall.addFilter("staged", "eq", 1);
}
if (meta.advanced.showStagedDeployed === "deployed") {
apiCall.addFilter("staged", "eq", 0);
}
if (meta.advanced.showEnabledDisabled === "enabled") {
apiCall.addFilter("disabled", "eq", 0);
}
if (meta.advanced.showEnabledDisabled === "disabled") {
apiCall.addFilter("disabled", "eq", 1);
}
if (meta.advanced.includeUserRules) {
// Fetch the rules from the user's custom config as well
apiCall.addArgument("config", "modsec2.user.conf"); /* TODO: EA-4700 */
}
}
if (meta) {
if (meta.sortBy && meta.sortDirection) {
apiCall.addSorting(meta.sortBy, meta.sortDirection, meta.sortType);
}
if (meta.pageNumber) {
apiCall.addPaging(meta.pageNumber, meta.pageSize || 10);
}
if (meta.filterBy && meta.filterCompare && meta.filterValue) {
apiCall.addFilter(meta.filterBy, meta.filterCompare, meta.filterValue);
}
}
var deferred = this.deferred(apiCall, {
transformAPISuccess: convertResponseToList
});
// pass the promise back to the controller
return deferred.promise;
},
/**
* Get a single rule by its id from the backend.
*
* @method fetchRulesById
* @param {Number} ruleId Id of the rule to fetch.
* @param {String} [vendorId] The unique vendor ID for the containing rule set.
* If this is not included, the user-defined rule set will be searched.
* @return {Promise} Promise that will fulfill the request.
*/
fetchRulesById: function(ruleId, vendorId) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_get_rules");
apiCall.addArgument("exclude_other_directives", 1);
apiCall.addArgument("exclude_bare_comments", 1);
apiCall.addFilter("id", "==", ruleId);
// If a vendor string was passed, use that as the vendor ID.
// Otherwise, use the user-defined config.
if (typeof vendorId === "string") {
apiCall.addArgument("vendor_id", vendorId);
} else {
apiCall.addArgument("config", "modsec2.user.conf"); /* TODO: EA-4700 */
}
// Don't add the filtering here that requires the vendor and config to be active
// because if someone is specifically picking a rule out by id, they probably want
// it regardless of those other conditions.
var deferred = this.deferred(apiCall, {
apiSuccess: function(response, deferred) {
response = convertResponseToList(response);
var length = response.items.length;
if (length === 1) {
deferred.resolve(response);
} else {
deferred.reject({ count: length });
}
},
transformAPIFailure: function(response) {
return { message: response.error };
}
});
// pass the promise back to the controller
return deferred.promise;
},
/**
* Get all custom rules as a single text block for mass edit
*
* @method getCustomConfigText
* @return {Promise} Promise that will fulfill the request.
*/
getCustomConfigText: function() {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_get_config_text");
apiCall.addArgument("config", "modsec2.user.conf"); /* TODO: EA-4700 */
apiCall.addArgument("pagable", 1);
apiCall.addPaging(1, NUMBER_OF_DIRECTIVES_PER_BATCH);
var that = this;
var deferred = this.deferred(apiCall, {
context: this,
apiSuccess: function(response, deferred) {
deferred.notify({ text: response.data, type: "page", page: 1 });
var meta = response.meta;
var totalPages = meta.paginate.total_pages || 1;
if (totalPages > 1) {
var promise = $q.all({});
/**
* Build a function that returns a promise for a specific page of data
* @param {Number} page Page # to retrieve.
* @return {Promise}
*/
var pageRequestFactory = function(page) {
return function() {
apiCall.addPaging(page, NUMBER_OF_DIRECTIVES_PER_BATCH);
return that.deferred(apiCall, {
apiSuccess: function(response) {
deferred.notify({ text: response.data, type: "page", page: page, totalPages: totalPages });
},
apiFailure: function(response) {
deferred.notify({ type: "error", error: response.error });
}
},
deferred);
};
};
for (var page = 2; page <= totalPages; page++) {
// build the promise chain for page 2 to n
promise = promise.then(pageRequestFactory(page));
}
promise.finally(function() {
deferred.resolve();
});
} else {
deferred.notify({ text: response.data.text, type: "page", page: 1, totalPages: 1, done: true });
deferred.resolve();
}
},
apiFailure: function(response) {
deferred.notify({ type: "error", error: response.error });
deferred.reject();
}
});
// pass the promise back to the controller
return deferred.promise;
},
/**
* Sets the contents of the user defined configuration file
*
* @method setCustomConfigText
* @param {String} text The contents of the configuration file to be set
@param {Boolean} deploy If true, will deploy the rule, if false will only save the rule to the staging file.
* @return {Promise}
*/
setCustomConfigText: function(text, deploy) {
// Splits the text into array of equal parts
var lines = text.split(LINE_REGEX);
var lineCount = lines.length;
var sections = [];
for (var i = 0; i < lineCount; i += NUMBER_OF_LINES_PER_BATCH ) {
sections.push(lines.slice(i, i + NUMBER_OF_LINES_PER_BATCH).join("\n"));
}
var pages = sections.length;
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_assemble_config_text");
apiCall.addArgument("config", "modsec2.user.conf"); /* TODO: EA-4700 */
apiCall.addArgument("text", sections[0]);
apiCall.addArgument("init", 1);
if (pages === 1) {
apiCall.addArgument("final", 1);
if (deploy) {
apiCall.addArgument("deploy", 1);
}
}
var that = this;
var deferred = this.deferred(apiCall, {
context: this,
apiSuccess: function(response, deferred) {
if (pages > 1) {
// We don't need it for the rest of the pages
apiCall.removeArgument("init");
var promise = $q.all({});
/**
* Build a function that returns a promise for a specific page of data
*
* @name pagePostFactory
* @private
* @param {Number} page Section of the data to post to the server.
* @param {Boolean} done If true add the final argument. Otherwise don't.
* @param {Boolean} deploy If true deploy the changes, saving another api call. yeah. Otherwise don't. Ignored unless done is also true.
* @return {Promise}
*/
var pagePostFactory = function(page, done, deploy) {
return function() {
apiCall.addArgument("text", sections[page]);
if (done) {
apiCall.addArgument("final", 1);
if (deploy) {
apiCall.addArgument("deploy", 1);
}
}
return that.deferred(apiCall, {
apiSuccess: function(response) {
deferred.notify({ text: response.data, type: "post", page: page, totalPages: pages });
},
apiFailure: function(response) {
// TODO: Need to clean up the partly assembled item?
deferred.notify({ type: "error", error: response.error });
deferred.reject();
}
},
deferred);
};
};
// sections[0] already sent, so send the rest
for (var page = 1; page < pages; page++) {
// build the promise chain for page 2 to n
var done = (page === pages - 1);
promise = promise.then(pagePostFactory(page, done, deploy));
}
promise.finally(function() {
// keep the promise
deferred.resolve();
});
} else {
// keep the promise
deferred.resolve();
}
},
apiFailure: function(response, deferred) {
if ( response.data ? PARSE.parsePerlBoolean(response.data.duplicate) : false ) {
if (deploy) {
_deployRules.call(this, deferred);
} else {
// ignore the duplicate edit, keep the promise
deferred.resolve();
}
} else {
// pass the error along
deferred.notify({ type: "error", error: response.error });
deferred.reject();
}
}
});
// pass the promise back to the controller
return deferred.promise;
},
/**
* Add a rule and optionally disable and optionally deploy it.
*
* @method addRule
* @param {Strring} ruleText Proposed multi-line modsec2 SecAction or SecRule to add to the staging file.
* @param {Boolean} enabled If true, will save the rule as enabled, if false will save the rule a disabled.
* @param {Boolean} deploy If true, will deploy the rule, if false will only save the rule to the staging file.
* @return {Promise} Promise that will fulfill the request.
*/
addRule: function(ruleText, enabled, deploy) {
// make a promise
var deferred = $q.defer();
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_add_rule");
apiCall.addArgument("config", "modsec2.user.conf"); /* TODO: EA-4700 */
apiCall.addArgument("rule", ruleText);
this.deferred(apiCall, {
context: this,
apiSuccess: function(response) {
var rule = normalizeRule(response.data.rule);
deferred.notify(LOCALE.maketext("You have successfully added the rule to the staged configuration file."));
if (!enabled) {
_disableRule.call(this, deferred, rule.config, rule.id, deploy, rule);
} else if (deploy) {
_deployRules.call(this, deferred, rule);
} else {
// keep the promise
deferred.resolve(rule);
}
},
apiFailure: function(response) {
var error = {
message: response.error,
duplicate: response.data ? PARSE.parsePerlBoolean(response.data.duplicate) : false
};
deferred.reject(error);
}
}, deferred);
// pass the promise back to the controller
return deferred.promise;
},
/**
* Clone a rule returning the rule with a unique id.
*
* @method cloneRule
* @param {String} id Original rule id
* @param {String} config Original rule configuration file
* @return {Promise} Promise that will fulfill the request.
*/
cloneRule: function(id, config) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_clone_rule");
apiCall.addArgument("id", id);
apiCall.addArgument("config", config);
var deferred = this.deferred(apiCall, {
transformAPISuccess: function(response) {
return normalizeRule(response.data.rule);
},
transformAPIFailure: function(response) {
return {
message: response.error,
duplicate: response.data ? PARSE.parsePerlBoolean(response.data.duplicate) : false
};
}
});
// pass the promise back to the controller
return deferred.promise;
},
/**
* Delete a rule by its id
*
* @method deleteRule
* @param {Number} ruleId Rule id to delete.
* @return {Promise} Promise that will fulfill the request.
*/
deleteRule: function(ruleId) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_remove_rule");
apiCall.addArgument("config", "modsec2.user.conf"); /* TODO: EA-4700 */
apiCall.addArgument("id", ruleId);
var deferred = this.deferred(apiCall);
// pass the promise back to the controller
return deferred.promise;
},
/**
* Update an existing rule... not much different than add
*
* @method updateRule
* @param {String} configFile The config file housing the rule.
* @param {String} ruleText Proposed multi-line modsec2 SecAction or SecRule to update
* @param {Boolean} enabled If true, will save the rule as enabled, if false will save the rule a disabled.
* @param {Boolean} enabledChanged If true, then set the enabled state to the requested enabled value.
* @param {Boolean} deploy If true, will deploy the rule, if false will only save the rule to the staging file.
*/
updateRule: function(configFile, ruleId, ruleText, enabled, enabledChanged, deploy) {
// make a promise
var deferred = $q.defer();
var apiCall = new APIREQUEST.Class();
/**
* Proxies to enable, disable, and deploy as needed
*
* @method _toggleAndDeploy
* @private
* @param {Boolean} deploy If true, deploy and restart Apache.
* @param {Object} [rule] Optional rule object to use as the payload for the promise.
*/
var _toggleAndDeploy = function _toggleAndDeploy(deploy, rule) {
if (enabledChanged) {
if (enabled) {
_enableRule.call(this, deferred, configFile, rule.id, deploy, rule);
} else {
_disableRule.call(this, deferred, configFile, rule.id, deploy, rule);
}
} else if (deploy) {
_deployRules.call(this, deferred, rule);
} else {
deferred.resolve(rule);
}
}.bind(this);
// We're updating a user-defined rule so they can edit the text and everything.
if (configFile.match(/modsec2.user.conf$/)) { /* TODO: EA-4700 */
apiCall.initialize(NO_MODULE, "modsec_edit_rule");
apiCall.addArgument("config", configFile);
apiCall.addArgument("id", ruleId);
apiCall.addArgument("rule", ruleText);
this.deferred(apiCall, {
context: this,
apiSuccess: function(response) {
var rule = normalizeRule(response.data.rule);
deferred.notify(LOCALE.maketext("You have successfully updated the rule in the staged configuration file."));
_toggleAndDeploy(deploy, rule);
},
apiFailure: function(response) {
var error = {
message: response.error,
duplicate: response.data ? PARSE.parsePerlBoolean(response.data.duplicate) : false
};
deferred.reject(error);
}
},
deferred);
} else {
// We're updating a vendor rule so the only thing they can do is enable, disable, and deploy.
_toggleAndDeploy(deploy, {
id: ruleId,
config: configFile,
rule: ruleText
});
}
// pass the promise back to the controller
return deferred.promise;
},
/**
* Disable a rule by id and optionally deploy it
*
* @method disableRule
* @param {String} config File rule is contained within.
* @param {Number} id Rule id.
* @param {Boolean} deploy if true, will deploy the change, if false will only stage the change.
* @return {Promise}
*/
disableRule: function(config, id, deploy) {
// make a promise
var deferred = $q.defer();
// pass the promise back to the controller
return _disableRule.call(this, deferred, config, id, deploy);
},
/**
* Enable a rule by id and optionally deploy it
*
* @method enableRule
* @param {String} config File rule is contained within.
* @param {Number} id [description]
* @param {Boolean} deploy if true, will deploy the change, if false will only stage the change.
* @return {Promise}
*/
enableRule: function(config, id, deploy) {
// make a promise
var deferred = $q.defer();
// pass the promise back to the controller
return _enableRule.call(this, deferred, config, id, deploy);
},
/**
* Deploy the queued rules if any exist
*
* @method deployQueuedRules
* @return {Promise}
*/
deployQueuedRules: function() {
// make a promise
var deferred = $q.defer();
return _deployRules.call(this, deferred);
},
/**
* Discard the queue rules if any exist
*
* @method discardQueuedRules
* @return {Promise}
*/
discardQueuedRules: function() {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_discard_all_rule_changes");
var deferred = this.deferred(apiCall);
// pass the promise back to the controller
return deferred.promise;
},
/**
* Helper method that calls convertResponseToList to prepare the data structure
*
* @method prepareList
* @param {Object} response
* @return {Object} Sanitized data structure.
*/
prepareList: function(response) {
// Since this is coming from the backend, but not through the api.js layer,
// we need to parse it to the frontend format.
response = APIDRIVER.parse_response(response).parsedResponse;
return convertResponseToList(response);
}
});
return new RulesService();
}]);
}
);
/*
# mod_security/services/reportService.js Copyright(c) 2020 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* global define: false */
define(
'app/services/reportService',[
// Libraries
"angular",
// CJT
"cjt/util/locale",
"cjt/io/whm-v1-request",
"cjt/io/whm-v1",
"cjt/services/APIService",
// Feature-specific
"app/services/hitlistService",
"app/services/ruleService"
],
function(angular, LOCALE, APIREQUEST) {
var NO_MODULE = "";
// Fetch the current application
var app;
try {
app = angular.module("App"); // For runtime
} catch (e) {
app = angular.module("App", ["cjt2.services.api"]); // Fall-back for unit testing
}
/**
* This service uses the ruleService and hitListService to allow for better front-end
* visualization of the relationships between hits and rules. Using setHit or setRule
* will return a promise that will resolve with a report object, which is just a
* conglomerate object of related rules and hits. The two methods differ slightly in
* their output and more information is provided in their documentation blocks.
*/
app.factory("reportService", [
"$q",
"APIService",
"ruleService",
"hitListService",
function(
$q,
APIService,
ruleService,
hitListService
) {
var currentReport; // Will be a promise
/**
* Extracts the vendor id from a config file path.
*
* @method _getVendorFromFile
* @private
* @param {String} file The full file path to the config file.
*
* @return {String} The vendor id if it's a vendor config or undefined if we
* can't parse the file path properly.
*/
function _getVendorFromFile(file) {
var VENDOR_REGEX = /\/modsec_vendor_configs\/(\w+)/;
var match = file && file.match(VENDOR_REGEX);
return match ? match[1] : void 0;
}
/**
* Given a unique hit ID (the id column from modsec.hits) or an actual hit object,
* this method will kick off a promise chain that will package together the full
* hit object along with its associated rule. The resolved report object from this
* method will differ from the report object provided by the setRule promise in
* that it will ONLY include the hit given as an argument.
*
* @method setHit
* @param {String|Number|Object} hit Either a bare hit ID or a hit object
*
* @return {Promise} This promise will resolve with a report object,
* which essentially just packages a rule object
* with an array of associated hits. For this
* method, there will only be one hit in the array.
*/
function fetchByHit(hit) {
var fetched = {}; // This will house the eventual response
var hitPromise;
if (!angular.isObject(hit)) {
// This is a bare hitId so we need to fetch the actual hit object first
hitPromise = hitListService.fetchById(hit)
.then(function(response) {
fetched.hits = response.items;
return response.items[0]; // This length is guaranteed by the hitListService
});
} else {
// We already have the hit object, so just wrap it in an array and a promise
fetched.hits = [hit];
var deferred = $q.defer();
deferred.resolve(hit);
hitPromise = deferred.promise;
}
currentReport = hitPromise.then(function(hit) {
// Reports only work with vendors right now, so check that this is a vendor rule
var vendor = _getVendorFromFile(hit.meta_file);
if (!vendor) {
return $q.reject( LOCALE.maketext("You can only report [asis,ModSecurity] rules that a vendor provided.") );
}
// Fetch the rule
return ruleService.fetchRulesById(hit.meta_id, vendor);
}).then(function(response) {
fetched.rule = response.items[0]; // The length is guaranteed by the ruleService
return fetched;
});
return currentReport;
}
/**
* Given a unique rule ID or an actual rule object, this method will kick off a promise
* chain that will package together the full rule object along with any associated hits.
* The resolved report object from this method will differ from the report object
* provided by the setHit promise in that it will include ALL hits associated with the
* rule argument.
*
* @method setRule
* @param {String|Number|Object} rule Either a rule ID or a rule object
* @param {String} vendor A vendor ID string
*
* @return {Promise} This promise will resolve with a report object,
* which essentially just packages a rule object
* with an array of associated hits.
*/
function fetchByRule(rule, vendor) {
var fetched = {};
var rulePromise;
if (!angular.isObject(rule)) { // This is a bare ruleId so we need to fetch the actual rule object first
// Reports only work with vendors right now, so check that one was provided
if (!vendor) {
return $q.reject( LOCALE.maketext("You can only report [asis,ModSecurity] rules that a vendor provided.") );
}
rulePromise = ruleService.fetchRulesById(rule, vendor).then(function(response) {
fetched.rule = response.items[0]; // The length is guaranteed by the ruleService
return fetched.rule;
});
} else { // We already have the rule object, so just wrap it in a promise
// Reports only work with vendors, so check that one was provided
if (!rule.vendor_id) {
return $q.reject( LOCALE.maketext("Only [asis,ModSecurity] rules provided by vendors may be reported.") );
}
fetched.rule = rule;
var deferred = $q.defer();
deferred.resolve(rule);
rulePromise = deferred.promise;
}
currentReport = rulePromise.then(function(rule) {
return hitListService.fetchList({
filterBy: "meta_id",
filterValue: rule.id,
filterCompare: "eq"
});
}).then(function(response) {
fetched.hits = response.items;
return fetched;
});
return currentReport;
}
/**
* Returns the current report promise. This is useful when changing views/controllers.
*
* @method getCurrent
* @return {Promise} Either undefined if there is no current report promise,
* or a promise that will resolve with a report object,
* which essentially just packages a rule object with an
* array of associated hits.
*/
function getCurrent() {
return currentReport;
}
/**
* Unsets the current report so that it doesn't become stale.
* @method clearCurrent
*/
function clearCurrent() {
currentReport = void 0;
}
/**
* Generates a report but doesn't send it.
*
* @method viewReport
* @param {Object} reportParams See _generateReport documentation
*
* @return {Promise}
*/
function viewReport(reportParams) {
reportParams.send = false;
return _generateReport.call(this, reportParams);
}
/**
* Generates a report and sends it. Optionally disables the rule as well.
*
* @method sendReport
* @param {Object} reportParams See _generateReport documentation
* @param {Object} [disableParams] A set of params required for disabling the rule.
* @param {Number} disableParams.ruleId The id of the rule to be disabled.
* @param {Boolean} disableParams.deployRule Should the disable change be deployed?
* @param {String} disableParams.ruleConfig The path of the config file housing the rule.
*
* @return {Promise} Resolves when both operations are complete (or just the report, if no disableParams were given)
*/
function sendReport(reportParams, disableParams) {
var promises = {};
reportParams.send = true;
promises.report = _generateReport.call(this, reportParams);
if (disableParams) {
promises.disable = ruleService.disableRule(disableParams.ruleConfig, disableParams.ruleId, disableParams.deployRule);
}
return $q.all(promises);
}
/**
* Uses the modsec_report_rule API to either send a report or only perform a dry
* run and generate what would be sent without actually sending the payload.
*
* @method _generateReport
* @param {Object} params Contains the key/value pairs for the parameters that will be passed with the API call.
* @param {Array} params.hits An array of hit IDs that correspond to the id column in the modsec.hits table.
* @param {String} params.message A short message to accompany the report.
* @param {String} params.email The sender's email address.
* @param {String} params.reason The reason for which the report is being submitted.
* @param {Boolean} params.send If true, the generated report will be sent by the API.
*
* @return {Promise} Resolves with the raw JSON generated by the API.
*/
function _generateReport(params) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_report_rule");
angular.forEach({
row_ids: params.hits.join(","),
message: params.message,
email: params.email,
type: params.reason,
send: params.send ? 1 : 0
}, function(val, key) {
apiCall.addArgument(key, val);
});
return this.deferred(apiCall, {
transformAPISuccess: _extractReport
}).promise;
}
/**
* Extracts the report object from the response.
* @param {Object} response The response from the API.
* @return {Object} The report object.
*/
function _extractReport(response) {
return response.data.report;
}
// Set up the service's constructor and parent
var ReportService = function() {};
ReportService.prototype = new APIService();
// Extend the prototype with any class-specific functionality
angular.extend(ReportService.prototype, {
fetchByHit: fetchByHit,
fetchByRule: fetchByRule,
getCurrent: getCurrent,
clearCurrent: clearCurrent,
viewReport: viewReport,
sendReport: sendReport
});
return new ReportService();
}
]);
}
);
/*
# templates/mod_security/views/hitlistController.js Copyright(c) 2020 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* global define: false */
define(
'app/views/hitListController',[
"angular",
"cjt/util/locale",
"uiBootstrap",
"cjt/directives/responsiveSortDirective",
"cjt/decorators/paginationDecorator",
"cjt/directives/autoFocus",
"cjt/filters/wrapFilter",
"cjt/directives/spinnerDirective",
"cjt/services/alertService",
"app/services/hitlistService",
"app/services/reportService"
],
function(angular, LOCALE) {
// Retrieve the current application
var app = angular.module("App");
var controller = app.controller("hitListController", [
"$scope",
"$location",
"$anchorScroll",
"$routeParams",
"$timeout",
"hitListService",
"alertService",
"reportService",
"spinnerAPI",
"PAGE",
function(
$scope,
$location,
$anchorScroll,
$routeParams,
$timeout,
hitListService,
alertService,
reportService,
spinnerAPI,
PAGE
) {
$scope.loadingPageData = true;
$scope.activeSearch = false;
$scope.filteredData = false;
$scope.selectedRow = -1;
$scope.showAddSuccess = false;
/**
* Extracts the vendor id from a config file path.
*
* @method _getVendorFromFile
* @private
* @param {String} file The full file path to the config file.
* @return {String} The vendor id if it's a vendor config or undefined if we
* can't parse the file path properly.
*/
function _getVendorFromFile(file) {
var VENDOR_REGEX = /\/modsec_vendor_configs\/+([^/]+)/; /* TODO: EA-4700 */
var match = file && file.match(VENDOR_REGEX);
return match ? match[1] : void 0;
}
/**
* Checks to see if the given config file is the user config file.
*
* @method _isUserConfigFile
* @private
* @param {String} file The full file path to the config file.
* @return {Boolean} True if it is the user config file.
*/
function _isUserConfigFile(file) {
var USER_CONF_REGEX = /\/modsec2\.user\.conf$/; /* TODO: EA-4700 */
return USER_CONF_REGEX.test( file );
}
/**
* Passes the hit object to the report service to save a fetch and loads the view.
*
* @method loadReportview
* @param {Object} hit A hit object that corresponds to a single row from the modsec.hits table.
*/
$scope.loadReportView = function(hit) {
reportService.fetchByHit(hit);
$scope.loadView("report/hit/" + hit.id);
};
/**
* Load the edit rule view with the requested rule.
*
* @method loadEditRuleView
* @param {Number} ruleId
*/
$scope.loadEditRuleView = function(ruleId, file) {
var viewParams = {
ruleId: ruleId,
back: "hitList"
};
var vendorId;
if (_isUserConfigFile(file)) {
$scope.loadView("editCustomRule", viewParams);
} else if ( (vendorId = _getVendorFromFile(file)) ) { // Extra parens needed for jshint: http://www.jshint.com/docs/options/#boss
viewParams.vendorId = vendorId;
$scope.loadView("editCustomRule", viewParams);
} else {
alertService.add({
type: "danger",
message: LOCALE.maketext("An unknown error occurred in the attempt to retrieve the rule."),
id: "errorFetchRule"
});
}
};
/**
* Clear the search query
*/
$scope.clearFilter = function() {
$scope.meta.filterValue = "";
$scope.activeSearch = false;
$scope.filteredData = false;
// Leave history so refresh works
$location.search("api.filter.enable", 0);
$location.search("api.filter.verbose", null);
$location.search("api.filter.a.field", null);
$location.search("api.filter.a.type", null);
$location.search("api.filter.a.arg0", null);
// select the first page of search results
return $scope.selectPage(1);
};
/**
* Start a search query
*/
$scope.startFilter = function() {
$scope.activeSearch = true;
$scope.filteredData = false;
// Leave history so refresh works
$location.search("api.filter.enable", 1);
$location.search("api.filter.verbose", 1);
$location.search("api.filter.a.field", "*");
$location.search("api.filter.a.type", "contains");
$location.search("api.filter.a.arg0", $scope.meta.filterValue);
// Select the first page of search results
$scope.selectPage(1);
$scope.filteredData = true;
};
/**
* Selects a table row
* @param {Number} index The index of selected row
*/
$scope.toggleRow = function(index) {
if ( index === $scope.selectedRow ) {
// collapse the row
$scope.selectedRow = -1;
} else {
// expand the selected row
$scope.selectedRow = index;
}
};
/**
* Select a specific page
* @param {Number} [page] Optional page number, if not provided will use the current
* page provided by the scope.meta.pageNumber.
* @return {Promise}
*/
$scope.selectPage = function(page) {
// clear the selected row
$scope.selectedRow = -1;
// set the page if requested
if (page && angular.isNumber(page)) {
$scope.meta.pageNumber = page;
}
// Leave history so refresh works
$location.search("api.chunk.enable", 1);
$location.search("api.chunk.verbose", 1);
$location.search("api.chunk.size", $scope.meta.pageSize);
$location.search("api.chunk.start", ( ($scope.meta.pageNumber - 1) * $scope.meta.pageSize) + 1);
return $scope.fetch();
};
/**
* Sort the list of hits
* @param {String} sortBy Field name to sort by.
* @param {String} sortDirection Direction to sort by: asc or decs
* @param {String} [sortType] Optional sort type applied to the field. Sort type is lexical by default.
*/
$scope.sortList = function(meta, defaultSort) {
// clear the selected row
$scope.selectedRow = -1;
// Leave history so refresh works
$location.search("api.sort.enable", 1);
$location.search("api.sort.a.field", meta.sortBy);
$location.search("api.sort.a.method", meta.sortType || "");
$location.search("api.sort.a.reverse", meta.sortDirection === "asc" ? 0 : 1);
if (!defaultSort) {
$scope.fetch();
}
};
/**
* Handles the keybinding for the clearing and searching.
* Esc clears the search field.
* Enter performs a search.
*
* @method triggerToggleSearch
* @param {Event} event - The event object
*/
$scope.triggerToggleSearch = function(event) {
// clear on Esc
if (event.keyCode === 27) {
$scope.toggleSearch(true);
}
// filter on Enter
if (event.keyCode === 13) {
$scope.toggleSearch();
}
};
/**
* Toggles the clear button and conditionally performs a search.
* The expected behavior is if the user clicks the button or focuses the button and hits enter the button state rules.
*
* @param {Boolean} isClick Toggle button clicked.
*/
$scope.toggleSearch = function(isClick) {
var filter = $scope.meta.filterValue;
if ( !filter && ($scope.activeSearch || $scope.filteredData)) {
// no query in box, but we prevously filtered or there is an active search
$scope.clearFilter();
} else if (isClick && $scope.activeSearch ) {
// User clicks clear
$scope.clearFilter();
} else if (filter) {
$scope.startFilter();
}
};
/**
* Fetch the list of hits from the server
* @return {Promise} Promise that when fulfilled will result in the list being loaded with the new criteria.
*/
$scope.fetch = function() {
spinnerAPI.start("hitlistSpinner");
return hitListService
.fetchList($scope.meta)
.then(function(results) {
$scope.hitList = results.items;
$scope.totalItems = results.totalItems;
$scope.totalPages = results.totalPages;
}, function(error) {
// failure
alertService.add({
type: "danger",
message: error,
id: "errorFetchHitList"
});
})
.then(function() {
$scope.loadingPageData = false;
spinnerAPI.stop("hitlistSpinner");
});
};
// setup data structures for the view
$scope.hitList = [];
$scope.totalPages = 0;
$scope.totalItems = 0;
var routeHasPaging = $routeParams["api.chunk.enable"] === "1";
var pageSize = 10;
var page = 1;
if (routeHasPaging) {
pageSize = parseInt($routeParams["api.chunk.size"], 10);
page = Math.floor(parseInt($routeParams["api.chunk.start"], 10) / pageSize) + 1;
}
var routeHasSorting = $routeParams["api.sort.enable"] === "1";
$scope.meta = {
filterBy: $routeParams["api.filter.a.field"] || "*",
filterCompare: "contains",
filterValue: $routeParams["api.filter.a.arg0"] || "",
pageSize: routeHasPaging ? pageSize : 10,
pageNumber: routeHasPaging ? page : 1,
sortDirection: routeHasSorting ? ( $routeParams["api.sort.a.reverse"] === "1" ? "desc" : "asc" ) : "desc",
sortBy: routeHasSorting ? $routeParams["api.sort.a.field"] : "timestamp",
sortType: routeHasSorting ? $routeParams["api.sort.a.type"] : "numeric",
pageSizes: [10, 20, 50, 100]
};
// if the user types something else in the search box, we change the button icon so they can search again.
$scope.$watch("meta.filterValue", function(oldValue, newValue) {
if (oldValue === newValue) {
return;
}
$scope.activeSearch = false;
});
// watch the page size and and load the first page if it changes
$scope.$watch("meta.pageSize", function(oldValue, newValue) {
if (oldValue === newValue) {
return;
}
$scope.selectPage(1);
});
$scope.activeSearch = $scope.filteredData = $scope.meta.filterValue ? true : false;
// Setup the installed bit...
$scope.isInstalled = PAGE.installed;
// Expose any backend exceptions, ie missing database, missing table,
$scope.dbException = PAGE.hitList.metadata.result === 0 ? PAGE.hitList.metadata.reason : "";
$scope.$on("$viewContentLoaded", function() {
// check for page data in the template if this is a first load
if (app.firstLoad.hitList && PAGE.hitList) {
app.firstLoad.hitList = false;
$scope.loadingPageData = false;
var results = hitListService.prepareList(PAGE.hitList);
$scope.hitList = results.items;
$scope.totalItems = results.totalItems;
$scope.totalPages = results.totalPages;
} else {
// Otherwise, retrieve it via ajax
$timeout(function() {
// NOTE: Without this delay the spinners are not created on inter-view navigation.
$scope.selectPage(1);
});
}
});
if ($routeParams["addSuccess"]) {
$scope.showAddSuccess = true;
}
}
]);
return controller;
}
);
/*
# templates/mod_security/services/vendorService.js Copyright(c) 2020 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* global define: false */
define(
'app/services/vendorService',[
// Libraries
"angular",
// CJT
"cjt/util/locale",
"cjt/util/parse",
"cjt/io/api",
"cjt/io/whm-v1-request",
"cjt/io/whm-v1", // IMPORTANT: Load the driver so its ready
// Angular components
"cjt/services/APIService"
],
function(angular, LOCALE, PARSE, API, APIREQUEST, APIDRIVER) {
// Constants
var NO_MODULE = "";
// Fetch the current application
var app = angular.module("App");
/**
* Normalize the vendor to account for any missing data, type conversion, etc.
*
* @method _normalizeVendor
* @private
* @param {Object} vendor Vendor returned from the server.
* @return {Object} Vendor with all the fields normalized and patched.
*/
function _normalizeVendor(vendor) {
vendor.cpanel_provided = PARSE.parsePerlBoolean(vendor.cpanel_provided);
vendor.enabled = PARSE.parsePerlBoolean(vendor.enabled);
vendor.update = PARSE.parsePerlBoolean(vendor.update);
vendor.installed = PARSE.parsePerlBoolean(vendor.installed);
vendor.totalEnabled = 0;
vendor.totalDisabled = 0;
if (vendor.configs) {
for (var i = 0, l = vendor.configs.length; i < l; i++) {
var config = vendor.configs[i];
config.enabled = PARSE.parsePerlBoolean(config.active);
delete config.active;
if (config.enabled) {
vendor.totalEnabled++;
} else {
vendor.totalDisabled++;
}
}
// Sort initially by config
vendor.configs.sort(function(configA, configB) {
return configA.config.localeCompare(configB.config);
});
}
return vendor;
}
/**
* Converts the response to our application data structure
* @method _convertResponseToList
* @private
* @param {Object} response
* @return {Object} Sanitized data structure.
*/
function _convertResponseToList(response) {
var items = [];
if (response.status) {
var data = response.data;
for (var i = 0, length = data.length; i < length; i++) {
var vendor = data[i];
// Mark the record as unchanged
vendor.changed = false;
items.push(
_normalizeVendor(vendor)
);
}
var meta = response.meta;
var totalItems = meta.paginate.total_records || data.length;
var totalPages = meta.paginate.total_pages || 1;
return {
items: items,
totalItems: totalItems,
totalPages: totalPages
};
} else {
return {
items: [],
totalItems: 0,
totalPages: 0
};
}
}
/**
* Normalize the outcome for an enable/disable config operation for
* missing data, type conversion, etc.
*
* @method _normalizeOutcome
* @private
* @param {Object} outcome Outcome returned from the server.
* @param {Boolean} enableCalled true if we are trying to enable, false otherwise
* @return {Object} Outcome with all the fields normalized and patched.
*/
function _normalizeOutcome(outcome, enableCalled) {
var ok = PARSE.parsePerlBoolean(outcome.ok);
outcome.ok = ok;
outcome.enabled = enableCalled ? ok : !ok;
return outcome;
}
/**
* Cleans up the response for outcomes
*
* @method _convertOutcomeResponseToList
* @private
* @param {Array} outcomes
* @param {Boolean} enableCalled true if we are trying to enable, false otherwise
* @return {Array} Sanitized data structure.
*/
function _convertOutcomeResponseToList(data, enableCalled) {
var configs = [];
var totalEnabled = 0;
var totalDisabled = 0;
if (data) {
for (var i = 0, length = data.length; i < length; i++) {
var config = data[i];
configs.push(
_normalizeOutcome(config, enableCalled)
);
if (config.enabled) {
totalEnabled++;
} else {
totalDisabled++;
}
}
}
return {
configs: configs,
totalEnabled: totalEnabled,
totalDisabled: totalDisabled
};
}
/**
* Returns a promise with vendor information that optionally adds the vendor to the list
*
* @method _returnVendor
* @private
* @param {Deferred} deferred
* @param {String} method The API method to call.
* @param {Object} parameters Parameters for the add and preview methods
* @param {String} url Vendor URL for the YAML file describing the vendor configuration.
* @return {Promise}
*/
var _returnVendor = function(deferred, method, parameters) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, method);
apiCall.addArgument("url", parameters.url);
this.deferred(apiCall, {
transformAPISuccess: function(response) {
return response.data;
}
}, deferred);
// pass the promise back to the controller
return deferred.promise;
};
/**
* Setup the configuration models API service
*/
app.factory("vendorService", ["$q", "APIService", function($q, APIService) {
// Set up the service's constructor and parent
var VendorService = function() {};
VendorService.prototype = new APIService();
// Extend the prototype with any class-specific functionality
angular.extend(VendorService.prototype, {
/**
* Get a single vendor by its id from the backend.
*
* @method fetchVendorById
* @param {number} vendorId Id of the vendor to fetch.
* @return {Promise} Promise that will fulfill the request.
*/
fetchVendorById: function(vendorId) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_get_vendors");
apiCall.addArgument("show_uninstalled", 1);
apiCall.addFilter("vendor_id", "eq", vendorId);
var deferred = this.deferred(apiCall, {
apiSuccess: function(response, deferred) {
var results = _convertResponseToList(response);
if (results.items.length === 1) {
deferred.resolve(results.items[0]);
} else if (results.items.length > 1) {
deferred.reject(LOCALE.maketext("You have multiple vendors with the same [asis,vendor_id]."));
} else {
deferred.reject(LOCALE.maketext("The system could not find the specified [asis,vendor_id].", vendorId));
}
}
});
// pass the promise back to the controller
return deferred.promise;
},
/**
* Get a list of vendors
* * @param {object} meta Optional meta data to control sorting, filtering and paging
* @param {string} meta.sortBy Name of the field to sort by
* @param {string} meta.sordDirection asc or desc
* @param {string} meta.sortType Optional name of the sort rule to apply to the sorting
* @param {string} meta.filterBy Name of the field to filter by
* @param {string} meta.filterCompare Optional comparator to use when comparing for filter.
* @param {string} meta.filterValue Expression/argument to pass to the compare method.
* @param {string} meta.pageNumber Page number to fetch.
* @param {string} meta.pageSize Size of a page, will default to 10 if not provided.
* @return {Promise} Promise that will fulfill the request.
*/
fetchList: function(meta) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_get_vendors");
apiCall.addArgument("show_uninstalled", 1);
if (meta) {
if (meta.sortBy && meta.sortDirection) {
apiCall.addSorting(meta.sortBy, meta.sortDirection, meta.sortType);
}
if (meta.pageNumber) {
apiCall.addPaging(meta.pageNumber, meta.pageSize || 10);
}
if (meta.filterBy && meta.filterValue) {
apiCall.addFilter(meta.filterBy, meta.filterCompare, meta.filterValue);
}
}
return this.deferred(apiCall, {
transformAPISuccess: _convertResponseToList
}).promise;
},
/**
* Disable a vendor by id
*
* @method disableVendor
* @param {Number} id Vendor id.
* @return {Promise}
*/
disableVendor: function(id) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_disable_vendor");
apiCall.addArgument("vendor_id", id);
return this.deferred(apiCall).promise;
},
/**
* Enable a vendor by id
*
* @method enableRule
* @param {Number} id Vendor id.
* @return {Promise}
*/
enableVendor: function(id) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_enable_vendor");
apiCall.addArgument("vendor_id", id);
return this.deferred(apiCall).promise;
},
/**
* Disable a config file by path
*
* @method disableConfig
* @param {String} config Path to the specific config file.
* @return {Promise}
*/
disableConfig: function(config) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_make_config_inactive");
apiCall.addArgument("config", config);
return this.deferred(apiCall).promise;
},
/**
* Enable a config file by path
*
* @method disableConfig
* @param {String} config Path to the specific config file.
* @return {Promise}
*/
enableConfig: function(config) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_make_config_active");
apiCall.addArgument("config", config);
return this.deferred(apiCall).promise;
},
/**
* Enable all the config files for a vendor
*
* @method enableAllConfigs
* @param {String} id Vendor id.
* @return {Promise}
*/
enableAllConfigs: function(id) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_enable_vendor_configs");
apiCall.addArgument("vendor_id", id);
var deferred = this.deferred(apiCall, {
transformAPISuccess: function(response) {
return _convertOutcomeResponseToList(response.data, true);
},
transformAPIFailure: function(response) {
return _convertOutcomeResponseToList(response.data, true);
}
});
return deferred.promise;
},
/**
* Disable all the config files for a vendor
*
* @method disableAllConfigs
* @param {String} id Vendor id.
* @return {Promise}
*/
disableAllConfigs: function(id) {
// make a promise
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_disable_vendor_configs");
apiCall.addArgument("vendor_id", id);
var deferred = this.deferred(apiCall, {
transformAPISuccess: function(response) {
return _convertOutcomeResponseToList(response.data, false);
},
transformAPIFailure: function(response) {
return _convertOutcomeResponseToList(response.data, false);
}
});
// pass the promise back to the controller
return deferred.promise;
},
/**
* Enable automatic updates for a vendor
*
* @method enableVendorUpdates
* @param {String} id Vendor id.
* @return {Promise}
*/
enableVendorUpdates: function(id) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_enable_vendor_updates");
apiCall.addArgument("vendor_id", id);
var deferred = this.deferred(apiCall);
return deferred.promise;
},
/**
* Disable automatic updates for a vendor
*
* @method disableVendorUpdates
* @param {String} id Vendor id.
* @return {Promise}
*/
disableVendorUpdates: function(id) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_disable_vendor_updates");
apiCall.addArgument("vendor_id", id);
var deferred = this.deferred(apiCall);
return deferred.promise;
},
/**
* Remove a vendor from the system by its id
*
* @method deleteVendor
* @param {Number} id Vendor id for the vendor to delete.
* @return {Promise} Promise that will fulfill the request.
*/
deleteVendor: function(id) {
var apiCall = new APIREQUEST.Class();
apiCall.initialize(NO_MODULE, "modsec_remove_vendor");
apiCall.addArgument("vendor_id", id);
var deferred = this.deferred(apiCall);
return deferred.promise;
},
/**
* Retrieves vendor information from a remote URL containing configuration information
* stored in a YAML format.
*
* @method loadVendor
* @param {String} url Vendor URL for the YAML file describing the vendor configuration.
* @return {Promise} Promise that will fulfill the request.
*/
loadVendor: function(url) {
// make a promise
var deferred = $q.defer(),
parameters = {
url: url
};
// pass the promise back to the controller
return _returnVendor.call(this, deferred, "modsec_preview_vendor", parameters);
},
/**
* Adds a vendor configuration to the list of vendors
*
* @method saveVendor
* @param {String} url Vendor URL for the YAML file describing the vendor configuration.
* @return {Promise} Promise that will fulfill the request.
*/
saveVendor: function(url) {
// make a promise
var deferred = $q.defer(),
parameters = {
url: url,
};
// pass the promise back to the controller
return _returnVendor.call(this, deferred, "modsec_add_vendor", parameters);
},
/**
* Helper method that calls _convertResponseToList to prepare the data structure
*
* @method prepareList
* @param {Object} response
* @return {Object} Sanitized data structure.
*/
prepareList: function(response) {
// Since this is coming from the backend, but not through the api.js layer,
// we need to parse it to the frontend format.
response = APIDRIVER.parse_response(response).parsedResponse;
return _convertResponseToList(response);
}
});
return new VendorService();
}]);
}
);
/*
# mod_security/views/rulelistController.js Copyright(c) 2020 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* global define */
define(
'app/views/rulesListController',[
"angular",
"lodash",
"cjt/util/locale",
"cjt/util/logic",
"uiBootstrap",
"cjt/directives/responsiveSortDirective",
"cjt/decorators/paginationDecorator",
"cjt/directives/autoFocus",
"cjt/filters/wrapFilter",
"cjt/filters/splitFilter",
"cjt/filters/htmlFilter",
"cjt/directives/spinnerDirective",
"cjt/directives/actionButtonDirective",
"cjt/services/alertService",
"app/services/ruleService",
"app/services/vendorService",
"cjt/io/whm-v1-querystring-service",
],
function(angular, _, LOCALE, LOGIC) {
"use strict";
var USER_CONFIG = "modsec2.user.conf"; /* TODO: EA-4700 */
var STATUS_ENUM = {
ENABLED: "enabled",
DISABLED: "disabled",
BOTH: "both",
};
var PUBLISHED_ENUM = {
DEPLOYED: "deployed",
STAGED: "staged",
BOTH: "both",
};
// Retrieve the current application
var app = angular.module("App");
var controller = app.controller(
"rulesListController", [
"$scope",
"$location",
"$anchorScroll",
"$timeout",
"ruleService",
"vendorService",
"alertService",
"spinnerAPI",
"queryService",
"PAGE",
function(
$scope,
$location,
$anchorScroll,
$timeout,
ruleService,
vendorService,
alertService,
spinnerAPI,
queryService,
PAGE) {
$scope.loadingPageData = true;
$scope.activeSearch = false;
$scope.filteredData = false;
$scope.advancedSearchApplied = false;
/**
* Update the advanced search applied flag by checking if the advanced search flags are
* in their default state or not.
*
* @private
* @method _updateAdvancedSearchApplied
* @return {Boolean} true if there is an advanced search, false otherwise
*/
function _updateAdvancedSearchApplied() {
// See if we have an advanced search different from the default
if ($scope.meta.advanced.includeUserRules !== true ||
$scope.meta.advanced.showEnabledDisabled !== STATUS_ENUM.BOTH ||
$scope.meta.advanced.showStagedDeployed !== PUBLISHED_ENUM.BOTH) {
$scope.advancedSearchApplied = true;
} else {
$scope.advancedSearchApplied = false;
}
// Also update the indicator at this point.
$scope.appliedIncludeUserRules = $scope.meta.advanced.includeUserRules;
}
/**
* Update the previous state information for rollback should the fetch fail.
* @private
* @method _updatePreviousState
*/
function _updatePreviousState() {
$scope.previouslySelected = $scope.selectedVendors;
$scope.meta.advanced.previousShowStagedDeployed = $scope.meta.advanced.showStagedDeployed;
$scope.meta.advanced.previousShowEnabledDisabled = $scope.meta.advanced.showEnabledDisabled;
$scope.meta.advanced.previousIncludeUserRules = $scope.meta.advanced.includeUserRules;
}
/**
* Revert to the previous advanced filter setting.
* @private
* @method _revertToPreviousState
*/
function _revertToPreviousState() {
$scope.selectedVendors = $scope.previouslySelected;
$scope.meta.advanced.showStagedDeployed = $scope.meta.advanced.previousShowStagedDeployed;
$scope.meta.advanced.showEnabledDisabled = $scope.meta.advanced.previousShowEnabledDisabled;
$scope.meta.advanced.includeUserRules = $scope.meta.advanced.previousIncludeUserRules;
$scope.meta.advanced.changed = false;
}
/**
* Apply the advanced search filters to the query-string.
*
* @method _addAdvancedSearchToQuery
* @private
*/
function _addAdvancedSearchToQuery() {
if ($scope.meta.advanced.showStagedDeployed === PUBLISHED_ENUM.STAGED) {
queryService.query.addSearchField("c", "staged", "eq", "1");
} else {
queryService.query.clearSearchField("staged", "eq", "1");
}
if ($scope.meta.advanced.showStagedDeployed === PUBLISHED_ENUM.DEPLOYED) {
queryService.query.addSearchField("b", "staged", "eq", "0");
} else {
queryService.query.clearSearchField("staged", "eq", "0");
}
if ($scope.meta.advanced.showEnabledDisabled === STATUS_ENUM.ENABLED) {
queryService.query.addSearchField("d", "disabled", "eq", "0");
} else {
queryService.query.clearSearchField("disabled", "eq", "0");
}
if ($scope.meta.advanced.showEnabledDisabled === STATUS_ENUM.DISABLED) {
queryService.query.addSearchField("e", "disabled", "eq", "1");
} else {
queryService.query.clearSearchField("disabled", "eq", "1");
}
if (!$scope.meta.advanced.includeUserRules) {
queryService.query.removeParameter("config");
} else {
queryService.query.addParameter("config", USER_CONFIG);
}
var vendors = _getSelectedVendorIDs();
if (vendors.length === 0) {
queryService.query.removeParameter("vendor_id");
} else {
queryService.query.addParameter("vendor_id", vendors.join(","));
}
}
/**
* Event handler triggered when one of the advanced filter options is changed.
*
* @method onAdvancedChanged
* @param {String} type
*/
$scope.onAdvancedChanged = function(type) {
switch (type) {
case "vendor":
if ( $scope.previouslySelected !== $scope.selectedVendors ) {
$scope.meta.advanced.changed = true;
}
break;
case "userDefined":
if ($scope.meta.advanced.previousIncludeUserRules !== $scope.meta.advanced.includeUserRules) {
$scope.meta.advanced.changed = true;
}
break;
default:
$scope.meta.advanced.changed = true;
}
};
/**
* Check if there are any search criteria applied. This includes various advanced search
* criteria different then their defaults or any unselected vendors.
*
* @method hasSearchFilter
* @return {Boolean} true if the is an active search, false otherwise.
*/
$scope.hasSearchFilter = function() {
return $scope.filteredData === true ||
$scope.advancedSearchApplied === true ||
$scope.appliedVendors.length < $scope.vendors.length;
};
/**
* Clear the search query
*
* @method clearFilter
* @returns Promise
*/
$scope.clearFilter = function() {
$scope.meta.filterValue = "";
$scope.activeSearch = false;
$scope.filteredData = false;
queryService.query.clearSearchField("*", "contains");
_addAdvancedSearchToQuery();
// select the first page of search results
return $scope.selectPage(1);
};
/**
* Start a search query
*
* @method startFilter
* @returns Promise
*/
$scope.startFilter = function() {
$scope.activeSearch = ($scope.meta.filterValue !== "");
$scope.filteredData = false;
// Leave history so refresh works
if ($scope.meta.filterValue) {
queryService.query.addSearchField("a", "*", "contains", $scope.meta.filterValue);
} else {
queryService.query.clearSearchField("*", "contains");
}
return $scope.selectPage(1)
.then(function() {
_addAdvancedSearchToQuery();
if ($scope.meta.filterValue) {
$scope.filteredData = true;
}
_updatePreviousState();
$scope.meta.advanced.changed = false;
}, function() {
// Revert to the previous state
_revertToPreviousState();
}).finally(function() {
_updateAdvancedSearchApplied();
});
};
/**
* Open the advanced search menu from another button.
*
* @method openAdvancedSearch
* @param {Event} $event The jQlite event
*/
$scope.openAdvancedSearch = function($event) {
$event.preventDefault();
$event.stopPropagation();
$event.currentTarget.blur();
$scope.advancedSearchOpen = !$scope.advancedSearchOpen;
};
/**
* Apply the advanced filter and close the dropdown
*
* @method applyAdvancedFilter
* @param {Event} $event
*/
$scope.applyAdvancedFilter = function($event) {
$event.preventDefault();
$event.stopPropagation();
$scope.advancedSearchOpen = false;
$scope.toggleSearch();
};
/**
* Reset the advanced filter and close the dropdown
*
* @method resetAdvancedFilter
* @param {Event} $event
*/
$scope.resetAdvancedFilter = function($event) {
$event.preventDefault();
$event.stopPropagation();
$scope.advancedSearchOpen = false;
$scope.resetFilter();
};
/**
* Update the applied vendor field.
*
* @private
* @method _updateAppliedVendor
*/
function _updateAppliedVendor() {
$scope.appliedVendors = $scope.selectedVendors.slice();
}
/**
* Reset the advanced filter and apply
*/
$scope.resetFilter = function() {
$scope.meta.advanced.changed = false;
if ($scope.meta.advanced.showStagedDeployed !== PUBLISHED_ENUM.BOTH) {
$scope.meta.advanced.showStagedDeployed = PUBLISHED_ENUM.BOTH;
$scope.meta.advanced.changed = true;
}
if ($scope.meta.advanced.showEnabledDisabled !== STATUS_ENUM.BOTH) {
$scope.meta.advanced.showEnabledDisabled = STATUS_ENUM.BOTH;
$scope.meta.advanced.changed = true;
}
if (!$scope.meta.advanced.includeUserRules) {
$scope.meta.advanced.includeUserRules = true;
$scope.meta.advanced.changed = true;
}
if ($scope.selectedVendors.length < $scope.vendors.length) {
$scope.selectedVendors = $scope.vendors;
$scope.meta.advanced.changed = true;
}
if ($scope.meta.advanced.changed) {
$scope.startFilter().then(_updateAppliedVendor);
}
};
/**
* Handles the keybinding for the clearing and searching.
* Esc clears the search field.
* Enter performs a search.
*
* @method triggerToggleSearch
* @param {Event} event - The event object
*/
$scope.triggerToggleSearch = function(event) {
// clear on Esc
if (event.keyCode === 27) {
$scope.toggleSearch(true);
}
// filter on Enter
if (event.keyCode === 13) {
$scope.toggleSearch();
}
};
/**
* Toggles the clear button and conditionally performs a search.
* The expected behavior is if the user clicks the button or focuses the button and hits enter the button state rules.
*
* @method toggleSearch
* @scope
* @param {Boolean} isClick Toggle button clicked.
*/
$scope.toggleSearch = function(isClick) {
var filter = $scope.meta.filterValue;
var advancedChanged = $scope.meta.advanced.changed;
if ( (!filter && !advancedChanged && ($scope.activeSearch || $scope.filteredData))) {
// no query in box, but we previously filtered or there is an active search
$scope.clearFilter().then(_updateAppliedVendor);
} else if (isClick && $scope.activeSearch ) {
// User clicks clear
$scope.clearFilter().then(_updateAppliedVendor);
} else if (filter || advancedChanged) {
$scope.startFilter().then(_updateAppliedVendor);
}
};
/**
* Select a specific page of rules
*
* @method selectPage
* @param {Number} [page] Optional page number, if not provided will use the current
* page provided by the scope.meta.pageNumber.
* @return {Promise}
*/
$scope.selectPage = function(page) {
// set the page if requested
if (page && angular.isNumber(page)) {
$scope.meta.pageNumber = page;
}
queryService.query.updatePagination($scope.meta.pageNumber, $scope.meta.pageSize);
return $scope.fetch();
};
/**
* Sort the list of rules
*
* @param {Object} meta The sort model.
* @param {Boolean} defaultSort If true, the sort was not not initiated by the user
*/
$scope.sortList = function(meta, defaultSort) {
queryService.query.clearSort();
queryService.query.addSortField(meta.sortBy, meta.sortType, meta.sortDirection);
if (!defaultSort) {
$scope.fetch();
}
};
/**
* Disables a rule from the list using the rule service
*
* @method disable
* @param {Object} rule The rule to disable
* @return {Promise}
*/
$scope.disable = function(rule) {
var ruleIndentifier = rule.id || "rule";
// if message is defined append it to the rule identifier
if ( rule.hasOwnProperty("meta_msg") && rule.meta_msg !== "" ) {
ruleIndentifier += ": " + rule.meta_msg;
}
return ruleService
.disableRule(rule.config, rule.id, false)
.then(function() {
// success
rule.disabled = true;
rule.staged = true;
$scope.stagedChanges = true;
alertService.add({
type: "success",
message: LOCALE.maketext("You successfully disabled “[_1]” in the list of [asis,ModSecurity™] rules.", _.escape(ruleIndentifier)),
id: "alertDisableSuccess",
});
}, function(error) {
// failure
alertService.add({
type: "danger",
message: _.escape(error),
id: "errorDisablingRule",
});
});
};
/**
* Enables a rule from the list using the rule service
*
* @method enable
* @param {Object} rule The rule to enable
* @return {Promise}
*/
$scope.enable = function(rule) {
var ruleIndentifier = rule.id || "rule";
// if message is defined append it to the rule identifier
if ( rule.hasOwnProperty("meta_msg") && rule.meta_msg !== "" ) {
ruleIndentifier += ": " + rule.meta_msg;
}
return ruleService
.enableRule(rule.config, rule.id, false)
.then(function() {
// success
rule.disabled = false;
rule.staged = true;
$scope.stagedChanges = true;
alertService.add({
type: "success",
message: LOCALE.maketext("You successfully enabled “[_1]” in the list of [asis,ModSecurity™] rules.", _.escape(ruleIndentifier)),
id: "alertEnableSuccess",
});
}, function(error) {
// failure
alertService.add({
type: "danger",
message: _.escape(error),
id: "errorEnablingRule",
});
});
};
/**
* Deletes a rule from the list using the rule service
*
* @method delete
* @param {Object} rule The rule to delete
* @return {Promise}
*/
$scope.delete = function(rule) {
var ruleIndentifier = rule.id || "rule";
// if message is defined append it to the rule identifier
if ( rule.hasOwnProperty("meta_msg") && rule.meta_msg !== "" ) {
ruleIndentifier += ": " + rule.meta_msg;
}
rule.deleting = true;
return ruleService
.deleteRule(rule.id)
.then(function() {
// success
$scope.fetch();
alertService.add({
type: "success",
message: LOCALE.maketext("You successfully deleted “[_1]” from the list of [asis,ModSecurity™] rules.", _.escape(ruleIndentifier)),
id: "alertDeleteSuccess",
});
}, function(error) {
rule.deleting = false;
// reset delete confirmation
rule.showDeleteConfirm = false;
// failure
alertService.add({
type: "danger",
message: _.escape(error),
id: "errorDeletingRule",
});
});
};
/**
* Deploys staged rules using the rule service
*
* @method deployChanges
* @return {Promise}
*/
$scope.deployChanges = function() {
$scope.pendingChanges = true;
return ruleService
.deployQueuedRules()
.then(function() {
// success
$scope.stagedChanges = false;
alertService.add({
type: "success",
message: LOCALE.maketext("You successfully deployed the staged changes and [asis,Apache] received a graceful restart request."),
id: "successDeployChanges",
});
}, function(error) {
// failure
alertService.add({
type: "danger",
message: _.escape(error),
id: "errorDeployChanges",
});
}).finally(function() {
$scope.pendingChanges = false;
$scope.fetch();
});
};
/**
* Discards staged rule changes using the rule service
*
* @method discardChanges
* @return {Promise}
*/
$scope.discardChanges = function() {
$scope.pendingChanges = true;
return ruleService
.discardQueuedRules()
.then(function() {
var replace = false;
// discard changes success
$scope.stagedChanges = false;
return $scope.fetch().then(function() {
// fetch success
replace = true;
}, function() {
// fetch failure
replace = false;
}).finally(function() {
// display discard changes success
$scope.discardConfirm = false;
alertService.add({
type: "success",
message: LOCALE.maketext("You successfully discarded the staged changes."),
id: "successDiscardingChanges",
replace: replace,
});
});
}, function(error) {
$scope.fetch(); // To update the list to match the new state.
// discard changes failure
alertService.add({
type: "danger",
message: _.escape(error),
id: "errorDiscardingChanges",
});
}).finally(function() {
$scope.pendingChanges = false;
});
};
/**
* Initialize the selected vendors
*
* @private
* @method initializeSelectedVendors
*/
var _initializeSelectedVendors = function() {
var vendorIds = [];
var vendor_id_param = queryService.route.getParameter("vendor_id");
if (vendor_id_param) {
vendorIds = vendor_id_param.split(",");
}
var config_param = queryService.route.getParameter("config");
var vendors = [];
if (!angular.isDefined(vendor_id_param) && !angular.isDefined(config_param)) {
// This is a default load, so select all vendors
vendors = $scope.vendors;
} else if (vendorIds.length > 0) {
// Some vendors were passed on the querystring, so use those
vendors = _.filter($scope.vendors, function(vendor) {
// find the vendors by ids from the list of vendorIds
var id = _.find(vendorIds, function(id) {
return vendor.vendor_id === id;
});
return !!id;
});
}
$scope.previouslySelected = $scope.selectedVendors = vendors;
// Also update the applied vendors so the ui is updated on load.
$scope.appliedVendors = vendors.slice();
};
/**
* Determines if a rule is from a custom set
*
* @method isCustomVendor
* @param {Object} rule The rule to read vendor id from
* @return {Boolean} Returns true if the rule is from a custom set
*/
$scope.isCustomVendor = function(rule) {
return rule.hasOwnProperty("vendor_id") && rule.vendor_id === "";
};
/**
* Returns the full vendor name for the supplied rule
*
* @method getVendorName
* @param {Object} rule The rule to read vendor id from
* @return {String} The full vendor name
*/
$scope.getVendorName = function(rule) {
var currentVendor;
if ( rule.vendor_id !== "" ) {
for ( var i = 0, length = $scope.vendors.length; i < length; i++ ) {
currentVendor = $scope.vendors[i];
if ( rule.vendor_id === currentVendor.vendor_id ) {
return currentVendor.name;
}
}
}
return LOCALE.maketext("Custom");
};
/**
* Get a list of the enabled vendors
*
* @method _onlyEnabledVendors
* @private
* @param {Array} vendor A list of vendors
* @return {Array} A list of the vendors that were enabled
*/
function _onlyEnabledVendors(vendors) {
if (vendors && angular.isArray(vendors)) {
return vendors.filter( function(vendor) {
return vendor.enabled;
});
} else {
return [];
}
}
/**
* Retrieve the list of vendors
*
* @method getVendors
* @return {Promise} Promise that when fulfilled will result in the list being loaded with the new criteria.
*/
$scope.getVendors = function() {
spinnerAPI.start("ruleListSpinner");
return vendorService
.fetchList()
.then(function(results) {
if (angular.isArray(results.items)) {
$scope.vendors = _onlyEnabledVendors(results.items);
_initializeSelectedVendors();
} else {
alertService.add({
message: "The system was unable to retrieve the list of available vendors.",
type: "danger",
});
}
}, function(error) {
// failure
alertService.add({
type: "danger",
message: _.escape(error),
id: "errorLoadingVendorList",
});
}).finally(function() {
spinnerAPI.stop("ruleListSpinner");
});
};
/**
* Performs final prep work on the selectedVendors list.
* Extracts the vendor ids into an array.
*
* @method _getSelectedVendorIDs
* @return {Array} A list of vendor ids that is ready for consumption by the ruleService
*/
function _getSelectedVendorIDs() {
return $scope.selectedVendors && $scope.selectedVendors.map(function(vendor) {
return vendor.vendor_id;
});
}
/**
* Fetch the list of rules from the server
* @method fetch
* @return {Promise} Promise that when fulfilled will result in the list being loaded with the new criteria.
*/
$scope.fetch = function() {
$scope.loadingPageData = true;
spinnerAPI.start("ruleListSpinner");
alertService.removeById("errorFetchRulesList");
return ruleService
.fetchRulesList(_getSelectedVendorIDs(), $scope.meta)
.then(function(results) {
$scope.rules = results.items;
$scope.stagedChanges = results.stagedChanges;
$scope.totalItems = results.totalItems;
$scope.totalPages = results.totalPages;
}, function(error) {
// failure
alertService.add({
type: "danger",
message: _.escape(error),
id: "errorFetchRulesList",
});
// throw an error for chained promises
throw error;
}).finally(function() {
$scope.loadingPageData = false;
spinnerAPI.stop("ruleListSpinner");
});
};
/**
* Generates the text for the vendor/user-defined indicator button.
* This is needed because we need to dynamically determine plurality of phrases.
*
* @method generateIndicatorText
* @param {String} type The type of vendor count to generate (e.g. "short" or "long")
* @return {String} The formatted vendor count string
*/
$scope.generateIndicatorText = function(type) {
switch (type) {
case "vendor-short":
return $scope.appliedVendors.length;
case "vendor-long":
return LOCALE.maketext("[quant,_1,Vendor,Vendors]", $scope.appliedVendors.length);
case "vendor-title":
return $scope.generateVendorTitle();
case "user-title":
return $scope.meta.advanced.previousIncludeUserRules ?
LOCALE.maketext("Your user-defined rules are included below.") :
LOCALE.maketext("Your user-defined rules are not included below.");
default:
return LOCALE.maketext("Loading …");
}
};
/**
* Generates the text for the title/tooltip that displays when a user hovers over the rule set count.
*
* @method generateVendorTitle
* @return {String} The text for the tooltip
*/
$scope.generateVendorTitle = function() {
var vendors = $scope.appliedVendors;
if (vendors.length === 0) {
return LOCALE.maketext("You have not selected any vendor rule sets.");
}
var vendorNames = vendors.map(function(vendor) {
return vendor.name;
});
return LOCALE.maketext("The displayed rules are from the following vendor rule [numerate,_1,set,sets]: [list_and,_2]", vendors.length, vendorNames);
};
/**
* Sets the left property so that the dropdown lines up with the input group
*
* @method _setMenuLeft
* @private
* @param {Element} menu The dropdown menu element
* @param {Number} groupWidth The width of the input group in pixels
*/
function _setMenuLeft(menu, groupWidth) {
menu.css("left", -1 * groupWidth);
menu.css("right", "auto");
}
/**
* Unsets the left and right properties so that they reset back to the CSS defaults
*
* @method _setMenuRight
* @private
* @param {Element} menu The dropdown menu element
*/
function _setMenuRight(menu) {
menu.css("left", "");
menu.css("right", "");
}
/**
* Adjusts the position of the dropdown menu depending on whether or not it is being
* clipped by the edge of the viewport.
*
* @method fixMenuClipping
* @param {Event} event The associated event object
*/
$scope.fixMenuClipping = function(event) {
var menu = this.find(".advanced-filter-menu");
var inputGroup = this.siblings("input");
var groupWidth = inputGroup.outerWidth();
// This keeps the menu from flying around while still allowing offset to work
if (event.type === "open") {
menu.css("opacity", 0);
}
$timeout(function() { // We need to queue this up after $digest or the dropdown won't be visible yet and the offset will be incorrect
if (menu) {
switch (event.type) {
case "resize":
if (menu.offset().left < 0) {
_setMenuLeft(menu, groupWidth);
} else if (groupWidth > menu.outerWidth()) { // If the menu isn't clipping, it could be because we fixed it already or because it fits on the page
_setMenuRight(menu);
}
break;
case "open":
if (menu.offset().left < 0) {
_setMenuLeft(menu, groupWidth);
}
break;
case "close":
_setMenuRight(menu);
break;
}
menu.css("opacity", 1);
}
}, 0, false);
};
// setup data structures for the view
$scope.rules = [];
$scope.vendors = [];
$scope.appliedVendors = []; // Differs from the selectedVendors in that this is only populated once the settings have been applied
$scope.totalPages = 0;
$scope.totalItems = 0;
var pageSize = queryService.route.getPageSize(queryService.DEFAULT_PAGE_SIZE);
var page = queryService.route.getPage(pageSize, 1);
var sorting = queryService.route.getSortProperties("disabled", "", "asc");
/**
* Determin if we should check the includeUserRules advanced search option.
* @note if neither the config or vendor_id is set in the querystring, then default to
* showing the custom config to match the default prefetch rules.
* @return {Boolean}
*/
function _includeUserRules() {
var config = queryService.route.getParameter("config");
var vendor_id = queryService.route.getParameter("vendor_id");
if (config !== USER_CONFIG && !vendor_id) {
return true; // We default to showing custom rules
}
return config === USER_CONFIG;
}
var staged = LOGIC.compareOrDefault(queryService.route.getSearchFieldValue("staged"), "1", true);
var deployed = LOGIC.compareOrDefault(queryService.route.getSearchFieldValue("staged"), "0", true);
var disabled = LOGIC.compareOrDefault(queryService.route.getSearchFieldValue("disabled"), "1", true);
var enabled = LOGIC.compareOrDefault(queryService.route.getSearchFieldValue("disabled"), "0", true);
$scope.meta = {
filterBy: "*",
filterCompare: "contains",
filterValue: "",
pageSize: pageSize,
pageNumber: page,
sortBy: sorting.field,
sortType: sorting.type,
sortDirection: sorting.direction,
pageSizes: [10, 20, 50, 100],
advanced: {
showStagedDeployed: LOGIC.translateBinaryAndToState(staged, deployed, PUBLISHED_ENUM.BOTH, PUBLISHED_ENUM.STAGED, PUBLISHED_ENUM.DEPLOYED, PUBLISHED_ENUM.BOTH),
showEnabledDisabled: LOGIC.translateBinaryAndToState(enabled, disabled, STATUS_ENUM.BOTH, STATUS_ENUM.ENABLED, STATUS_ENUM.DISABLED, STATUS_ENUM.BOTH),
includeUserRules: _includeUserRules(),
changed: false,
},
};
$scope.appliedIncludeUserRules = $scope.meta.advanced.includeUserRules;
_updatePreviousState();
$scope.activeSearch = $scope.filteredData = $scope.meta.filterValue ? true : false;
// if the user types something else in the search box, we change the button icon so they can search again.
$scope.$watch("meta.filterValue", function(oldValue, newValue) {
if (oldValue === newValue) {
return;
}
$scope.activeSearch = false;
});
// watch the page size and and load the first page if it changes
$scope.$watch("meta.pageSize", function(oldValue, newValue) {
if (oldValue === newValue) {
return;
}
$scope.selectPage(1);
});
// Setup the installed bit...
$scope.isInstalled = PAGE.installed;
if (!$scope.isInstalled) {
// redirect to the historic view of the hit list if mod_security is not installed
$scope.loadView("hitList");
}
$scope.$on("$viewContentLoaded", function() {
// check for page data in the template if this is a first load
if (app.firstLoad.rules && PAGE.rules) {
app.firstLoad.rules = false;
$scope.loadingPageData = false;
$scope.advancedSearchOpen = false;
var vendors = vendorService.prepareList(PAGE.vendors);
// In the rules list page, we only care about
// searching for rules from enabled vendors.
$scope.vendors = _onlyEnabledVendors(vendors.items);
_initializeSelectedVendors();
var rules = ruleService.prepareList(PAGE.rules);
$scope.rules = rules.items;
$scope.stagedChanges = rules.stagedChanges;
$scope.totalItems = rules.totalItems;
$scope.totalPages = rules.totalPages;
if ( !rules.status ) {
// on view load in an error state give the user a chance to discard staged changes
$scope.stagedChanges = true;
$scope.loadingPageData = "error";
alertService.add({
type: "danger",
message: LOCALE.maketext("There was a problem loading the page. The system is reporting the following error: [_1].", _.escape(PAGE.rules.metadata.reason)),
id: "errorFetchRulesList",
});
}
} else {
// Otherwise, retrieve it via ajax
$timeout(function() {
// NOTE: Without this delay the spinners are not created on inter-view navigation.
$scope.getVendors().then(function() {
$scope.selectPage(1);
});
});
}
});
},
]);
return controller;
}
);
/*
# templates/mod_security/views/addRuleController.js Copyright(c) 2020 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* global define: false */
define(
'app/views/addRuleController',[
"angular",
"lodash",
"jquery",
"cjt/util/locale",
"cjt/util/parse",
"uiBootstrap",
"cjt/directives/autoFocus",
"cjt/directives/spinnerDirective",
"cjt/services/alertService",
"app/services/ruleService",
],
function(angular, _, $, LOCALE, PARSE) {
"use strict";
// Retrieve the current application
var app = angular.module("App");
var controller = app.controller(
"addRuleController",
["$scope", "$location", "$anchorScroll", "$routeParams", "$q", "spinnerAPI", "alertService", "ruleService", "PAGE",
function($scope, $location, $anchorScroll, $routeParams, $q, spinnerAPI, alertService, ruleService, PAGE) {
/**
* Disable the save button based on form state
*
* @method disableSave
* @param {FormController} form
* @return {Boolean}
*/
$scope.disableSave = function(form) {
return (form.rule.$pristine && form.enabled.$pristine) || (form.$dirty && form.$invalid);
};
/**
* Clear the form
*
* @method clearForm
*/
$scope.clearForm = function() {
$scope.enabled = true;
$scope.rule = "";
$scope.deploy = false;
$scope.clearNotices();
};
/**
* Clear the notices
*
* @method clearNotices
*/
$scope.clearNotices = function() {
alertService.clear();
$scope.notice = "";
};
/**
* Navigate to the previous view.
*
* @method cancel
*/
$scope.cancel = function() {
$scope.clearNotices();
$scope.loadView("rulesList");
};
/**
* Save the form and navigate or clean depending on the users choices.
*
* @method save
* @param {FormController} form
* @param {Boolean} exit If true, will navigate back on completion, if false,
* will clear the form and let you add another rule.
* @return {Promise}
*/
$scope.save = function(form, exit) {
$scope.clearNotices();
if (!form.$valid) {
return;
}
spinnerAPI.start("loadingSpinner");
return ruleService
.addRule($scope.rule, $scope.enabled, $scope.deploy)
.then(
/**
* Handle successfully adding the rule
* @method success
* @private
* @param {Rule} rule Rule added to the system
*/
function success(rule) {
$scope.clearNotices();
form.$setPristine();
spinnerAPI.stop("loadingSpinner");
$scope.clearForm();
alertService.add({
type: "success",
message: LOCALE.maketext("You have successfully saved your [asis,ModSecurity™] rule with the following ID: [_1].", _.escape(rule.id)),
id: "alertAddSuccess",
replace: true,
});
if (exit) {
$scope.loadView("rulesList");
} else {
if ( $scope.isCopy ) {
$scope.getClonedRule(rule);
}
// refocus the user for the next add
$scope.scrollTo("top");
document.getElementById("txtRuleText").focus();
}
},
/**
* Handle failure of adding the rule
* @method failure
* @private
* @param {Object} error Error from the backend.
* @param {String} message
* @param {Boolean} duplicate true if this is a duplicate queue item, false otherwise.
*/
function failure(error) {
$scope.notice = "";
if (error && error.duplicate) {
alertService.add({
type: "warning",
message: LOCALE.maketext("There is a duplicate [asis,ModSecurity™] rule in the staged configuration file. You cannot add a duplicate rule."),
id: "alertAddWarning",
});
} else {
var message = error.message || error; // It can come from either structured or unstructured errors
alertService.add({
type: "danger",
message: _.escape(message),
id: "alertAddFailure",
});
}
// ensure the error is in view and focus is in the rule field
$scope.scrollTo("top");
document.getElementById("txtRuleText").focus();
},
/**
* Handle step wise updating
* @method notify
* @private
* @param {Rule} rule Rule added to the system
*/
function notify(notice) {
$scope.notice += notice + "\n";
}
).finally(function() {
spinnerAPI.stop("loadingSpinner");
});
};
/**
* Disable the rule that
*
* @method disableOriginalRule
* @param {object} rule The original rule to be disabled
* @param {string} rule.id The id of the original rule
* @param {string} rule.config The configuration file of the original file
* @param {Boolean} rule.disabled Is the rule disabled?
* @return {Promise}
*/
$scope.disableOriginalRule = function(rule) {
return ruleService
.disableRule(rule.config, rule.id, false)
.then(
/**
* Handle successfully disabling the original rule
* @method success
* @private
*/
function success() {
rule.disabled = true;
alertService.add({
type: "success",
message: LOCALE.maketext("You successfully disabled the [asis,ModSecurity™] rule with the following ID: [_1]", _.escape(rule.id)),
id: "alertDisableSuccess",
});
},
/**
* Handle failure of cloning the rule
* @method failure
* @private
* @param {Object} error Error from the backend.
* @param {String} message
*/
function failure(error) {
var message = error.message || error;
alertService.add({
type: "danger",
message: message,
id: "alertDisableFailure",
});
}
).finally(function() {
// ensure the alert is in view and focus is in the rule field
$scope.scrollTo("top");
document.getElementById("txtRuleText").focus();
});
};
/**
* Retrieve a copy of the rule with a unique id
*
* @method getClonedRule
* @param {object} rule The original rule to be cloned
* @param {string} rule.id The id of the original rule
* @param {string} rule.config The configuration file of the original file
* @param {Boolean} rule.disabled Is the rule disabled?
* @return {Promise}
*/
$scope.getClonedRule = function(rule) {
spinnerAPI.start("loadingSpinner");
return ruleService
.cloneRule(rule.id, rule.config)
.then(
/**
* Handle successfully cloning the rule
* @method success
* @private
* @param {Rule} rule The cloned rule with a unique id ready to be saved
*/
function success(rule) {
$scope.id = rule.id;
$scope.enabled = !rule.disabled;
$scope.rule = rule.rule;
// activate the save buttons
$scope.form.rule.$pristine = false;
},
/**
* Handle failure of cloning the rule
* @method failure
* @private
* @param {Object} error Error from the backend.
* @param {String} error.message The error message.
*/
function failure(error) {
var message = error.message || error;
alertService.add({
type: "danger",
message: _.escape(message),
id: "alertCopyFailure",
});
// ensure the error is in view and focus is in the rule field
$scope.scrollTo("top");
document.getElementById("txtRuleText").focus();
}
).finally(function() {
spinnerAPI.stop("loadingSpinner");
});
};
// Setup the installed bit...
$scope.isInstalled = PAGE.installed;
if (!$scope.isInstalled) {
$scope.loadView("hitList");
}
// Initialize the form on first load.
$scope.clearForm();
// check for copy of existing rule
if ( $location.$$path.indexOf("copy") !== -1 ) {
$scope.originalRule = {
id: $routeParams["id"],
config: $routeParams["config"],
disabled: $routeParams["disabled"],
};
if ( $scope.originalRule.id && $scope.originalRule.config ) {
$scope.isCopy = true;
$scope.getClonedRule($scope.originalRule);
}
}
},
]);
return controller;
}
);
/*
# templates/mod_security/views/addRuleController.js Copyright(c) 2020 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* global define: false */
define(
'app/views/editRuleController',[
"angular",
"lodash",
"jquery",
"cjt/util/locale",
"cjt/util/parse",
"uiBootstrap",
"cjt/directives/autoFocus",
"cjt/directives/spinnerDirective",
"cjt/services/alertService",
"app/services/ruleService",
],
function(angular, _, $, LOCALE, PARSE) {
"use strict";
// Retrieve the current application
var app = angular.module("App");
var controller = app.controller(
"editRuleController",
["$scope", "$location", "$anchorScroll", "$routeParams", "spinnerAPI", "alertService", "ruleService", "PAGE",
function($scope, $location, $anchorScroll, $routeParams, spinnerAPI, alertService, ruleService, PAGE) {
/**
* Disable the save button based on form state
*
* @method disableSave
* @param {FormController} form
* @return {Boolean}
*/
$scope.disableSave = function(form) {
var pristineInputs = form.rule.$pristine && form.enabled.$pristine;
return ($scope.isEditor && $scope.cantEdit) || pristineInputs || (form.$dirty && form.$invalid);
};
/**
* Clear the form
*
* @method clearForm
*/
$scope.clearForm = function() {
$scope.enabled = false;
$scope.oldRule = 0;
$scope.ruleText = "";
$scope.deploy = false;
$scope.clearNotices();
$scope.cantEdit = false;
};
/**
* Clear the notices
*
* @method clearNotices
*/
$scope.clearNotices = function() {
alertService.clear();
$scope.notice = "";
};
/**
* Navigate to the previous view.
*
* @method cancel
*/
$scope.cancel = function() {
$scope.clearNotices();
$scope.loadView(backRoute);
};
/**
* Save the form and navigate or clean depending on the users choices.
*
* @method save
* @param {FormController} form
* @param {Boolean} exit If true, will navigate back on completion, if false,
* will clear the form and let you add another rule.
* @return {Promise}
*/
$scope.save = function(form, exit) {
$scope.clearNotices();
if (!form.$valid) {
return;
}
spinnerAPI.start("loadingSpinner");
return ruleService
.updateRule($scope.configFile, $scope.id, $scope.rule, $scope.enabled, $scope.enabled !== $scope.originalEnabled, $scope.deploy)
.then(
/**
* Handle successfully adding the rule
* @method success
* @private
* @param {Rule} rule Rule added to the system
*/
function success(rule) {
$scope.clearNotices();
form.$setPristine();
spinnerAPI.stop("loadingSpinner");
$scope.clearForm();
alertService.add({
type: "success",
message: LOCALE.maketext("You have successfully updated the [asis,ModSecurity™] rule."),
id: "alertAddSuccess",
replace: true,
});
app.firstLoad.rules = false;
$scope.loadView("rulesList");
},
/**
* Handle failure of adding the rule
* @method failure
* @private
* @param {Object} error Error from the backend.
* @param {String} message
* @param {Boolean} duplicate true if this is a duplicate queue item, false otherwise.
*/
function failure(error) {
$scope.notice = "";
if (error && error.duplicate) {
alertService.add({
type: "warning",
message: LOCALE.maketext("There is a duplicate [asis,ModSecurity™] rule in the staged configuration file. You cannot add a duplicate rule."),
id: "alertAddWarning",
});
} else {
var message = error.message || error; // It can come from either structured or unstructured errors
alertService.add({
type: "danger",
message: _.escape(message),
id: "alertAddFailure",
});
}
// ensure the error is in view and focus is in the rule field
$scope.scrollTo("top");
document.getElementById("txtRuleText").focus();
},
/**
* Handle step wise updating
* @method notify
* @private
* @param {Rule} rule Rule added to the system
*/
function notify(notice) {
$scope.notice += notice + "\n";
}
).then(
function finish() {
spinnerAPI.stop("loadingSpinner");
}
);
};
/**
* Fetch the list of hits from the server
*
* @method fetchRule
* @param {Number} ruleId The numeric ID of the rule.
* @param {String} [vendorId] Optional unique vendor ID string. If this is not
* included, we will search for the rule in the user
* defined rule set.
* @return {Promise} Promise that when fulfilled with contain the matching
* rules, only one if the file isn't messed up with
* duplicate id's. Defensive logic is in place for other
* conditions. > 1 match and no matches.
*/
var fetchRule = function(ruleId, vendorId) {
spinnerAPI.start("loadingSpinner");
return ruleService
.fetchRulesById(ruleId, vendorId)
.then(function(results) {
// May be useful
$scope.stagedChanges = results.stagedChanges;
var matchedRule = results.items[0];
$scope.id = matchedRule.id;
$scope.enabled = !PARSE.parsePerlBoolean(matchedRule.disabled);
$scope.originalEnabled = $scope.enabled;
$scope.meta_msg = matchedRule.meta_msg;
$scope.rule = matchedRule.rule;
$scope.cantEdit = false;
$scope.configFile = matchedRule.config;
// If the vendor or config isn't active, we should let the user know
if (matchedRule.vendor_id && (!matchedRule.vendor_active || !matchedRule.config_active)) {
var message = !matchedRule.vendor_active ?
LOCALE.maketext("The vendor that provides the rule “[_1]” is disabled. Whether enabled or disabled, the rule will have no visible effect until you enable that vendor.", matchedRule.vendor_id) :
LOCALE.maketext("The configuration file that provides the rule “[_1]” is disabled. Whether enabled or disabled, the rule will have no visible effect until you enable the configuration file for the “[_2]” vendor.", matchedRule.config, matchedRule.vendor_id);
alertService.add({
type: "warning",
message: message,
id: "alertDisabledWarning",
replace: false,
});
}
}, function(error) {
var message;
if (error.count > 1) {
message = vendorId ?
LOCALE.maketext("The rule with ID number “[_1]” is not unique. There are multiple rules that use the same ID number within the “[_2]” vendor rule set.", ruleId, vendorId) :
LOCALE.maketext("The rule with ID number “[_1]” is not unique. There are multiple rules that use the same ID number within your user-defined rule set.", ruleId);
alertService.add({
type: "danger",
message: _.escape(message),
id: "alertEditError",
});
} else if (error.count < 1) {
message = vendorId ?
LOCALE.maketext("The system could not find the rule with ID number “[_1]” from the “[_2]” vendor rule set.", ruleId, vendorId) :
LOCALE.maketext("The system could not find the rule with ID number “[_1]” from your user-defined rule set.", ruleId);
alertService.add({
type: "warning",
message: _.escape(message),
id: "alertEditWarning",
});
} else {
alertService.add({
type: "danger",
message: _.escape(error.message),
id: "errorFetchRulesList",
});
}
$scope.cantEdit = true;
})
.finally(function() {
spinnerAPI.stop("loadingSpinner");
});
};
// Setup the installed bit...
$scope.isInstalled = PAGE.installed;
if (!$scope.isInstalled) {
$scope.loadView("hitList");
}
// Initialize the form on first load.
$scope.isVendor = !!$routeParams.vendorId;
$scope.isEditor = true;
$scope.cantEdit = true;
$scope.clearForm();
var ruleId = $routeParams["ruleId"];
var vendorId = $routeParams["vendorId"];
var backRoute = $routeParams["back"];
if (!backRoute) {
backRoute = "rulesList";
}
if (angular.isUndefined(ruleId)) {
alertService.add({
type: "danger",
message: LOCALE.maketext("The system could not find the ID number for this rule."),
id: "alertNoIdError",
});
$scope.cantEdit = true;
} else {
// Let the user know that they can only toggle it on or off if it's a vendor rule
if ($scope.isVendor) {
alertService.add({
type: "info",
message: LOCALE.maketext("A vendor configuration file provides this rule. You cannot edit vendor rules. You can enable or disable this rule with the controls below."),
id: "alertVendorRuleInfo",
});
}
fetchRule(ruleId, vendorId);
}
},
]);
return controller;
}
);
/*
# templates/mod_security/views/massEditRuleController.js Copyright(c) 2020 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* global define */
define(
'app/views/massEditRuleController',[
"angular",
"lodash",
"cjt/util/locale",
"jquery",
"cjt/jquery/plugins/rangeSelection",
"uiBootstrap",
"cjt/directives/autoFocus",
"cjt/directives/spinnerDirective",
"cjt/services/alertService",
"app/services/ruleService",
],
function(angular, _, LOCALE) {
"use strict";
// Retrieve the current application
var app = angular.module("App");
var controller = app.controller(
"massEditRuleController",
["$scope", "$location", "$anchorScroll", "$routeParams", "$q", "$timeout", "ruleService", "alertService", "spinnerAPI", "PAGE",
function($scope, $location, $anchorScroll, $routeParams, $q, $timeout, ruleService, alertService, spinnerAPI, PAGE) {
// CONSTANTS
var ANIMATION_INTER_STEP_DELAY = 500; // in milliseconds.
/**
* Disable the save button based on form state
*
* @method disableSave
* @param {FormController} form
* @return {Boolean}
*/
$scope.disableSave = function(form) {
return $scope.cantEdit || form.txtRules.$pristine || (form.$dirty && form.$invalid);
};
/**
* Clear the form
*
* @method clearForm
*/
$scope.clearForm = function() {
$scope.enabled = true;
$scope.rules = "";
$scope.deploy = false;
$scope.clearNotices();
$scope.cantEdit = false;
};
/**
* Clear the notices
*
* @method clearNotices
*/
$scope.clearNotices = function() {
alertService.clear();
$scope.notice = "";
};
/**
* Navigate to the previous view.
*
* @method cancel
*/
$scope.cancel = function() {
$scope.clearNotices();
$scope.loadView("rulesList");
};
/**
* Save the form and navigate or clean depending on the users choices.
*
* @method save
* @param {FormController} form
* @return {Promise}
*/
$scope.save = function(form) {
$scope.clearNotices();
if (!form.$valid) {
return;
}
spinnerAPI.start("loadingSpinner");
$scope.progress = 0;
$scope.cantEdit = true;
return ruleService
.setCustomConfigText($scope.rules, $scope.deploy)
.then(function() {
// on success, update alert and load the view
spinnerAPI.stop("loadingSpinner");
$timeout(function() {
$scope.progress = 100;
$timeout(function() {
// success
if ($scope.deploy) {
alertService.add({
type: "success",
message: LOCALE.maketext("You have successfully saved and deployed your [asis,ModSecurity™] rules."),
id: "alertSaveSuccess",
});
} else {
alertService.add({
type: "success",
message: LOCALE.maketext("You have successfully saved your [asis,ModSecurity™] rules."),
id: "alertSaveSuccess",
});
}
$timeout(function() {
$scope.loadView("rulesList");
$scope.showProgress = false;
}, 2 * ANIMATION_INTER_STEP_DELAY);
}, 2 * ANIMATION_INTER_STEP_DELAY);
}, 2 * ANIMATION_INTER_STEP_DELAY);
}, function(error) {
spinnerAPI.stop("loadingSpinner");
$scope.cantEdit = false;
if (error) {
// failures like timeout, lost connection, etc.
alertService.add({
type: "danger",
message: _.escape(error),
id: "errorFetchRules",
});
}
// ensure the any notifications and/or the final error is in view and focus is in the rule field
$timeout(function() {
$scope.scrollTo("top");
document.getElementById("txtRules").focus();
}, 2 * ANIMATION_INTER_STEP_DELAY);
}, function(data) {
switch (data.type) {
case "post":
// Only show the progress if there are more then 2 pages
if (data.totalPages > 2) {
$scope.showProgress = true;
}
// Update the progress
$scope.progress = Math.floor(data.page / data.totalPages * 100);
break;
case "error":
// api related failures.
alertService.add({
type: "danger",
message: _.escape(data.error),
id: "errorSaveRules",
});
break;
}
});
};
/**
* Fetch the config text from the user defined rules file
* @return {Promise} Promise that will fulfill the request.
*/
var fetchRules = function() {
spinnerAPI.start("loadingSpinner");
$scope.progress = 0;
$scope.cantEdit = true;
return ruleService
.getCustomConfigText()
.then(angular.noop, function(error) {
if (error) {
// failures like timeout, lost connection, etc.
alertService.add({
type: "danger",
message: _.escape(error),
id: "errorFetchRules",
});
}
}, function(data) {
switch (data.type) {
case "page":
// Only show the progress if there are more then 2 pages
if (data.totalPages > 2) {
$scope.showProgress = true;
}
if (data.text) {
// Update the progress
$scope.progress = Math.floor(data.page / data.totalPages * 100);
// Append
$scope.rules += data.text.join("");
}
break;
case "error":
// api related failures.
alertService.add({
type: "danger",
message: _.escape(data.error),
id: "errorFetchRules",
});
break;
}
})
.finally(function() {
spinnerAPI.stop("loadingSpinner");
$timeout(function() {
$scope.progress = 100;
$timeout(function() {
// delayed for a little so the progress bar can finish
$scope.cantEdit = false;
$scope.showProgress = false;
$timeout(function() {
$scope.progress = 0;
angular.element(document.querySelector( "#txtRules" )).selectRange(0);
}, ANIMATION_INTER_STEP_DELAY);
}, ANIMATION_INTER_STEP_DELAY);
}, ANIMATION_INTER_STEP_DELAY);
});
};
// Setup the installed bit...
$scope.isInstalled = PAGE.installed;
if (!$scope.isInstalled) {
$scope.loadView("hitList");
}
// Initialize the form on first load.
$scope.showProgress = false;
$scope.cantEdit = true;
$scope.clearForm();
fetchRules();
},
]);
return controller;
}
);
/*
# templates/mod_security/views/reportController.js Copyright(c) 2020 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* global define: false */
define('app/views/reportController',[
"angular",
"cjt/util/locale",
"uiBootstrap",
"app/services/reportService",
"cjt/validator/email-validator",
"cjt/directives/validationContainerDirective",
"cjt/directives/validationItemDirective",
"cjt/directives/spinnerDirective",
"cjt/filters/wrapFilter",
"cjt/filters/breakFilter",
],
function(angular, LOCALE) {
angular.module("App")
.controller("reportController", [
"$scope",
"reportService",
"alertService",
"$route",
"$window",
"$location",
"spinnerAPI",
function(
$scope,
reportService,
alertService,
$route,
$window,
$location,
spinnerAPI
) {
var view, report;
function initialize() {
/**
* The view model. Contains items only needed for the view.
*
* @property {String} step The name of the form submission step the user is on
* @property {String} vendorId The vendor_id from the API
* @property {Object} isDisabled Contains various standardized disabled flags
* @property {Boolean} isDisabled.rule Is the rule itself disabled?
* @property {Boolean} isDisabled.config Is the config housing this rule disabled?
* @property {Boolean} isDisabled.vendor Is the vendor providing this rule disabled?
* @property {Boolean} isDisabled.overall Is the rule disabled at any of the previous levels?
* @property {Number} includedHitCount The number of included hits in the report
* @property {Object} expandedHit The hit object that is currently expanded
* @property {Object} form The angular form controller for the main form
* @property {Object} loading An object with basic loading flags.
* @property {Boolean} loading.init Are the hits and rule loading?
* @property {Boolean} loading.report Is the generated report loading?
* @property {Boolean} submitting Is the report submitting?
* @property {Boolean} rawReportActive Is the rawReport tab active?
* @property {Array} lastIncludedHitIds This is an array of hit IDs that were included in the last report generated for the raw report tab
*/
view = $scope.view = {
step: "input",
loading: {
init: false,
report: false
},
submitting: false,
ruleExpanded: false
};
/**
* This object stores all of the items relevant to generating and submitting a report.
*
* @property {Array} hits An array of hit objects that are associated with the rule
* @property {Object} rule The rule object for the rule being reported
* @property {Object} inputs The values that the user inputs on the form
*/
report = $scope.report = {
hits: null,
rule: null,
inputs: {}
};
// pathParams should include hitId or ruleId properties
_getReport($route.current.pathParams).then(_updateViewModel);
}
/**
* Attempts to get the last promise from the report service that was created using fetchByHit
* or fetchByRule. If it's not available, use the lookup object to get a new one. This promise
* resolves with a rule object a list of associated hits and is used to populate the report
* $scope object.
*
* @param {Object} lookup This object should have either a hitId or ruleId/vendorId property
* @return {Promise} This is either cached or newly fetched promise
*/
function _getReport(lookup) {
view.loading.init = true;
// Check to see if there's a cached promise. If not, get a new one depending on the lookup data.
var reportPromise = reportService.getCurrent();
if (!reportPromise) {
if (lookup.hitId) {
reportPromise = reportService.fetchByHit(lookup.hitId);
} else if (lookup.ruleId && lookup.vendorId) {
reportPromise = reportService.fetchByRule(lookup.ruleId, lookup.vendorId);
} else {
throw new ReferenceError("Cannot populate the report without a ruleId or hitId.");
}
}
reportPromise.then(
function success(response) {
if (report.invalid) {
delete report.invalid;
}
report.rule = response.rule;
report.hits = response.hits.map(function(hit) {
hit.included = true;
return hit;
});
view.includedHitCount = report.hits.length;
},
function failure(error) {
report.invalid = true;
if (error && error.message) {
alertService.add({
type: "danger",
message: error.message,
id: "report-retrieval-error"
});
} else {
alertService.add({
type: "danger",
message: error,
id: "report-retrieval-error"
});
}
}
).finally(function() {
view.loading.init = false;
});
return reportPromise;
}
/**
* Update various bits of the view model with the results from the initial fetch.
*
* @method _updateViewModel
*/
function _updateViewModel() {
view.vendorId = report.rule.vendor_id;
view.isDisabled = {
overall: report.rule.disabled || !report.rule.config_active || !report.rule.vendor_active,
rule: report.rule.disabled,
config: !report.rule.config_active,
vendor: !report.rule.vendor_active
};
}
/**
* Get the text for the page title. If we have a vedor ID string, then we'll use it.
*
* @method getTitleText
* @return {String} The title
*/
function getTitleText() {
return view.vendorId ?
LOCALE.maketext("Report a [asis,ModSecurity] Rule to [_1]", view.vendorId) :
LOCALE.maketext("Report a [asis,ModSecurity] Rule");
}
/**
* Is the hit currently expanded?
*
* @method isExpanded
* @param {Object} hit A hit object
*
* @return {Boolean} Is it expanded?
*/
function isExpanded(hit) {
return view.expandedHit === hit;
}
/**
* Toggle the expanded or collapsed state of a hit in the table
* view. Only one hit will be expanded at a time.
*
* @method toggleExpandCollapse
* @param {Object} hit A hit object
*/
function toggleExpandCollapse(hit) {
view.expandedHit = view.expandedHit === hit ? null : hit;
}
/**
* Toggles the state of the hit as included or excluded from the report.
*
* @method toggleIncludeExclude
* @param {Object} hit A hit object
*/
function toggleIncludeExclude(hit) {
if (view.includedHitCount === 1) {
alertService.add({
type: "info",
message: LOCALE.maketext("You must include at least one hit record with your report."),
id: "report-last-hit-info"
});
return;
}
hit.included = !hit.included;
view.includedHitCount--;
}
/**
* Generates an array of hit IDs that the user has elected to include with the report.
* @return {Array} An array of numbers corresponding to hit IDs.
*/
function _includedHitIds() {
var includedHits = [];
if (report.hits) {
report.hits.forEach(function(hit) {
if (hit.included) {
includedHits.push(hit.id);
}
});
}
return includedHits;
}
/**
* Gathers all of the required report parameters together.
*
* @method _consolidateReportInputs
* @return {Object} An object suitable to pass to the reportService as reportParams
*/
function _consolidateReportInputs() {
return {
hits: _includedHitIds(),
email: report.inputs.email,
reason: report.inputs.reason,
message: report.inputs.comments
};
}
/**
* Duct tape for a bug with UI Bootstrap tabs that has already been fixed upstream.
* Basically the select callbacks are run on $destroy so it resulted in extra net
* requests for no reason.
*
* Issue thread here: https://github.com/angular-ui/bootstrap/issues/2155
* Fixed here: https://github.com/lanetix/bootstrap/commit/4d77f3995bb357741a86bcd48390c8bb2e9954e7
*/
var destroyed;
$scope.$on("$destroy", function() {
destroyed = true;
});
/**
* Callback when changing tabs.
*
* @method changeToTab
* @param {String} tabName The name of the tab
*/
function changeToTab(tabName) {
if (!destroyed) { // See workaround documentation directly above this method
// Trying to remove the last hit in the associated hit list gives an alert, so remove it if we're heading to another tab
if (tabName !== "hitList") {
alertService.removeById("report-last-hit-info");
}
// If it's the raw report tab, fetch the report
if (tabName === "rawReport") {
_updateRawTab();
}
}
}
/**
* Check to see if the included hits have changed since the last preview was generated.
*
* @method _includedHitIdsChanged
* @return {Boolean} True if the included hit ids have changed
*/
function _includedHitIdsChanged() {
var currentIds = _includedHitIds();
if (!view.lastIncludedHitIds || currentIds.length !== view.lastIncludedHitIds.length) {
return false;
} else {
return currentIds.some(function(val, index) {
return view.lastIncludedHitIds.indexOf(val) === -1;
});
}
}
/**
* Check to see if the generated report in the raw tab is stale, i.e. there
* is new information in the form or if the selected/included hits differ from
* the last time the report was generated.
*
* @method rawTabIsStale
* @return {Boolean} True if the generated report is stale
*/
function rawTabIsStale() {
return view.form.$dirty || _includedHitIdsChanged();
}
/**
* Fetches the JSON for the generated report and updates report.json
*
* @method _updateRawTab
*/
function _updateRawTab() {
if (rawTabIsStale()) {
// Reset the two stale conditions
view.form.$setPristine();
view.lastIncludedHitIds = _includedHitIds();
viewReport().then(
function(response) {
report.json = JSON.stringify(response, false, 2);
},
function(error) {
alertService.add({
type: "danger",
message: error,
id: "fetch-generated-report-error"
});
}
);
}
}
/**
* Fetch a generated report.
*
* @method viewReport
* @return {Promise} Resolves with the report as a parsed object.
*/
function viewReport() {
view.loading.report = true;
return reportService.viewReport(_consolidateReportInputs()).finally(function() {
view.loading.report = false;
});
}
/**
* Submit the report and optionally disable the rule.
*
* @method submitReport
* @return {Promise} Resolves when the report has been sent and the rule has
* been disabled, if the user chose to disable the rule.
*/
function submitReport() {
alertService.clear();
view.submitting = true;
var promise, disableParams;
// Send disable params if the rule is enabled and the user wants to disable
if (!view.isDisabled.rule && report.inputs.disableRule) {
disableParams = {
deployRule: report.inputs.deployRule,
ruleConfig: report.rule.config,
ruleId: report.rule.id
};
promise = reportService.sendReport(_consolidateReportInputs(), disableParams);
} else {
promise = reportService.sendReport(_consolidateReportInputs());
}
promise.then(
function success(response) {
alertService.add({
type: "success",
message: LOCALE.maketext("You have successfully submitted a report for the rule ID “[_1]” to “[_2]”.", report.rule.id, view.vendorId),
id: "report-rule-submit-success"
});
$scope.loadView("hitList");
},
function failure(error) {
alertService.add({
type: "warning",
message: error,
id: "report-rule-submit-error"
});
}
).finally(function() {
view.submitting = false;
});
return promise;
}
/**
* The user no longer wants to submit the report, so send them back to where
* they came from if we have history. If not, take them to the appropriate
* place based on their route params.
*
* @method cancelSubmission
*/
function cancelSubmission() {
alertService.clear();
if ($location.state) {
$window.history.back();
} else if ($route.current.pathParams.hitId) {
$scope.loadView("hitList");
} else {
$scope.loadView("rulesList");
}
}
/**
* Changes the submission step.
*
* @param {String} newStep The name of the new step
*/
function changeStep(newStep) {
view.step = newStep;
// If we're coming back to the review page and the raw tab is active,
// we need to update the report.
if (newStep === "review" && view.rawReportActive) {
_updateRawTab();
}
}
// Extend scope with the public methods
angular.extend($scope, {
getTitleText: getTitleText,
isExpanded: isExpanded,
toggleExpandCollapse: toggleExpandCollapse,
toggleIncludeExclude: toggleIncludeExclude,
changeToTab: changeToTab,
viewReport: viewReport,
submitReport: submitReport,
cancelSubmission: cancelSubmission,
changeStep: changeStep,
rawTabIsStale: rawTabIsStale
});
initialize();
}
])
.filter("onlyTrueHitFields", function() {
var EXCLUDED_KEYS = ["included", "reportable", "file_exists"];
/**
* Filters out any fields that are added to modsec_get_log results that don't exist in the database.
* @param {Object} hitObj The hit object
*
* @return {Object} A copy of the hit object with synthetic keys filtered out
*/
return function(hitObj) {
var filteredObj = {};
angular.forEach(hitObj, function(val, key) {
if (EXCLUDED_KEYS.indexOf(key) === -1) {
filteredObj[key] = val;
}
});
return filteredObj;
};
});
}
);
/*
# templates/mod_security/index.js Copyright(c) 2020 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* global require, define, PAGE */
define(
'app/index',[
"angular",
"jquery",
"lodash",
"cjt/core",
"cjt/modules",
"ngRoute",
"uiBootstrap"
],
function(angular, $, _, CJT) {
return function() {
// First create the application
angular.module("App", [
"cjt2.config.whm.configProvider", // This needs to load first
"ngRoute",
"ui.bootstrap",
"cjt2.whm"
]);
// Then load the application dependencies
var app = require(
[
"cjt/bootstrap",
"cjt/util/locale",
// Application Modules
"cjt/views/applicationController",
"cjt/filters/breakFilter",
"app/views/commonController",
"app/views/hitListController",
"app/views/rulesListController",
"app/views/addRuleController",
"app/views/editRuleController",
"app/views/massEditRuleController",
"app/views/reportController",
"cjt/services/autoTopService",
"cjt/services/whm/breadcrumbService"
], function(BOOTSTRAP, LOCALE) {
var app = angular.module("App");
app.value("PAGE", PAGE);
app.firstLoad = {
hitList: true,
rules: true
};
app.config(["$routeProvider",
function($routeProvider) {
// Setup the routes
$routeProvider.when("/hitList", {
controller: "hitListController",
templateUrl: CJT.buildFullPath("mod_security/views/hitListView.ptt"),
breadcrumb: LOCALE.maketext("Hits List"),
reloadOnSearch: false
});
$routeProvider.when("/rulesList", {
controller: "rulesListController",
templateUrl: CJT.buildFullPath("mod_security/views/rulesListView.ptt"),
breadcrumb: LOCALE.maketext("Rules List"),
reloadOnSearch: false
});
$routeProvider.when("/addCustomRule", {
controller: "addRuleController",
templateUrl: CJT.buildFullPath("mod_security/views/addEditRuleView.ptt"),
breadcrumb: LOCALE.maketext("Add Custom Rule"),
reloadOnSearch: false
});
$routeProvider.when("/copyCustomRule", {
controller: "addRuleController",
templateUrl: CJT.buildFullPath("mod_security/views/addEditRuleView.ptt"),
breadcrumb: LOCALE.maketext("Copy Rule"),
reloadOnSearch: false
});
$routeProvider.when("/editCustomRule", {
controller: "editRuleController",
templateUrl: CJT.buildFullPath("mod_security/views/addEditRuleView.ptt"),
breadcrumb: LOCALE.maketext("Edit Custom Rule"),
reloadOnSearch: false
});
$routeProvider.when("/editCustomRules", {
controller: "massEditRuleController",
templateUrl: CJT.buildFullPath("mod_security/views/massEditRuleView.ptt"),
breadcrumb: LOCALE.maketext("Edit Custom Rules"),
reloadOnSearch: false
});
$routeProvider.when("/report/hit/:hitId", {
controller: "reportController",
templateUrl: CJT.buildFullPath("mod_security/views/reportView.ptt"),
breadcrumb: LOCALE.maketext("Report ModSecurity Hit"),
reloadOnSearch: false
});
$routeProvider.when("/report/:vendorId/rule/:ruleId", {
controller: "reportController",
templateUrl: CJT.buildFullPath("mod_security/views/reportView.ptt"),
breadcrumb: LOCALE.maketext("Report ModSecurity Rule"),
reloadOnSearch: false
});
$routeProvider.otherwise({
redirectTo: function(routeParams, path, search) {
return "/hitList?" + window.location.search;
}
});
}
]);
app.run(["autoTopService", "breadcrumbService", function(autoTopService, breadcrumbService) {
// Setup the automatic scroll to top for view changes
autoTopService.initialize();
// Setup the breadcrumbs service
breadcrumbService.initialize();
}]);
BOOTSTRAP(document);
});
return app;
};
}
);