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
JavaScript
/* 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

## Local output example

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