UNPKG

sfdx-hardis

Version:

Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards

541 lines (527 loc) 24.5 kB
/* jscpd:ignore-start */ import { SfCommand, Flags, requiredOrgFlagWithDeprecations } from '@salesforce/sf-plugins-core'; import { Messages } from '@salesforce/core'; import c from 'chalk'; import { isCI, sortCrossPlatform, uxLog } from '../../../../common/utils/index.js'; import { bulkQuery } from '../../../../common/utils/apiUtils.js'; import { soqlQuery } from '../../../../common/utils/apiUtils.js'; import { CONSTANTS, getConfig } from '../../../../config/index.js'; import { NotifProvider } from '../../../../common/notifProvider/index.js'; import { prompts } from '../../../../common/utils/prompts.js'; import { generateCsvFile, generateReportPath } from '../../../../common/utils/filesUtils.js'; import { getNotificationButtons, getOrgMarkdown, getSeverityIcon } from '../../../../common/utils/notifUtils.js'; import { setConnectionVariables } from '../../../../common/utils/orgUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('sfdx-hardis', 'org'); export default class DiagnoseAuditTrail extends SfCommand { static title = 'Diagnose content of Setup Audit Trail'; static description = `Export Audit trail into a CSV file with selected criteria, and highlight suspect actions Also detects updates of Custom Settings values (disable by defining \`SKIP_AUDIT_TRAIL_CUSTOM_SETTINGS=true\`) Regular setup actions performed in major orgs are filtered. - "" - createScratchOrg - changedsenderemail - deleteScratchOrg - loginasgrantedtopartnerbt - Certificate and Key Management - insertCertificate - Custom App Licenses - addeduserpackagelicense - granteduserpackagelicense - revokeduserpackagelicense - Customer Portal - createdcustomersuccessuser - CSPUserDisabled - Currency - updateddatedexchrate - Data Management - queueMembership - Email Administration - dkimRotationPreparationSuccessful - dkimRotationSuccessful - External Objects - xdsEncryptedFieldChange - Groups - groupMembership - Holidays - holiday_insert - Inbox mobile and legacy desktop apps - enableSIQUserNonEAC - siqUserAcceptedTOS - Manage Users - activateduser - createduser - changedcommunitynickname - changedemail - changedfederationid - changedpassword - changedinteractionuseroffon - changedinteractionuseronoff - changedmarketinguseroffon - changedmarketinguseronoff - changedofflineuseroffon - changedprofileforuserstdtostd - changedprofileforuser - changedprofileforusercusttostd - changedprofileforuserstdtocust - changedroleforusertonone - changedroleforuser - changedroleforuserfromnone - changedUserAdminVerifiedStatusVerified - changedUserEmailVerifiedStatusUnverified - changedUserEmailVerifiedStatusVerified - changedknowledgeuseroffon - changedsfcontentuseroffon - changedsupportuseroffon - changedusername - changedUserPhoneNumber - changedUserPhoneVerifiedStatusUnverified - changedUserPhoneVerifiedStatusVerified - deactivateduser - deleteAuthenticatorPairing - deleteTwoFactorInfo2 - deleteTwoFactorTempCode - frozeuser - insertAuthenticatorPairing - insertTwoFactorInfo2 - insertTwoFactorTempCode - lightningloginenroll - PermSetAssign - PermSetGroupAssign - PermSetGroupUnassign - PermSetLicenseAssign - PermSetUnassign - PermSetLicenseUnassign - registeredUserPhoneNumber - resetpassword - suNetworkAdminLogin - suNetworkAdminLogout - suOrgAdminLogin - suOrgAdminLogout - unfrozeuser - useremailchangesent - Mobile Administration - assigneduserstomobileconfig - Reporting Snapshots - createdReportJob - deletedReportJob - Sandboxes - DeleteSandbox By default, deployment user defined in .sfdx-hardis.yml targetUsername property will be excluded. You can define additional users to exclude in .sfdx-hardis.yml **monitoringExcludeUsernames** property. You can also add more sections / actions considered as not suspect using property **monitoringAllowedSectionsActions** Example: \`\`\`yaml monitoringExcludeUsernames: - deploymentuser@cloudity.com - marketingcloud@cloudity.com - integration-user@cloudity.com monitoringAllowedSectionsActions: "Some section": [] // Will ignore all actions from such section "Some other section": ["actionType1","actionType2","actionType3"] // Will ignore only those 3 actions from section "Some other section". Other actions in the same section will be considered as suspect. \`\`\` ## Excel output example ![](https://github.com/hardisgroupcom/sfdx-hardis/raw/main/docs/assets/images/screenshot-monitoring-audittrail-excel.jpg) ## Local output example ![](https://github.com/hardisgroupcom/sfdx-hardis/raw/main/docs/assets/images/screenshot-monitoring-audittrail-local.jpg) This command is part of [sfdx-hardis Monitoring](${CONSTANTS.DOC_URL_ROOT}/salesforce-monitoring-suspect-audit-trail/) and can output Grafana, Slack and MsTeams Notifications. `; static examples = [ '$ sf hardis:org:diagnose:audittrail', '$ sf hardis:org:diagnose:audittrail --excludeusers baptiste@titi.com', '$ sf hardis:org:diagnose:audittrail --excludeusers baptiste@titi.com,bertrand@titi.com', '$ sf hardis:org:diagnose:audittrail --lastndays 5', ]; static flags = { excludeusers: Flags.string({ char: 'e', description: 'Comma-separated list of usernames to exclude', }), lastndays: Flags.integer({ char: 't', description: 'Number of days to extract from today (included)', }), outputfile: Flags.string({ char: 'f', description: 'Force the path and name of output report file. Must end with .csv', }), debug: Flags.boolean({ char: 'd', default: false, description: messages.getMessage('debugMode'), }), websocket: Flags.string({ description: messages.getMessage('websocket'), }), skipauth: Flags.boolean({ description: 'Skip authentication check when a default username is required', }), 'target-org': requiredOrgFlagWithDeprecations, }; static requiresProject = false; excludeUsers = []; lastNdays; allowedSectionsActions = {}; debugMode = false; suspectRecords = []; suspectUsers = []; suspectUsersAndActions = {}; suspectActions = []; severityIconLog = getSeverityIcon('log'); severityIconWarning = getSeverityIcon('warning'); auditTrailRecords = []; outputFile; outputFilesRes = {}; /* jscpd:ignore-end */ async run() { const { flags } = await this.parse(DiagnoseAuditTrail); this.debugMode = flags.debug || false; this.excludeUsers = flags.excludeusers ? flags.excludeusers.split(',') : []; this.lastNdays = flags.lastndays; this.outputFile = flags.outputfile || null; const config = await getConfig('branch'); // If manual mode and lastndays not sent as parameter, prompt user await this.manageAuditTimeframe(); // Initialize exceptions that will not be considered as suspect this.initializeAllowedSectionsActions(); // Append custom sections & actions considered as not suspect if (config.monitoringAllowedSectionsActions) { this.allowedSectionsActions = Object.assign(this.allowedSectionsActions, config.monitoringAllowedSectionsActions); } const conn = flags['target-org'].getConnection(); uxLog("action", this, c.cyan(`Extracting Setup Audit Trail and detect suspect actions in ${conn.instanceUrl} ...`)); // Manage exclude users list const whereConstraint = this.manageExcludedUsers(config); // Fetch SetupAuditTrail records await this.queryAuditTrail(whereConstraint, conn); await this.handleCustomSettingsAudit(conn); // Summarize uxLog("action", this, c.cyan(`Results summary:`)); let statusCode = 0; let msg = 'No suspect Setup Audit Trail records has been found'; const suspectActionsWithCount = []; if (this.suspectRecords.length > 0) { statusCode = 1; msg = `${this.suspectRecords.length} suspect Setup Audit Trail records has been found`; this.suspectUsers = [...new Set(this.suspectUsers)]; sortCrossPlatform(this.suspectUsers); const suspectActionsSummary = {}; for (const suspectAction of this.suspectActions) { suspectActionsSummary[suspectAction] = (suspectActionsSummary[suspectAction] || 0) + 1; } for (const suspectAction of Object.keys(suspectActionsSummary)) { suspectActionsWithCount.push(`${suspectAction} (${suspectActionsSummary[suspectAction]})`); } sortCrossPlatform(suspectActionsWithCount); uxLog("other", this, 'Suspect records list'); uxLog("other", this, JSON.stringify(this.suspectRecords, null, 2)); let logMsg = ''; logMsg += c.yellow(msg) + '\n\n'; logMsg += c.yellow('Related users:') + '\n'; for (const user of this.suspectUsers) { logMsg += c.yellow(`- ${user}` + ' (' + this.suspectUsersAndActions[user].actions.join(', ') + ")") + '\n'; } logMsg += '\n' + c.yellow('Related actions:') + '\n'; for (const action of suspectActionsWithCount) { logMsg += c.yellow(`- ${action}`) + '\n'; } logMsg += '\n'; uxLog("other", this, logMsg); } else { uxLog("success", this, c.green(msg)); } // Generate output CSV file this.outputFile = await generateReportPath('audit-trail', this.outputFile); this.outputFilesRes = await generateCsvFile(this.auditTrailRecords, this.outputFile, { fileTitle: 'Suspect Actions' }); // Manage notifications const orgMarkdown = await getOrgMarkdown(flags['target-org']?.getConnection()?.instanceUrl); const notifButtons = await getNotificationButtons(); let notifSeverity = 'log'; let notifText = `No suspect Setup Audit Trail records has been found in ${orgMarkdown}`; let notifAttachments = []; if (this.suspectRecords.length > 0) { notifSeverity = 'warning'; notifText = `${this.suspectRecords.length} suspect Setup Audit Trail records have been found in ${orgMarkdown}`; let notifDetailText = ``; notifDetailText += '*Related users*:\n'; for (const user of this.suspectUsers) { notifDetailText += `• ${user + " (" + this.suspectUsersAndActions[user].actions.join(', ') + ")"}\n`; } notifDetailText += '\n'; notifDetailText += '*Related actions*:\n'; for (const action of suspectActionsWithCount) { notifDetailText += `• ${action}\n`; } notifAttachments = [{ text: notifDetailText }]; } await setConnectionVariables(flags['target-org']?.getConnection()); // Required for some notifications providers like Email await NotifProvider.postNotifications({ type: 'AUDIT_TRAIL', text: notifText, attachments: notifAttachments, buttons: notifButtons, severity: notifSeverity, attachedFiles: this.outputFilesRes.xlsxFile ? [this.outputFilesRes.xlsxFile] : [], logElements: this.auditTrailRecords, data: { metric: this.suspectRecords.length }, metrics: { SuspectMetadataUpdates: this.suspectRecords.length, }, }); if ((this.argv || []).includes('audittrail')) { process.exitCode = statusCode; } // Return an object to be displayed with --json return { status: statusCode, message: msg, suspectRecords: this.suspectRecords, suspectUsers: this.suspectUsers, csvLogFile: this.outputFile, }; } async queryAuditTrail(whereConstraint, conn) { const auditTrailQuery = `SELECT CreatedDate,CreatedBy.Username,CreatedBy.Name,Action,Section,Display,ResponsibleNamespacePrefix,DelegateUser ` + `FROM SetupAuditTrail ` + whereConstraint + `ORDER BY CreatedDate DESC`; const queryRes = await bulkQuery(auditTrailQuery, conn); this.auditTrailRecords = queryRes.records.map((record) => { const section = record?.Section || ''; record.Suspect = false; record.severity = 'log'; record.severityIcon = this.severityIconLog; // Unallowed actions if ((this.allowedSectionsActions[section] && this.allowedSectionsActions[section].length > 0 && !this.allowedSectionsActions[section].includes(record.Action)) || !this.allowedSectionsActions[section]) { record.Suspect = true; record.SuspectReason = `Manual config in unallowed section ${section} with action ${record.Action}`; record.severity = 'warning'; record.severityIcon = this.severityIconWarning; this.suspectRecords.push(record); const suspectUserDisplayName = `${record['CreatedBy.Name']}`; this.suspectUsers.push(suspectUserDisplayName); const actionFullName = `${section} - ${record.Action}`; this.suspectActions.push(actionFullName); if (!this.suspectUsersAndActions[suspectUserDisplayName]) { this.suspectUsersAndActions[suspectUserDisplayName] = { name: record['CreatedBy.Name'], actions: [], }; } const suspectUserActions = this.suspectUsersAndActions[suspectUserDisplayName].actions; if (!suspectUserActions.includes(record.Action)) { suspectUserActions.push(record.Action); } this.suspectUsersAndActions[suspectUserDisplayName].actions = suspectUserActions; return record; } return record; }); } async handleCustomSettingsAudit(conn) { if (process.env?.SKIP_AUDIT_TRAIL_CUSTOM_SETTINGS === "true") { uxLog("action", this, c.cyan(`Skipping Custom Settings modifications as SKIP_AUDIT_TRAIL_CUSTOM_SETTINGS=true has been found`)); return; } // Add custom settings tracking uxLog("action", this, c.cyan(`List available custom settings...`)); uxLog("log", this, c.grey(`(Define SKIP_AUDIT_TRAIL_CUSTOM_SETTINGS=true if you don't want them)`)); const customSettingsQuery = `SELECT QualifiedApiName, Label FROM EntityDefinition WHERE IsCustomSetting = true`; const customSettingsResult = await soqlQuery(customSettingsQuery, conn); uxLog("action", this, c.cyan(`Analyze updates in ${customSettingsResult.records.length} Custom Settings...`)); let whereConstraintCustomSetting = `WHERE LastModifiedDate = LAST_N_DAYS:${this.lastNdays}` + ` AND LastModifiedBy.Username != NULL `; if (this.excludeUsers.length > 0) { whereConstraintCustomSetting += `AND LastModifiedBy.Username NOT IN ('${this.excludeUsers.join("','")}') `; } // Get custom settings modifications const customSettingModifications = []; for (const cs of customSettingsResult.records) { try { const result = await soqlQuery(`SELECT Id, LastModifiedDate, LastModifiedBy.Name, LastModifiedBy.Username FROM ${cs.QualifiedApiName} ` + whereConstraintCustomSetting, conn); if (result.records.length > 0) { for (const record of result.records) { customSettingModifications.push({ CreatedDate: record.LastModifiedDate, 'CreatedBy.Name': record['LastModifiedBy']?.['Name'], 'CreatedBy.Username': record['LastModifiedBy']?.['Username'], 'LastModifiedBy.Name': record['LastModifiedBy']?.['Name'], 'LastModifiedBy.Username': record['LastModifiedBy']?.['Username'], Action: `customSetting${cs.QualifiedApiName}`, Section: 'Custom Settings', Display: `Updated custom setting ${cs.Label} (${cs.QualifiedApiName})`, ResponsibleNamespacePrefix: null, DelegateUser: null, Suspect: true, severity: 'warning', severityIcon: getSeverityIcon('warning'), SuspectReason: `CustomSettingUpdate` }); } } } catch (error) { uxLog("error", this, c.red(`Error querying Custom Setting ${cs.Label}: ${error}`)); continue; } } // Add custom setting updates to audit trail records if (customSettingModifications.length > 0) { uxLog("warning", this, c.yellow(`Found ${customSettingModifications.length} Custom Setting updates`)); this.auditTrailRecords.push(...customSettingModifications); // Add to suspect records for (const csUpdate of customSettingModifications) { this.suspectRecords.push(csUpdate); const suspectUserDisplayName = csUpdate['LastModifiedBy.Name']; this.suspectUsers.push(suspectUserDisplayName); const actionFullName = `${csUpdate.Section} - ${csUpdate.Display}`; this.suspectActions.push(actionFullName); if (!this.suspectUsersAndActions[suspectUserDisplayName]) { this.suspectUsersAndActions[suspectUserDisplayName] = { name: csUpdate['LastModifiedBy.Name'], actions: [] }; } if (!this.suspectUsersAndActions[suspectUserDisplayName].actions.includes(csUpdate.Action)) { this.suspectUsersAndActions[suspectUserDisplayName].actions.push(csUpdate.Action); } } } } initializeAllowedSectionsActions() { this.allowedSectionsActions = { '': ['createScratchOrg', 'changedsenderemail', 'deleteScratchOrg', 'loginasgrantedtopartnerbt'], 'Certificate and Key Management': ['insertCertificate'], 'Custom App Licenses': [ 'addeduserpackagelicense', 'granteduserpackagelicense', 'revokeduserpackagelicense' ], 'Customer Portal': [ 'createdcustomersuccessuser', 'CSPUserDisabled' ], Currency: ['updateddatedexchrate'], 'Data Management': ['queueMembership'], 'Email Administration': ['dkimRotationSuccessful', 'dkimRotationPreparationSuccessful'], 'External Objects': ['xdsEncryptedFieldChange'], Holidays: ['holiday_insert'], 'Inbox mobile and legacy desktop apps': [ 'enableSIQUserNonEAC', 'siqUserAcceptedTOS' ], Groups: ['groupMembership'], 'Manage Territories': ['tm2_userAddedToTerritory', 'tm2_userRemovedFromTerritory'], 'Manage Users': [ 'activateduser', 'createduser', 'changedcommunitynickname', 'changedemail', 'changedfederationid', 'changedinteractionuseroffon', 'changedinteractionuseronoff', 'changedmarketinguseroffon', 'changedmarketinguseronoff', 'changedManager', "changedofflineuseroffon", 'changedprofileforuser', 'changedprofileforusercusttostd', 'changedprofileforuserstdtocust', 'changedroleforusertonone', 'changedroleforuser', 'changedroleforuserfromnone', 'changedpassword', "changedprofileforuserstdtostd", 'changedsfcontentuseroffon', 'changedUserAdminVerifiedStatusVerified', 'changedUserEmailVerifiedStatusUnverified', 'changedUserEmailVerifiedStatusVerified', 'changedknowledgeuseroffon', 'changedsupportuseroffon', 'changedusername', 'changedUserPhoneNumber', 'changedUserPhoneVerifiedStatusUnverified', 'changedUserPhoneVerifiedStatusVerified', 'deactivateduser', 'deleteAuthenticatorPairing', 'deleteTwoFactorInfo2', 'deleteTwoFactorTempCode', 'frozeuser', 'insertAuthenticatorPairing', 'insertTwoFactorInfo2', 'insertTwoFactorTempCode', 'lightningloginenroll', 'PermSetAssign', 'PermSetGroupAssign', 'PermSetGroupUnassign', 'PermSetLicenseAssign', 'PermSetUnassign', 'PermSetLicenseUnassign', 'registeredUserPhoneNumber', 'resetpassword', 'suNetworkAdminLogin', 'suNetworkAdminLogout', 'suOrgAdminLogin', 'suOrgAdminLogout', 'unfrozeuser', 'useremailchangesent', ], 'Mobile Administration': ['assigneduserstomobileconfig'], 'Reporting Snapshots': ['createdReportJob', 'deletedReportJob'], Sandboxes: ['DeleteSandbox'], }; } async manageAuditTimeframe() { if (!isCI && !this.lastNdays) { const lastNdaysResponse = await prompts({ type: 'select', name: 'lastndays', message: 'Please select the number of days in the past from today you want to detect suspiscious setup activities', description: 'Choose the timeframe for analyzing audit trail records to detect suspicious administrative activities', placeholder: 'Select number of days', choices: [ { title: `1`, value: 1 }, { title: `2`, value: 2 }, { title: `3`, value: 3 }, { title: `4`, value: 4 }, { title: `5`, value: 5 }, { title: `6`, value: 6 }, { title: `7`, value: 7 }, { title: `14`, value: 14 }, { title: `30`, value: 30 }, { title: `60`, value: 60 }, { title: `90`, value: 90 }, { title: `180`, value: 180 }, ], }); this.lastNdays = lastNdaysResponse.lastndays; } else { this.lastNdays = this.lastNdays || 1; } } manageExcludedUsers(config) { if (this.excludeUsers.length === 0) { if (config.targetUsername) { this.excludeUsers.push(config.targetUsername); } if (config.monitoringExcludeUsernames) { this.excludeUsers.push(...config.monitoringExcludeUsernames); } } let whereConstraint = `WHERE CreatedDate = LAST_N_DAYS:${this.lastNdays}` + ` AND CreatedBy.Username != NULL `; if (this.excludeUsers.length > 0) { whereConstraint += `AND CreatedBy.Username NOT IN ('${this.excludeUsers.join("','")}') `; } uxLog("log", this, c.grey(`Excluded users are ${this.excludeUsers.join(',') || 'None'}`)); uxLog("log", this, c.grey(`Use argument --excludeusers or .sfdx-hardis.yml property monitoringExcludeUsernames to exclude more users`)); return whereConstraint; } } //# sourceMappingURL=audittrail.js.map