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
247 lines (241 loc) • 13.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 fs from 'fs-extra';
import * as path from "path";
import { createTempDir, execCommand, uxLog } from '../../../../common/utils/index.js';
import { soqlQuery } from '../../../../common/utils/apiUtils.js';
import { NotifProvider } from '../../../../common/notifProvider/index.js';
import { generateCsvFile, generateReportPath } from '../../../../common/utils/filesUtils.js';
import { getNotificationButtons, getOrgMarkdown, getSeverityIcon } from '../../../../common/utils/notifUtils.js';
import moment from 'moment';
import columnify from 'columnify';
import { CONSTANTS } from '../../../../config/index.js';
import sortArray from 'sort-array';
import { createBlankSfdxProject } from '../../../../common/utils/projectUtils.js';
import { parseXmlFile } from '../../../../common/utils/xmlUtils.js';
import { setConnectionVariables } from '../../../../common/utils/orgUtils.js';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');
export default class DiagnoseUnusedConnectedApps extends SfCommand {
static title = 'Unused Connected Apps in an org';
static allowedInactiveConnectedApps = [
"Ant Migration Tool",
"Chatter Desktop",
"Chatter Mobile for BlackBerry",
"Force.com IDE",
"OIQ_Integration",
"Salesforce CLI",
"Salesforce Files",
"Salesforce Mobile Dashboards",
"Salesforce Touch",
"Salesforce for Outlook",
"SalesforceA",
"SalesforceA for Android",
"SalesforceA for iOS",
"SalesforceDX Namespace Registry",
"SalesforceIQ"
];
static description = `Request objects ConnectedApp, LoginHistory and OAuthToken to find which connected apps might not be used anymore, and could be deleted for security / technical debt reasons.
Check with Connected Apps metadatas if the app is still active (inactive = "Admin Users are pre-authorized + no Profile or Permission set assigned")
The following default Salesforce Connected Apps are ignored:
- ${this.allowedInactiveConnectedApps.join("\n- ")}
You can add more ignored apps by defining a comma-separated list of names in variable ALLOWED_INACTIVE_CONNECTED_APPS
_Example: ALLOWED_INACTIVE_CONNECTED_APPS=My App 1,My App 2, My App 3_
This command is part of [sfdx-hardis Monitoring](${CONSTANTS.DOC_URL_ROOT}/salesforce-monitoring-release-updates/) and can output Grafana, Slack and MsTeams Notifications.
`;
static examples = [
'$ sf hardis:org:diagnose:unused-connected-apps',
];
static flags = {
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;
debugMode = false;
tmpSfdxProjectPath;
connectedAppResults = [];
outputFile;
outputFilesRes = {};
/* jscpd:ignore-end */
async run() {
const { flags } = await this.parse(DiagnoseUnusedConnectedApps);
this.debugMode = flags.debug || false;
this.outputFile = flags.outputfile || null;
const conn = flags['target-org'].getConnection();
// Collect all Connected Apps
uxLog(this, c.cyan(`Extracting the whole list of Connected Apps from ${conn.instanceUrl} ...`));
const allConnectedAppsQuery = `SELECT Name,CreatedBy.Name,CreatedDate,LastModifiedBy.Name,LastModifiedDate,OptionsAllowAdminApprovedUsersOnly FROM ConnectedApplication ORDER BY Name`;
const allConnectedAppsQueryRes = await soqlQuery(allConnectedAppsQuery, conn);
const allConnectedApps = allConnectedAppsQueryRes.records;
// Collect all Connected Apps metadata in a blank project
const tmpDirForSfdxProject = await createTempDir();
this.tmpSfdxProjectPath = await createBlankSfdxProject(tmpDirForSfdxProject);
uxLog(this, c.cyan(`Retrieve ConnectedApp Metadatas from ${conn.instanceUrl} ...`));
await execCommand(`sf project retrieve start -m ConnectedApp --target-org ${conn.username}`, this, { cwd: this.tmpSfdxProjectPath, fail: true, output: true });
// Collect all Connected Apps used in LoginHistory table
uxLog(this, c.cyan(`Extracting all applications found in LoginHistory object from ${conn.instanceUrl} ...`));
const allAppsInLoginHistoryQuery = `SELECT Application FROM LoginHistory GROUP BY Application ORDER BY Application`;
const allAppsInLoginHistoryQueryRes = await soqlQuery(allAppsInLoginHistoryQuery, conn);
const allAppsInLoginHistoryNames = allAppsInLoginHistoryQueryRes.records.map(loginHistory => loginHistory.Application);
// Perform analysis
uxLog(this, c.cyan(`Starting analysis...`));
this.connectedAppResults = await Promise.all(allConnectedApps.map(async (connectedApp) => {
return await this.analyzeConnectedApp(allAppsInLoginHistoryNames, connectedApp, conn);
}));
uxLog(this, c.cyan(`Analysis complete. Deleting temporary project files...`));
await fs.rm(tmpDirForSfdxProject, { recursive: true });
this.connectedAppResults = sortArray(this.connectedAppResults, {
by: ['severity', 'Name'],
order: ['severity', 'asc'],
customOrders: {
severity: ["critical", "error", "warning", "info", "success", "log"]
}
});
const numberWarnings = this.connectedAppResults.filter(app => app.severity === "warning").length;
// Process result
if (this.connectedAppResults.length > 0) {
// Build notification
const orgMarkdown = await getOrgMarkdown(flags['target-org']?.getConnection()?.instanceUrl);
const notifButtons = await getNotificationButtons();
const notifSeverity = numberWarnings > 0 ? 'warning' : 'log';
const notifText = `${numberWarnings} Connected Apps to check have been found in ${orgMarkdown}`;
let notifDetailText = '';
for (const connectedApp of this.connectedAppResults.filter(app => app.severity === "warning")) {
notifDetailText += `• *${connectedApp.Name}*\n`;
}
const notifAttachments = [{ text: notifDetailText }];
// Post notif
await setConnectionVariables(flags['target-org']?.getConnection()); // Required for some notifications providers like Email
await NotifProvider.postNotifications({
type: 'CONNECTED_APPS',
text: notifText,
attachments: notifAttachments,
buttons: notifButtons,
severity: notifSeverity,
attachedFiles: this.outputFilesRes.xlsxFile ? [this.outputFilesRes.xlsxFile] : [],
logElements: this.connectedAppResults,
data: { metric: numberWarnings },
metrics: {
ConnectedApps: numberWarnings,
},
});
// Display output
const connectedAppsLight = this.connectedAppResults.filter(app => app.severity === "warning").map(connectedApp => {
return {
SeverityIcon: connectedApp.severityIcon,
ConnectedApp: connectedApp.Name,
AppLastModifiedDate: moment(connectedApp.LastModifiedDate).format('ll'),
AppLastModifiedBy: connectedApp.LastModifiedBy,
LastOAuthUsageDate: connectedApp.LastOAuthUsageDate ? moment(connectedApp.LastOAuthUsageDate).format('ll') : '',
LastOAuthUsageBy: connectedApp.LastOAuthUsageDate,
SeverityReason: connectedApp.severityReason,
};
});
uxLog(this, c.yellow(`${numberWarnings} Connected Apps to check have been found` + "\n" + columnify(connectedAppsLight)));
uxLog(this, c.yellow("See more details in report files below"));
// Generate output CSV file
this.outputFile = await generateReportPath('connected-apps', this.outputFile);
this.outputFilesRes = await generateCsvFile(this.connectedAppResults, this.outputFile);
}
// Return an object to be displayed with --json
return {
status: numberWarnings > 0 ? 1 : 0,
allConnectedAppResults: this.connectedAppResults,
csvLogFile: this.outputFile,
};
}
async analyzeConnectedApp(allAppsInLoginHistoryNames, connectedApp, conn) {
let loginHistoryFound = true;
let reason = "Found in Login History";
let severity = !allAppsInLoginHistoryNames.includes(connectedApp.Name) ? 'warning' : 'log';
if (severity === "warning") {
loginHistoryFound = false;
reason = "Not Found in Login History";
}
// Check OAuthToken
({ severity, reason } = await this.checkOAuthToken(connectedApp, conn, loginHistoryFound, severity, reason));
// If OAuthToken < 6 months found, check on the metadata if the app is not available
if (severity === "warning") {
({ severity, reason } = await this.checkNotAccessible(connectedApp, severity, reason));
}
// Check if app name is in allowedInactiveConnectedApps
const additionalIgnoredConnectedApps = process.env?.ALLOWED_INACTIVE_CONNECTED_APPS ? process.env?.ALLOWED_INACTIVE_CONNECTED_APPS.split(",") : [];
const allowedInactiveConnectedApps = DiagnoseUnusedConnectedApps.allowedInactiveConnectedApps.concat(additionalIgnoredConnectedApps);
if (severity === "warning" && allowedInactiveConnectedApps.includes(connectedApp.Name)) {
severity = "info";
reason = "Member of ignored connected apps";
}
// Build result
const severityIcon = getSeverityIcon(severity);
connectedApp.CreatedBy = connectedApp?.CreatedBy?.Name || 'Not set';
connectedApp.LastModifiedBy = connectedApp?.LastModifiedBy?.Name || 'Not set';
connectedApp.loginHistoryFound = loginHistoryFound;
connectedApp.severityReason = reason;
delete connectedApp.attributes;
return Object.assign({
severityIcon: severityIcon,
severity: severity,
}, connectedApp);
}
async checkOAuthToken(connectedApp, conn, loginHistoryFound, severity, reason) {
uxLog(this, c.grey(`Looking in OAuthToken for last usage of ${connectedApp.Name}...`));
const oAuthTokenQuery = `SELECT AppName,User.Name,LastUsedDate FROM OAuthToken WHERE AppName='${connectedApp.Name.replace(/'/g, "\\'")}' ORDER BY LastUsedDate DESC LIMIT 1`;
const oAuthTokenQueryRes = await soqlQuery(oAuthTokenQuery, conn);
const latestOAuthToken = oAuthTokenQueryRes.records.length === 1 ? oAuthTokenQueryRes.records[0] : null;
if (latestOAuthToken && latestOAuthToken.LastUsedDate) {
connectedApp.LastOAuthUsageDate = latestOAuthToken.LastUsedDate;
connectedApp.LastOAuthUsageBy = latestOAuthToken?.User?.Name || 'Not set';
const today = moment();
const lastUsage = moment(connectedApp.LastOAuthUsageDate);
if (today.diff(lastUsage, "months") < 6 && loginHistoryFound === false) {
severity = 'log';
reason = "OAuth Token < 6 months";
}
else {
reason = loginHistoryFound === false ? "Not Found in Login History and OAuth Token > 6 months" : reason;
}
}
else {
reason = loginHistoryFound === false ? "Not Found in Login History or used OAuth Token" : reason;
connectedApp.LastOAuthUsageDate = '';
connectedApp.LastOAuthUsageBy = '';
}
return { severity, reason };
}
async checkNotAccessible(connectedApp, severity, reason) {
const connectedAppMdFile = path.join(this.tmpSfdxProjectPath, "force-app", "main", "default", "connectedApps", `${connectedApp.Name}.connectedApp-meta.xml`);
if (fs.existsSync(connectedAppMdFile)) {
const connectedAppXml = await parseXmlFile(connectedAppMdFile);
if (connectedAppXml?.ConnectedApp?.oauthConfig[0]?.isAdminApproved[0] === "true" &&
(!this.hasProfiles(connectedAppXml)) &&
(!this.hasPermissionSets((connectedAppXml)))) {
severity = "info";
reason = "Not accessible (Admin pre-auth + no profiles and PS)";
}
}
return { severity, reason };
}
hasProfiles(connectedAppXml) {
return connectedAppXml?.ConnectedApp?.profileName?.length > 0;
}
hasPermissionSets(connectedAppXml) {
return connectedAppXml?.ConnectedApp?.permissionsetName?.length > 0;
}
}
//# sourceMappingURL=unused-connected-apps.js.map