@openzeppelin/defender-as-code
Version:
Configure your Defender environment via code
430 lines (429 loc) • 20.3 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.isTenantNetwork = exports.removeDefenderIdReferences = exports.isDefenderId = exports.formatABI = exports.isUnauthorisedError = exports.validateAdditionalPermissionsOrThrow = exports.constructMonitor = exports.isResource = exports.constructNotification = exports.getNetworkClient = exports.getDeployClient = exports.getProposalClient = exports.getRelayGroupClient = exports.getRelayClient = exports.getActionClient = exports.getMonitorClient = exports.getTeamAPIkeysOrThrow = exports.isSSOT = exports.getStackName = exports.getResourceID = exports.isTemplateResource = exports.getConsolidatedSecrets = exports.getEquivalentResourceByKey = exports.validateTypesAndSanitise = exports.getEquivalentResource = void 0;
const lodash_1 = __importDefault(require("lodash"));
const defender_sdk_base_client_1 = require("@openzeppelin/defender-sdk-base-client");
const defender_sdk_action_client_1 = require("@openzeppelin/defender-sdk-action-client");
const defender_sdk_monitor_client_1 = require("@openzeppelin/defender-sdk-monitor-client");
const defender_sdk_relay_client_1 = require("@openzeppelin/defender-sdk-relay-client");
const defender_sdk_relay_group_client_1 = require("@openzeppelin/defender-sdk-relay-group-client");
const defender_sdk_proposal_client_1 = require("@openzeppelin/defender-sdk-proposal-client");
const defender_sdk_deploy_client_1 = require("@openzeppelin/defender-sdk-deploy-client");
const defender_sdk_network_client_1 = require("@openzeppelin/defender-sdk-network-client");
const sanitise_1 = require("./sanitise");
const logger_1 = __importDefault(require("./logger"));
const getDefenderIdFromResource = (resource, resourceType) => {
switch (resourceType) {
case 'Actions':
return resource.actionId;
case 'Monitors':
return resource.monitorId;
case 'Relayers':
return resource.relayerId;
case 'Relayer Groups':
return resource.relayerGroupId;
case 'Notifications':
return resource.notificationId;
case 'Block Explorer Api Keys':
return resource.blockExplorerApiKeyId;
case 'Private Networks':
case 'Forked Networks':
return resource.tenantNetworkId;
case 'Contracts':
const contract = resource;
return `${contract.network}-${contract.address}`;
default:
throw new Error(`Incompatible resource type ${resourceType}`);
}
};
/**
* @dev this function retrieves the Defender equivalent object of the provided template resource
* This will not work for resources that do not have the stackResourceId property, ie. secrets and contracts
*/
const getEquivalentResource = (context, resource, resources, currentResources, type) => {
if (resource) {
if ((0, exports.isDefenderId)(resource)) {
const foundResource = currentResources.find((e) => getDefenderIdFromResource(e, type) === resource);
if (!foundResource)
logger_1.default.getInstance().warn(`Resource ${resource} not found in Defender. Skipping...`);
return foundResource;
}
const [key, value] = Object.entries(resources ?? {}).find((a) => lodash_1.default.isEqual(a[1], resource));
const foundResource = currentResources.find((e) => e.stackResourceId === (0, exports.getResourceID)((0, exports.getStackName)(context), key));
if (!foundResource)
logger_1.default.getInstance().warn(`Resource ${key} not found in Defender. Skipping...`);
return foundResource;
}
};
exports.getEquivalentResource = getEquivalentResource;
const validateTypesAndSanitise = (o) => {
return (0, sanitise_1.sanitise)(o);
};
exports.validateTypesAndSanitise = validateTypesAndSanitise;
const getEquivalentResourceByKey = (resourceKey, currentResources) => {
return currentResources.find((e) => e.stackResourceId === resourceKey);
};
exports.getEquivalentResourceByKey = getEquivalentResourceByKey;
/**
* @dev returns both a list of consolidated secrets for both global and stack, where the latter will be preceded with the stack name.
* */
const getConsolidatedSecrets = (context, resources) => {
const secrets = resources?.secrets ?? {};
const globalSecrets = secrets.global ?? {};
const stackSecrets = secrets.stack ?? {};
const stackSecretsPrecededWithStackName = Object.entries(stackSecrets).map(([ssk, ssv]) => {
return {
[`${(0, exports.getStackName)(context)}_${ssk}`]: ssv,
};
});
return lodash_1.default.map(lodash_1.default.entries(Object.assign(globalSecrets, ...stackSecretsPrecededWithStackName)), ([k, v]) => ({
[k]: v,
}));
};
exports.getConsolidatedSecrets = getConsolidatedSecrets;
const isTemplateResource = (context, resource, resourceType, resources) => {
return !!Object.entries(resources).find((a) => resourceType === 'Secrets'
? // if secret, just compare key
Object.keys(a[1])[0] === resource
: resourceType === 'Contracts'
? // if contracts, compare network and address
a[1].network === resource.network &&
a[1].address === resource.address
: // anything else, compare stackResourceId
(0, exports.getResourceID)((0, exports.getStackName)(context), a[0]) === resource.stackResourceId);
};
exports.isTemplateResource = isTemplateResource;
const getResourceID = (stackName, resourceName) => {
return `${stackName}.${resourceName}`;
};
exports.getResourceID = getResourceID;
const getStackName = (context) => {
if (context.service.provider.stackName && typeof context.service.provider.stackName === 'string') {
return context.service.provider.stackName;
}
if (context.service.provider.stage)
return `${context.service.service}-${context.service.provider.stage}`;
throw new Error(`Unable to get stack name. Missing "provider.stage" property. Alternatively, define "stackName" under "provider" in your serverless.yaml file.`);
};
exports.getStackName = getStackName;
const isSSOT = (context) => {
return !!context.service.provider.ssot;
};
exports.isSSOT = isSSOT;
const getTeamAPIkeysOrThrow = (context) => {
const defenderConfig = context.service.initialServerlessConfig.defender;
if (!defenderConfig)
throw new Error(`Missing "defender" top-level property in configuration. Please define "defender" with the "key" and "secret" properties in your serverless.yaml file.`);
if (!defenderConfig.key || !defenderConfig.secret)
throw new Error(`Missing "defender" key or secret properties in configuration. Please define a "key" and "secret" property under "defender" in your serverless.yaml file.`);
return { apiKey: defenderConfig.key, apiSecret: defenderConfig.secret };
};
exports.getTeamAPIkeysOrThrow = getTeamAPIkeysOrThrow;
const getMonitorClient = (key) => {
return new defender_sdk_monitor_client_1.MonitorClient(key);
};
exports.getMonitorClient = getMonitorClient;
const getActionClient = (key) => {
return new defender_sdk_action_client_1.ActionClient(key);
};
exports.getActionClient = getActionClient;
const getRelayClient = (key) => {
return new defender_sdk_relay_client_1.RelayClient(key);
};
exports.getRelayClient = getRelayClient;
const getRelayGroupClient = (key) => {
return new defender_sdk_relay_group_client_1.RelayGroupClient(key);
};
exports.getRelayGroupClient = getRelayGroupClient;
const getProposalClient = (key) => {
return new defender_sdk_proposal_client_1.ProposalClient(key);
};
exports.getProposalClient = getProposalClient;
const getDeployClient = (key) => {
return new defender_sdk_deploy_client_1.DeployClient(key);
};
exports.getDeployClient = getDeployClient;
const getNetworkClient = (key) => {
return new defender_sdk_network_client_1.NetworkClient(key);
};
exports.getNetworkClient = getNetworkClient;
const constructNotification = (notification, stackResourceId) => {
const commonNotification = {
type: notification.type,
name: notification.name,
paused: notification.paused,
stackResourceId,
};
let currentConfig;
let config;
switch (notification.type) {
case 'datadog':
currentConfig = notification.config;
config = {
apiKey: currentConfig['api-key'],
metricPrefix: currentConfig['metric-prefix'],
};
return { ...commonNotification, config };
case 'discord':
currentConfig = notification.config;
config = {
url: currentConfig.url,
};
return { ...commonNotification, config };
case 'email':
currentConfig = notification.config;
config = {
emails: currentConfig.emails,
};
return { ...commonNotification, config };
case 'slack':
currentConfig = notification.config;
config = {
url: currentConfig.url,
};
return { ...commonNotification, config };
case 'telegram':
currentConfig = notification.config;
config = {
botToken: currentConfig['bot-token'],
chatId: currentConfig['chat-id'],
};
return { ...commonNotification, config };
case 'opsgenie':
currentConfig = notification.config;
config = currentConfig;
return { ...commonNotification, config };
case 'pager-duty':
currentConfig = notification.config;
config = currentConfig;
return { ...commonNotification, config };
case 'webhook':
currentConfig = notification.config;
config = {
url: currentConfig.url,
secret: currentConfig.secret,
};
return { ...commonNotification, config };
default:
throw new Error(`Incompatible notification type ${notification.type}`);
}
};
exports.constructNotification = constructNotification;
const isResource = (item) => {
return !!item;
};
exports.isResource = isResource;
const getDefenderAction = (resource, actions) => {
if (!resource)
return undefined;
if ((0, exports.isDefenderId)(resource))
return actions.find((a) => a.actionId === resource);
return actions.find((a) => a.name === resource.name);
};
const getDefenderContract = (resource, contracts) => {
if (!resource)
return undefined;
if ((0, exports.isDefenderId)(resource))
return contracts.find((a) => `${a.network}-${a.address}` === resource);
return contracts.find((a) => `${a.network}-${a.address}` === `${resource.network}-${resource.address}`);
};
const parseMonitorAbi = (abi) => {
// Because the way AbiType is typed (string | string[]), a list with 1 string item is interpreted as a string rather than string[]
// Therefore, JSON.parse may fail if the string is not a valid JSON
try {
return abi && JSON.stringify(typeof abi === 'string' ? JSON.parse(abi) : abi);
}
catch (e) {
return abi && JSON.stringify([abi]);
}
};
const constructMonitor = (context, resources, stackResourceId, monitor, notifications, actions, blockwatchers, contracts) => {
const actionCondition = getDefenderAction(monitor['action-condition'], actions);
const actionTrigger = getDefenderAction(monitor['action-trigger'], actions);
const notifyConfig = monitor['notify-config'];
const threshold = monitor['alert-threshold'];
const notificationChannels = notifyConfig.channels
.map((notification) => {
const maybeNotification = (0, exports.getEquivalentResource)(context, notification, resources?.notifications, notifications, 'Notifications');
return maybeNotification?.notificationId;
})
.filter(exports.isResource);
// !NOTE: This depends on Contracts being deployed before Monitors
// otherwise getDefenderContract will return old values
const monitorContracts = monitor.contracts?.map((contract) => getDefenderContract(contract, contracts));
// if monitor.abi is defined, we use that over the first entry from monitorContracts by default
const monitorABI = parseMonitorAbi(monitor.abi) || monitorContracts?.[0]?.abi;
// Pull addresses from either monitor.addresses or monitor.contracts
const monitorAddresses = (monitorContracts &&
monitorContracts.map((contract) => {
if (!contract) {
throw new Error('Contract not found in Defender');
}
return contract.address;
})) ||
monitor.addresses;
if (!monitorAddresses && monitor.type === 'BLOCK') {
throw new Error('BLOCK monitor must have either addresses or contracts defined');
}
const commonMonitor = {
type: monitor.type,
name: monitor.name,
network: monitor.network,
addresses: monitorAddresses,
abi: monitorABI,
paused: monitor.paused,
actionCondition: actionCondition && actionCondition.actionId,
actionTrigger: actionTrigger && actionTrigger.actionId,
alertThreshold: threshold && {
amount: threshold.amount,
windowSeconds: threshold['window-seconds'],
},
alertMessageBody: notifyConfig.message,
alertMessageSubject: notifyConfig['message-subject'],
alertTimeoutMs: notifyConfig.timeout,
notificationChannels,
severityLevel: notifyConfig['severity-level'],
riskCategory: monitor['risk-category'],
stackResourceId: stackResourceId,
};
if (monitor.type === 'FORTA') {
const fortaMonitor = {
...commonMonitor,
type: 'FORTA',
privateFortaNodeId: monitor['forta-node-id'],
agentIDs: monitor['agent-ids'],
fortaConditions: {
alertIDs: monitor.conditions && monitor.conditions['alert-ids'],
minimumScannerCount: (monitor.conditions && monitor.conditions['min-scanner-count']) || 1,
severity: monitor.conditions?.severity,
},
fortaLastProcessedTime: monitor['forta-last-processed-time'],
};
return fortaMonitor;
}
if (monitor.type === 'BLOCK') {
const compatibleBlockWatcher = blockwatchers.find((b) => b.confirmLevel === monitor['confirm-level']);
if (!compatibleBlockWatcher) {
throw new Error(`A blockwatcher with confirmation level (${monitor['confirm-level']}) does not exist on ${monitor.network}. Choose another confirmation level.`);
}
const blockMonitor = {
...commonMonitor,
type: 'BLOCK',
network: monitor.network,
addresses: monitorAddresses,
confirmLevel: compatibleBlockWatcher.confirmLevel,
skipABIValidation: monitor['skip-abi-validation'],
eventConditions: monitor.conditions &&
monitor.conditions.event &&
monitor.conditions.event.map((c) => {
return {
eventSignature: c.signature,
expression: c.expression,
};
}),
functionConditions: monitor.conditions &&
monitor.conditions.function &&
monitor.conditions.function.map((c) => {
return {
functionSignature: c.signature,
expression: c.expression,
};
}),
txCondition: monitor.conditions && monitor.conditions.transaction,
};
return blockMonitor;
}
throw new Error('Incompatible monitor type. Type must be either FORTA or BLOCK');
};
exports.constructMonitor = constructMonitor;
const validateAdditionalPermissionsOrThrow = async (context, resources, resourceType) => {
if (!resources)
return;
const teamApiKey = (0, exports.getTeamAPIkeysOrThrow)(context);
switch (resourceType) {
case 'Monitors':
// Check for access to Actions
// Enumerate all monitors, and check if any monitor has an action associated
const monitorssWithActions = Object.values(resources).filter((r) => !!r['action-condition'] || !!r['action-trigger']);
// If there are monitors with actions associated, then try to list actions
if (!lodash_1.default.isEmpty(monitorssWithActions)) {
try {
await (0, exports.getActionClient)(teamApiKey).list();
return;
}
catch (e) {
// catch the error and verify it is an unauthorised access error
if ((0, exports.isUnauthorisedError)(e)) {
// if this fails (due to permissions issue), we re-throw the error with more context
throw new Error('At least one Monitor is associated with an Action. Additional API key permissions are required to access Actions. Alternatively, remove the association with the action to complete this action.');
}
// also throw with original error if its not a permission issue
throw e;
}
}
case 'Actions':
// Check for access to Relayers
// Enumerate all actions, and check if any action has a relayer associated
const actionsWithRelayers = Object.values(resources).filter((r) => !!r.relayer);
// If there are actions with relayers associated, then try to list relayers
if (!lodash_1.default.isEmpty(actionsWithRelayers)) {
try {
await (0, exports.getRelayClient)(teamApiKey).list();
return;
}
catch (e) {
// catch the error and verify it is an unauthorised access error
if ((0, exports.isUnauthorisedError)(e)) {
// if this fails (due to permissions issue), we re-throw the error with more context
throw new Error('At least one Action is associated with a Relayer. Additional API key permissions are required to access Relayers. Alternatively, remove the association with the relayer to complete this action.');
}
// also throw with original error if its not a permission issue
throw e;
}
}
// No other resources require additional permissions
default:
return;
}
};
exports.validateAdditionalPermissionsOrThrow = validateAdditionalPermissionsOrThrow;
const isUnauthorisedError = (e) => {
try {
const error = e.response.status;
return error === 403;
}
catch {
// if it is not a DefenderAPIError,
// the error is most likely caused due to something else
return false;
}
};
exports.isUnauthorisedError = isUnauthorisedError;
const formatABI = (abi) => {
return abi && JSON.stringify(typeof abi === 'string' ? JSON.parse(abi) : abi);
};
exports.formatABI = formatABI;
const isDefenderId = (resource) => {
return resource && typeof resource === 'string';
};
exports.isDefenderId = isDefenderId;
const removeDefenderIdReferences = (resources) => {
if (resources) {
for (const [id, resource] of Object.entries(resources)) {
if ((0, exports.isDefenderId)(resource)) {
delete resources[id];
}
}
}
return resources;
};
exports.removeDefenderIdReferences = removeDefenderIdReferences;
const isTenantNetwork = (network) => {
if (!network)
return false;
return !(0, defender_sdk_base_client_1.isValidNetwork)(network);
};
exports.isTenantNetwork = isTenantNetwork;
;