From d96a43f31dd0f2b48349fcf2504aa8254bccd89a Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 1 Jul 2026 19:46:31 +0530 Subject: [PATCH 1/3] Reporting command implementation(Audit, Action and Password). (#180) --- .../EnterpriseReportManager.ts | 35 + .../src/enterpriseReport/actionReport.ts | 518 +++++++++++++ KeeperSdk/src/enterpriseReport/auditReport.ts | 724 ++++++++++++++++++ KeeperSdk/src/enterpriseReport/index.ts | 54 ++ .../src/enterpriseReport/passwordReport.ts | 492 ++++++++++++ KeeperSdk/src/enterpriseReport/reportTypes.ts | 414 ++++++++++ KeeperSdk/src/enterpriseReport/reportUtils.ts | 36 + KeeperSdk/src/index.ts | 52 ++ KeeperSdk/src/utils/constants.ts | 43 ++ KeeperSdk/src/utils/index.ts | 3 + KeeperSdk/src/vault/KeeperVault.ts | 38 + examples/sdk_example/package.json | 3 + .../src/enterpriseReport/action_report.ts | 122 +++ .../src/enterpriseReport/audit_report.ts | 179 +++++ .../src/enterpriseReport/password_report.ts | 79 ++ .../sdk_example/src/utils/reportFormat.ts | 125 +++ keeperapi/src/commands.ts | 78 ++ 17 files changed, 2995 insertions(+) create mode 100644 KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts create mode 100644 KeeperSdk/src/enterpriseReport/actionReport.ts create mode 100644 KeeperSdk/src/enterpriseReport/auditReport.ts create mode 100644 KeeperSdk/src/enterpriseReport/index.ts create mode 100644 KeeperSdk/src/enterpriseReport/passwordReport.ts create mode 100644 KeeperSdk/src/enterpriseReport/reportTypes.ts create mode 100644 KeeperSdk/src/enterpriseReport/reportUtils.ts create mode 100644 examples/sdk_example/src/enterpriseReport/action_report.ts create mode 100644 examples/sdk_example/src/enterpriseReport/audit_report.ts create mode 100644 examples/sdk_example/src/enterpriseReport/password_report.ts create mode 100644 examples/sdk_example/src/utils/reportFormat.ts diff --git a/KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts b/KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts new file mode 100644 index 00000000..92b28e2d --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts @@ -0,0 +1,35 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' +import { runAuditReport } from './auditReport' +import { runActionReport } from './actionReport' +import type { + ActionReportOptions, + ActionReportResult, + AuditReportOptions, + AuditReportResult, +} from './reportTypes' +import type { AuthProvider } from './reportUtils' + +export class EnterpriseReportManager { + private readonly authProvider: AuthProvider + + constructor(authProvider: AuthProvider) { + this.authProvider = authProvider + } + + public async runAuditReport(options: AuditReportOptions = {}): Promise { + return runAuditReport(this.requireAuth(), options) + } + + public async runActionReport(options: ActionReportOptions = {}): Promise { + return runActionReport(this.requireAuth(), options) + } + + private requireAuth(): Auth { + const auth = this.authProvider() + if (!auth) { + throw new KeeperSdkError('You are not logged in. Please log in first.', ResultCodes.NOT_LOGGED_IN) + } + return auth + } +} diff --git a/KeeperSdk/src/enterpriseReport/actionReport.ts b/KeeperSdk/src/enterpriseReport/actionReport.ts new file mode 100644 index 00000000..c59fb2d3 --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/actionReport.ts @@ -0,0 +1,518 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { getAuditEventReportsCommand } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { actionUsers, UserAction } from '../users/actionUser' +import { deleteUsers } from '../users/deleteUser' +import { + DeleteUserStatus, + EnterpriseUserStatus, + formatTransferStatus, + formatUserStatus, +} from '../users/userTypes' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseNode, + type EnterpriseRoleTeamLink, + type EnterpriseRoleUserLink, + type EnterpriseTeamUserLink, + type EnterpriseUser, + type EnterpriseUserAliasLink, + type GetEnterpriseDataResponse, +} from '../teams/enterpriseData' +import { + ACTION_COLUMN_LABELS, + ACTION_DEFAULT_DAYS, + ACTION_DEFAULT_DAYS_BY_TARGET, + ACTION_EVENT_SUMMARY_ROW_LIMIT, + ACTION_REPORT_COLUMN_ORDER, + ACTION_STATUS_EVENT_TYPES, + ACTION_USERNAME_BATCH_SIZE, + ActionReportColumn, + AdminAction, + DEFAULT_ACTION_REPORT_COLUMNS, + SECONDS_PER_DAY, + SUPPORTED_ACTION_REPORT_COLUMNS, + TargetUserStatus, + type ActionReportEntry, + type ActionReportOptions, + type ActionReportResult, + type ActionResult, + type AuditReportFilterPayload, +} from './reportTypes' +import { assertSucceeded, chunkArray, resolveTimezone } from './reportUtils' + +const ACTION_REPORT_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.RoleUsers, + EnterpriseDataInclude.RoleTeams, + EnterpriseDataInclude.UserAliases, +] + +type AuditUsernameField = 'username' | 'to_username' + +type TargetAuditConfig = { + auditColumn: AuditUsernameField + auditFilterField: AuditUsernameField + pickCandidates: (users: EnterpriseUser[]) => EnterpriseUser[] + identity: (user: EnterpriseUser) => string +} + +const ALLOWED_ACTIONS: Readonly>> = { + [TargetUserStatus.NoLogon]: new Set([AdminAction.None, AdminAction.Lock]), + [TargetUserStatus.NoUpdate]: new Set([AdminAction.None]), + [TargetUserStatus.Locked]: new Set([AdminAction.None, AdminAction.Delete, AdminAction.Transfer]), + [TargetUserStatus.Invited]: new Set([AdminAction.None, AdminAction.Delete]), + [TargetUserStatus.NoRecovery]: new Set([AdminAction.None]), +} + +function usernameTargetConfig( + pickCandidates: (users: EnterpriseUser[]) => EnterpriseUser[] +): TargetAuditConfig { + return { + auditColumn: 'username', + auditFilterField: 'username', + pickCandidates, + identity: (user) => user.username, + } +} + +const TARGET_AUDIT_CONFIG: Record = { + [TargetUserStatus.NoLogon]: usernameTargetConfig((users) => + users.filter((user) => formatUserStatus(user) === 'Active') + ), + [TargetUserStatus.NoUpdate]: usernameTargetConfig((users) => + users.filter((user) => formatUserStatus(user) === 'Active') + ), + [TargetUserStatus.Locked]: { + auditColumn: 'to_username', + auditFilterField: 'to_username', + pickCandidates: (users) => users.filter((user) => formatUserStatus(user) === 'Locked'), + identity: (user) => user.username, + }, + [TargetUserStatus.Invited]: usernameTargetConfig((users) => + users.filter((user) => user.status === EnterpriseUserStatus.Invited) + ), + [TargetUserStatus.NoRecovery]: usernameTargetConfig((users) => + users.filter((user) => formatUserStatus(user) === 'Active') + ), +} + +export async function runActionReport( + auth: Auth, + options: ActionReportOptions = {} +): Promise { + const target = options.target ?? TargetUserStatus.NoLogon + const daysSince = options.daysSince ?? ACTION_DEFAULT_DAYS_BY_TARGET[target] ?? ACTION_DEFAULT_DAYS + const applyAction = options.applyAction ?? AdminAction.None + + validateActionReportOptions(target, applyAction, options.targetUser) + + const enterpriseData = new EnterpriseDataManager(auth) + const data = await enterpriseData.getData(ACTION_REPORT_INCLUDES) + await enterpriseData.decryptNodeNames(data.nodes || []) + await enterpriseData.getDisplayNames() + + const entries = await generateActionReportEntries(auth, data, { + target, + daysSince, + timezone: resolveTimezone(options.timezone), + nodeIds: resolveNodeFilter(data.nodes || [], options.node), + }) + + const actionResult = await applyAdminAction(auth, entries, { + applyAction, + targetUser: options.targetUser, + dryRun: options.dryRun === true, + }) + + const columns = resolveActionReportColumns(options.columns) + const headers = columns.map((column) => ACTION_COLUMN_LABELS[column]) + const rows = entries.map((entry) => columns.map((column) => actionReportEntryCell(entry, column))) + + return { + target, + headers, + rows, + entries, + actionResult, + } +} + +export function getAllowedActions(target: TargetUserStatus): AdminAction[] { + return [...ALLOWED_ACTIONS[target]] +} + +export function getDefaultDaysSince(target: TargetUserStatus): number { + return ACTION_DEFAULT_DAYS_BY_TARGET[target] ?? ACTION_DEFAULT_DAYS +} + +async function generateActionReportEntries( + auth: Auth, + data: GetEnterpriseDataResponse, + options: { + target: TargetUserStatus + daysSince: number + timezone: string + nodeIds: Set | null + } +): Promise { + const config = TARGET_AUDIT_CONFIG[options.target] + let candidates = config.pickCandidates(data.users || []) + + if (options.nodeIds) { + candidates = candidates.filter( + (user) => user.node_id != null && options.nodeIds!.has(user.node_id) + ) + } + if (candidates.length === 0) return [] + + const identities = candidates.map((user) => config.identity(user)).filter(Boolean) + const recentlyActive = await queryUsersWithRecentEvents( + auth, + config, + identities, + ACTION_STATUS_EVENT_TYPES[options.target], + options.daysSince, + options.timezone + ) + + const context = buildActionEntryContext(data) + return candidates + .filter((user) => { + const identity = config.identity(user).trim().toLowerCase() + return identity && !recentlyActive.has(identity) + }) + .map((user) => buildActionEntry(user, context)) +} + +async function queryUsersWithRecentEvents( + auth: Auth, + config: TargetAuditConfig, + identities: string[], + eventTypes: readonly string[], + daysSince: number, + timezone: string +): Promise> { + const active = new Set() + const createdFilter: AuditReportFilterPayload['created'] = { + min: Math.floor(Date.now() / 1000) - daysSince * SECONDS_PER_DAY, + } + + for (const batch of chunkArray(identities, ACTION_USERNAME_BATCH_SIZE)) { + const filter: AuditReportFilterPayload = { + created: createdFilter, + audit_event_type: [...eventTypes], + [config.auditFilterField]: batch, + } + + const response = await auth.executeRestCommand( + getAuditEventReportsCommand({ + report_type: 'span', + scope: 'enterprise', + timezone, + limit: ACTION_EVENT_SUMMARY_ROW_LIMIT, + aggregate: ['last_created'], + columns: [config.auditColumn], + filter, + }) + ) + assertSucceeded(response, 'get_audit_event_reports failed', ResultCodes.ACTION_REPORT_FAILED) + + for (const row of response.audit_event_overview_report_rows ?? []) { + const value = (row as Record)[config.auditColumn] + if (value == null || value === '') continue + active.add(String(value).trim().toLowerCase()) + } + } + + return active +} + +type ActionEntryContext = { + nodePaths: Map + userTeams: Map + userRoles: Map + userAliases: Map +} + +function buildActionEntryContext(data: GetEnterpriseDataResponse): ActionEntryContext { + const nodes = data.nodes || [] + const nodePaths = new Map( + nodes.map((node) => [ + node.node_id, + EnterpriseDataManager.getNodePath(nodes, node.node_id, { omitRoot: false }), + ]) + ) + const teamNames = new Map((data.teams || []).map((team) => [team.team_uid, team.name])) + const roleNames = new Map((data.roles || []).map((role) => [role.role_id, role.displayName || String(role.role_id)])) + + return { + nodePaths, + userTeams: buildUserTeamNames(data.team_users || [], teamNames), + userRoles: buildUserRoleNames(data.role_users || [], data.role_teams || [], data.team_users || [], roleNames), + userAliases: buildUserAliases(data.user_aliases || []), + } +} + +function buildActionEntry(user: EnterpriseUser, context: ActionEntryContext): ActionReportEntry { + return { + enterpriseUserId: user.enterprise_user_id, + email: user.username, + fullName: user.full_name || '', + status: formatUserStatus(user), + transferStatus: formatTransferStatus(user), + nodePath: context.nodePaths.get(user.node_id || 0) || '', + teams: context.userTeams.get(user.enterprise_user_id) || [], + roles: context.userRoles.get(user.enterprise_user_id) || [], + aliases: context.userAliases.get(user.enterprise_user_id) || [], + tfaEnabled: user.tfa_enabled === true, + } +} + +function buildUserTeamNames( + links: EnterpriseTeamUserLink[], + teamNames: Map +): Map { + const map = new Map>() + for (const link of links) { + if (!link.team_uid) continue + const name = teamNames.get(link.team_uid) || link.team_uid + const names = map.get(link.enterprise_user_id) ?? new Set() + names.add(name) + map.set(link.enterprise_user_id, names) + } + return new Map([...map.entries()].map(([id, names]) => [id, [...names].sort()])) +} + +function buildUserRoleNames( + roleUsers: EnterpriseRoleUserLink[], + roleTeams: EnterpriseRoleTeamLink[], + teamUsers: EnterpriseTeamUserLink[], + roleNames: Map +): Map { + const map = new Map>() + + for (const link of roleUsers) { + const roles = map.get(link.enterprise_user_id) ?? new Set() + roles.add(link.role_id) + map.set(link.enterprise_user_id, roles) + } + + const rolesForTeam = new Map>() + for (const link of roleTeams) { + if (!link.team_uid) continue + const roles = rolesForTeam.get(link.team_uid) ?? new Set() + roles.add(link.role_id) + rolesForTeam.set(link.team_uid, roles) + } + + for (const link of teamUsers) { + if (!link.team_uid) continue + const teamRoles = rolesForTeam.get(link.team_uid) + if (!teamRoles) continue + const userRoles = map.get(link.enterprise_user_id) ?? new Set() + teamRoles.forEach((roleId) => userRoles.add(roleId)) + map.set(link.enterprise_user_id, userRoles) + } + + return new Map( + [...map.entries()].map(([id, roleIds]) => [ + id, + [...roleIds].map((roleId) => roleNames.get(roleId) || String(roleId)).sort(), + ]) + ) +} + +function buildUserAliases(links: EnterpriseUserAliasLink[]): Map { + const map = new Map() + for (const link of links) { + if (!link.username) continue + const aliases = map.get(link.enterprise_user_id) + if (aliases) aliases.push(link.username) + else map.set(link.enterprise_user_id, [link.username]) + } + return map +} + +async function applyAdminAction( + auth: Auth, + entries: ActionReportEntry[], + options: { applyAction: AdminAction; targetUser?: string; dryRun: boolean } +): Promise { + const { applyAction, targetUser, dryRun } = options + + if (applyAction === AdminAction.None) { + return { action: AdminAction.None, status: 'none', affectedCount: 0, serverMessage: 'n/a' } + } + if (entries.length === 0) { + return { action: applyAction, status: 'no users matched', affectedCount: 0, serverMessage: 'n/a' } + } + if (dryRun) { + return { action: applyAction, status: 'dry run', affectedCount: entries.length, serverMessage: 'n/a' } + } + + const emails = entries.map((entry) => entry.email) + + try { + switch (applyAction) { + case AdminAction.Lock: { + const result = await actionUsers(auth, { action: UserAction.Lock, emails }) + return { + action: AdminAction.Lock, + status: result.success ? 'success' : 'partial', + affectedCount: result.succeeded, + serverMessage: `Succeeded: ${result.succeeded}, Skipped: ${result.skipped}, Failed: ${result.failed}`, + } + } + case AdminAction.Delete: { + const result = await deleteUsers(auth, { emails }) + const deleted = result.items.filter((item) => item.status === DeleteUserStatus.Deleted).length + return { + action: AdminAction.Delete, + status: result.success ? 'success' : 'partial', + affectedCount: deleted, + serverMessage: `Deleted: ${deleted}, Failed: ${result.failed}`, + } + } + case AdminAction.Transfer: { + if (!targetUser?.trim()) { + throw new KeeperSdkError( + '"targetUser" is required for transfer action.', + ResultCodes.ACTION_REPORT_TARGET_USER_REQUIRED + ) + } + throw new KeeperSdkError( + 'Account transfer is not yet supported in the JavaScript SDK.', + ResultCodes.ACTION_REPORT_TRANSFER_NOT_SUPPORTED + ) + } + default: + return { action: applyAction, status: 'unsupported', affectedCount: 0, serverMessage: 'n/a' } + } + } catch (err) { + return { + action: applyAction, + status: 'failed', + affectedCount: 0, + serverMessage: extractErrorMessage(err), + } + } +} + +function validateActionReportOptions( + target: TargetUserStatus, + applyAction: AdminAction, + targetUser: string | undefined +): void { + if (!ALLOWED_ACTIONS[target].has(applyAction)) { + throw new KeeperSdkError( + `Action "${applyAction}" is not allowed for target "${target}".`, + ResultCodes.ACTION_REPORT_INVALID_ACTION + ) + } + if (applyAction === AdminAction.Transfer && !targetUser?.trim()) { + throw new KeeperSdkError( + '"targetUser" is required when applyAction is "transfer".', + ResultCodes.ACTION_REPORT_TARGET_USER_REQUIRED + ) + } +} + +function resolveNodeFilter(nodes: EnterpriseNode[], nodeFilter: string | undefined): Set | null { + if (!nodeFilter?.trim()) return null + + const trimmed = nodeFilter.trim() + const numericId = Number(trimmed) + let rootNode: EnterpriseNode | undefined + + if (Number.isInteger(numericId)) { + rootNode = nodes.find((node) => node.node_id === numericId) + } + if (!rootNode) { + const lowered = trimmed.toLowerCase() + const matches = nodes.filter((node) => (node.displayName || '').trim().toLowerCase() === lowered) + if (matches.length === 1) rootNode = matches[0] + else if (matches.length > 1) { + throw new KeeperSdkError(`Multiple nodes match "${trimmed}".`, ResultCodes.ACTION_REPORT_NODE_NOT_UNIQUE) + } + } + if (!rootNode) { + throw new KeeperSdkError(`Node "${trimmed}" was not found.`, ResultCodes.ACTION_REPORT_NODE_NOT_FOUND) + } + + const children = new Map() + for (const node of nodes) { + const parentId = node.parent_id || 0 + const siblings = children.get(parentId) ?? [] + siblings.push(node.node_id) + children.set(parentId, siblings) + } + + const allowed = new Set([rootNode.node_id]) + const queue = [rootNode.node_id] + while (queue.length > 0) { + const current = queue.shift()! + for (const childId of children.get(current) || []) { + if (allowed.has(childId)) continue + allowed.add(childId) + queue.push(childId) + } + } + + return allowed +} + +function resolveActionReportColumns(input: ActionReportOptions['columns']): ActionReportColumn[] { + const defaultColumns: ActionReportColumn[] = [ActionReportColumn.UserId, ...DEFAULT_ACTION_REPORT_COLUMNS] + if (input == null) return defaultColumns + + const requested = + typeof input === 'string' + ? input.split(',').map((part) => part.trim()) + : input.map((part) => String(part).trim()) + + const allowed = new Set(SUPPORTED_ACTION_REPORT_COLUMNS) + const seen = new Set() + for (const column of requested) { + if (column && allowed.has(column)) seen.add(column as ActionReportColumn) + } + + if (seen.size === 0) return defaultColumns + + seen.add(ActionReportColumn.UserId) + return ACTION_REPORT_COLUMN_ORDER.filter((column) => seen.has(column)) +} + +function actionReportEntryCell(entry: ActionReportEntry, column: ActionReportColumn): string { + switch (column) { + case ActionReportColumn.UserId: + return String(entry.enterpriseUserId) + case ActionReportColumn.Email: + return entry.email + case ActionReportColumn.Name: + return entry.fullName + case ActionReportColumn.Status: + return entry.status + case ActionReportColumn.TransferStatus: + return entry.transferStatus + case ActionReportColumn.Node: + return entry.nodePath + case ActionReportColumn.TeamCount: + return String(entry.teams.length) + case ActionReportColumn.Teams: + return entry.teams.join(', ') + case ActionReportColumn.RoleCount: + return String(entry.roles.length) + case ActionReportColumn.Roles: + return entry.roles.join(', ') + case ActionReportColumn.Alias: + return entry.aliases.join(', ') + case ActionReportColumn.TwoFaEnabled: + return entry.tfaEnabled ? 'true' : 'false' + } +} diff --git a/KeeperSdk/src/enterpriseReport/auditReport.ts b/KeeperSdk/src/enterpriseReport/auditReport.ts new file mode 100644 index 00000000..71cce733 --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/auditReport.ts @@ -0,0 +1,724 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + getAuditEventDimensionsCommand, + getAuditEventReportsCommand, + getEnterpriseDataCommand, +} from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' +import { + AuditAggregate, + AuditReportFormat, + AuditReportOrder, + AUDIT_CREATED_BETWEEN_PATTERN, + AUDIT_DEFAULT_RAW_LIMIT, + AUDIT_DEFAULT_SUMMARY_LIMIT, + AUDIT_DIMENSION_API_LIMIT, + AUDIT_MISC_FIELDS, + AUDIT_NO_ARAM_RAW_LIMIT, + AUDIT_RAW_FIELDS, + AUDIT_RAW_PAGE_SIZE, + AUDIT_SUMMARY_MAX_LIMIT, + AUDIT_SYNTAX_HELP, + AUDIT_VIRTUAL_DIMENSIONS, + CREATED_PRESETS, + SUMMARY_REPORT_TYPES, +} from './reportTypes' +import type { + AuditDimensionEventType, + AuditDimensionIpAddress, + AuditDimensionKeeperVersion, + AuditDimensionRow, + AuditEventOverviewReportRow, + AuditReportFilter, + AuditReportFilterPayload, + AuditReportOptions, + AuditReportResult, + AuditSummaryReportType, + CreatedFilterCriteria, + CreatedPreset, + EnterpriseAuditLicense, +} from './reportTypes' +import { assertSucceeded, resolveTimezone, toAuditApiOrder } from './reportUtils' + +let dimensionCache = new Map() +let cachedUsername = '' +let syslogTemplates: Map | null = null + +export async function runAuditReport(auth: Auth, options: AuditReportOptions = {}): Promise { + if (options.syntaxHelp || !options.reportType) { + return buildSyntaxHelp(auth) + } + + const hasAram = await resolveHasAram(auth, options.hasAram) + const reportType = options.reportType + + if (reportType === 'dim') return runDimensionReport(auth, options) + if (reportType === 'raw') return runRawReport(auth, options, hasAram) + if ((SUMMARY_REPORT_TYPES as readonly string[]).includes(reportType)) { + return runSummaryReport(auth, options, hasAram, reportType as AuditSummaryReportType) + } + + throw new KeeperSdkError(`Unsupported report type "${reportType}".`, ResultCodes.AUDIT_INVALID_REPORT_TYPE) +} + +async function buildSyntaxHelp(auth: Auth): Promise { + const eventTypes = await loadDimension(auth, 'audit_event_type') + const eventTypeReference = eventTypes + .map((row) => { + if (!row || typeof row !== 'object') return null + const typed = row as AuditDimensionEventType + if (typed.id == null || !typed.name) return null + return { id: typed.id, name: typed.name } + }) + .filter((row): row is { id: number; name: string } => row !== null) + .sort((a, b) => a.id - b.id) + + const headers = ['id', 'name'] + const rows = eventTypeReference.map((row) => [String(row.id), row.name]) + return { + reportType: null, + headers, + rows, + syntaxHelp: AUDIT_SYNTAX_HELP, + eventTypeReference, + } +} + +async function runDimensionReport(auth: Auth, options: AuditReportOptions): Promise { + const columns = options.columns + if (!columns || columns.length !== 1) { + throw new KeeperSdkError( + '"dim" reports expect exactly one "columns" value.', + ResultCodes.AUDIT_DIMENSION_COLUMN_REQUIRED + ) + } + + const column = columns[0] + const fields = dimensionFields(column) + const rows = (await loadDimension(auth, column)).map((row) => dimensionCells(row, fields)) + + return { + reportType: 'dim', + headers: fields, + rows, + } +} + +async function runRawReport( + auth: Auth, + options: AuditReportOptions, + hasAram: boolean +): Promise { + let filter = await resolveFilter(auth, options.filter ?? {}) + let limit = options.limit + let order = options.order + + if (!hasAram) { + const requested = options.limit + limit = requested != null && requested > 0 ? Math.min(requested, AUDIT_NO_ARAM_RAW_LIMIT) : AUDIT_NO_ARAM_RAW_LIMIT + order = order ?? AuditReportOrder.Desc + if (!filter.created) { + filter.created = 'last_30_days' + } + } + + const reportFormat = options.reportFormat ?? AuditReportFormat.Message + const events = await fetchRawEvents(auth, { + filter: Object.keys(filter).length > 0 ? filter : undefined, + limit, + order, + timezone: options.timezone, + }) + const fields: string[] = [...AUDIT_RAW_FIELDS] + const miscFields = new Set(AUDIT_MISC_FIELDS) + const templates = + reportFormat === AuditReportFormat.Message ? await loadSyslogTemplates(auth) : new Map() + + if (reportFormat === AuditReportFormat.Message) fields.push('message') + + const rows: string[][] = [] + for (const event of events) { + if (reportFormat === AuditReportFormat.Fields) { + for (const key of Object.keys(event)) { + if (miscFields.has(key)) { + fields.push(key) + miscFields.delete(key) + } + } + } + rows.push( + fields.map((field) => + field === 'message' ? eventMessage(event, templates) : formatFieldValue(field, getAuditEventField(event, field), 'raw') + ) + ) + } + + return { + reportType: 'raw', + headers: fields, + rows, + events, + } +} + +async function runSummaryReport( + auth: Auth, + options: AuditReportOptions, + hasAram: boolean, + reportType: (typeof SUMMARY_REPORT_TYPES)[number] +): Promise { + if (!hasAram) { + throw new KeeperSdkError('Audit Reporting addon is not enabled.', ResultCodes.AUDIT_REPORTING_NOT_ENABLED) + } + + const limit = options.limit + if (typeof limit === 'number' && (limit < 0 || limit > AUDIT_SUMMARY_MAX_LIMIT)) { + throw new KeeperSdkError(`Invalid "limit" value: ${limit}`, ResultCodes.AUDIT_INVALID_LIMIT) + } + if (!options.columns?.length) { + throw new KeeperSdkError('"columns" parameter cannot be empty.', ResultCodes.AUDIT_COLUMNS_REQUIRED) + } + + const aggregates = + options.aggregates?.length ? options.aggregates : [AuditAggregate.Occurrences] + const events = await fetchSummaryEvents(auth, { + reportType, + filter: await resolveFilter(auth, options.filter), + limit, + order: options.order, + timezone: options.timezone, + columns: options.columns, + aggregates, + }) + + const fields = [...aggregates, ...(reportType !== 'span' ? ['created'] : []), ...options.columns] + const rows = events.map((event) => + fields.map((field) => formatFieldValue(field, getAuditEventField(event, field), reportType)) + ) + return { + reportType, + headers: fields, + rows, + events, + } +} + +async function fetchRawEvents( + auth: Auth, + options: { + filter?: AuditReportFilter + limit?: number + order?: AuditReportOrder + timezone?: string + } +): Promise { + const limit = options.limit ?? AUDIT_DEFAULT_RAW_LIMIT + if (limit === 0) return [] + + const isPaginated = limit < 0 || limit > AUDIT_RAW_PAGE_SIZE + let workingFilter = options.filter ? { ...options.filter } : undefined + const order = options.order ?? AuditReportOrder.Desc + const timezone = resolveTimezone(options.timezone) + + if (isPaginated && typeof workingFilter?.created === 'string') { + if ((CREATED_PRESETS as readonly string[]).includes(workingFilter.created)) { + workingFilter.created = expandCreatedPreset(workingFilter.created as CreatedPreset) + } + } + + const events: AuditEventOverviewReportRow[] = [] + let eventsReturned = 0 + let done = false + + while (!done) { + done = true + const queryLimit = isPaginated + ? limit <= 0 + ? AUDIT_RAW_PAGE_SIZE + : Math.min(AUDIT_RAW_PAGE_SIZE, limit - eventsReturned) + : limit + + const response = await auth.executeRestCommand( + getAuditEventReportsCommand({ + report_type: 'raw', + scope: 'enterprise', + timezone, + limit: queryLimit, + order: toAuditApiOrder(order), + filter: serializeFilter(workingFilter), + }) + ) + assertSucceeded(response, 'get_audit_event_reports failed', ResultCodes.AUDIT_REPORT_FAILED) + + let pageEvents = (response.audit_event_overview_report_rows ?? []) as AuditEventOverviewReportRow[] + if (!isPaginated && pageEvents.length > queryLimit) { + pageEvents = pageEvents.slice(0, queryLimit) + } + if (isPaginated && pageEvents.length === AUDIT_RAW_PAGE_SIZE) { + done = false + const lastEvent = pageEvents[pageEvents.length - 1] + const ts = Number(lastEvent.created) + let pos = pageEvents.length - 1 + while (pos > 900 && Number(pageEvents[pos].created) === ts) pos -= 1 + if (pos > 900) pageEvents = pageEvents.slice(0, pos) + else workingFilter = advanceCreatedFilter(workingFilter, ts + 1, order) + } + + events.push(...pageEvents) + eventsReturned += pageEvents.length + if ((limit > 0 && eventsReturned >= limit) || pageEvents.length === 0) break + } + + return limit > 0 ? events.slice(0, limit) : events +} + +async function fetchSummaryEvents( + auth: Auth, + options: { + reportType: AuditSummaryReportType + filter?: AuditReportFilter + limit?: number + order?: AuditReportOrder + timezone?: string + columns: string[] + aggregates: AuditAggregate[] + } +): Promise { + let limit = options.limit ?? AUDIT_DEFAULT_SUMMARY_LIMIT + if (limit <= 0) limit = AUDIT_DEFAULT_SUMMARY_LIMIT + else if (limit > AUDIT_SUMMARY_MAX_LIMIT) limit = AUDIT_SUMMARY_MAX_LIMIT + + const filter = serializeFilter(options.filter) + + const response = await auth.executeRestCommand( + getAuditEventReportsCommand({ + report_type: options.reportType, + scope: 'enterprise', + timezone: resolveTimezone(options.timezone), + limit, + aggregate: [...options.aggregates], + columns: [...options.columns], + ...(options.order ? { order: toAuditApiOrder(options.order) } : {}), + ...(filter ? { filter } : {}), + }) + ) + assertSucceeded(response, 'get_audit_event_reports failed', ResultCodes.AUDIT_REPORT_FAILED) + return (response.audit_event_overview_report_rows ?? []) as AuditEventOverviewReportRow[] +} + +async function resolveFilter(auth: Auth, filter: AuditReportFilter = {}): Promise { + const resolved: AuditReportFilter = { ...filter } + + if (filter.geoLocation || filter.ipAddress) { + const ipFilter = new Set() + if (filter.geoLocation) { + const parts = filter.geoLocation.split(',') + const country = (parts.pop() || '').trim().toLowerCase() + if (!country) { + throw new KeeperSdkError('"geoLocation" filter misses country.', ResultCodes.AUDIT_INVALID_FILTER) + } + const region = (parts.pop() || '').trim().toLowerCase() + const city = (parts.pop() || '').trim().toLowerCase() + for (const geo of await loadDimension(auth, 'geo_location')) { + if (!geo || typeof geo !== 'object') continue + const row = geo as AuditDimensionIpAddress & { ip_addresses?: string[] } + if ((row.country_code || '').toLowerCase() !== country) continue + if (region && (row.region || '').toLowerCase() !== region) continue + if (city && (row.city || '').toLowerCase() !== city) continue + row.ip_addresses?.forEach((ip) => ipFilter.add(ip)) + } + if (ipFilter.size === 0) { + throw new KeeperSdkError( + `"geoLocation" filter: invalid GEO location ${filter.geoLocation}`, + ResultCodes.AUDIT_INVALID_FILTER + ) + } + } + const addresses = filter.ipAddress + ? Array.isArray(filter.ipAddress) + ? filter.ipAddress + : [filter.ipAddress] + : [] + addresses.forEach((ip) => ipFilter.add(ip)) + resolved.ipAddress = Array.from(ipFilter) + } + + if (filter.deviceType) { + const versionFilter = new Set() + const parts = filter.deviceType.split(',') + const deviceType = (parts[0] || '').trim().toLowerCase() + let version = (parts[1] || '').trim().toLowerCase() + if (version && !version.includes('.')) version += '.' + if (!deviceType && !version) { + throw new KeeperSdkError('"deviceType" filter is empty.', ResultCodes.AUDIT_INVALID_FILTER) + } + for (const row of await loadDimension(auth, 'device_type')) { + if (!row || typeof row !== 'object') continue + const ver = row as AuditDimensionKeeperVersion & { version_ids?: number[] } + if (deviceType) { + const typeName = (ver.type_name || '').toLowerCase() + const typeCategory = (ver.type_category || '').toLowerCase() + if (deviceType !== typeName && deviceType !== typeCategory) continue + } + if (version && !(ver.version || '').startsWith(version)) continue + ver.version_ids?.forEach((id) => { + if (Number.isInteger(id)) versionFilter.add(id) + }) + } + if (versionFilter.size === 0) { + throw new KeeperSdkError('"deviceType" filter matched no events.', ResultCodes.AUDIT_INVALID_FILTER) + } + resolved.keeperVersion = Array.from(versionFilter) + } + + if (filter.eventType !== undefined) { + resolved.eventType = Array.isArray(filter.eventType) + ? filter.eventType.map((value) => asStrOrInt('event-type', value)) + : asStrOrInt('event-type', filter.eventType) + } + + delete resolved.geoLocation + delete resolved.deviceType + return resolved +} + +async function loadDimension(auth: Auth, dimension: string): Promise { + invalidateAuditCachesIfUserChanged(auth) + const cached = dimensionCache.get(dimension) + if (cached) return cached + + const virtualSource = AUDIT_VIRTUAL_DIMENSIONS[dimension] + const rows = virtualSource + ? buildVirtualDimension(dimension, await loadDimension(auth, virtualSource)) + : await fetchDimensionRows(auth, dimension) + + dimensionCache.set(dimension, rows) + return rows +} + +async function fetchDimensionRows(auth: Auth, dimension: string): Promise { + const response = await auth.executeRestCommand( + getAuditEventDimensionsCommand({ + report_type: 'dim', + columns: [dimension], + limit: AUDIT_DIMENSION_API_LIMIT, + scope: 'enterprise', + }) + ) + assertSucceeded(response, 'get_audit_event_dimensions failed', ResultCodes.AUDIT_DIMENSION_FAILED) + const rows = (response.dimensions?.[dimension] || []) as AuditDimensionRow[] + if (dimension !== 'ip_address') return rows + + return rows.map((row) => { + if (typeof row === 'string' || !row || typeof row !== 'object') return row + const ipRow = row as AuditDimensionIpAddress + const city = ipRow.city || '' + const region = ipRow.region || '' + const country = ipRow.country_code || '' + if (!city && !region && !country) return row + return { ...ipRow, geo_location: [city, region, country].filter(Boolean).join(', ') } + }) +} + +function buildVirtualDimension(dimension: string, sourceRows: AuditDimensionRow[]): AuditDimensionRow[] { + if (dimension === 'geo_location') { + const geoMap = new Map>() + for (const row of sourceRows) { + if (!row || typeof row !== 'object') continue + const ipRow = row as AuditDimensionIpAddress & { ip_addresses?: string[] } + if (!ipRow.geo_location || !ipRow.ip_address) continue + const existing = geoMap.get(ipRow.geo_location) + if (existing) (existing.ip_addresses as string[]).push(ipRow.ip_address) + else { + const entry: Record = { ...ipRow } + delete entry.ip_address + entry.ip_addresses = [ipRow.ip_address] + geoMap.set(ipRow.geo_location, entry) + } + } + return Array.from(geoMap.values()).map((entry) => ({ + ...entry, + ip_count: Array.isArray(entry.ip_addresses) ? entry.ip_addresses.length : 0, + })) + } + + if (dimension === 'device_type') { + const deviceMap = new Map>() + for (const row of sourceRows) { + if (!row || typeof row !== 'object') continue + const versionRow = row as AuditDimensionKeeperVersion & { version_ids?: number[] } + if (!versionRow.type_id || !versionRow.version_id) continue + const existing = deviceMap.get(versionRow.type_id) + if (existing) (existing.version_ids as number[]).push(versionRow.version_id) + else { + const entry: Record = { ...versionRow } + delete entry.version_id + entry.version_ids = [versionRow.version_id] + deviceMap.set(versionRow.type_id, entry) + } + } + return Array.from(deviceMap.values()) + } + + return sourceRows +} + +async function loadSyslogTemplates(auth: Auth): Promise> { + invalidateAuditCachesIfUserChanged(auth) + if (syslogTemplates) return syslogTemplates + const templates = new Map() + for (const row of await loadDimension(auth, 'audit_event_type')) { + if (!row || typeof row !== 'object') continue + const typed = row as AuditDimensionEventType + if (typed.name && typed.syslog) templates.set(typed.name, typed.syslog) + } + syslogTemplates = templates + return templates +} + +function invalidateAuditCachesIfUserChanged(auth: Auth): void { + if (auth.username !== cachedUsername) { + dimensionCache = new Map() + syslogTemplates = null + cachedUsername = auth.username || '' + } +} + +function getAuditEventField(event: AuditEventOverviewReportRow, field: string): unknown { + return (event as Record)[field] +} + +function eventMessage(event: AuditEventOverviewReportRow, templates: Map): string { + const template = templates.get(event.audit_event_type || '') + if (!template) return '' + let info = template + while (true) { + const match = /\$\{(\w+)\}/.exec(info) + if (!match) break + const fieldValue = getAuditEventField(event, match[1]) + const value = fieldValue == null ? ' ' : String(fieldValue) + info = info.slice(0, match.index) + value + info.slice(match.index + match[0].length) + } + return info +} + +function serializeFilter(filter: AuditReportFilter | undefined): AuditReportFilterPayload | undefined { + if (!filter) return undefined + const payload: AuditReportFilterPayload = {} + + if (filter.created !== undefined) { + if (typeof filter.created === 'string') { + payload.created = (CREATED_PRESETS as readonly string[]).includes(filter.created) + ? (filter.created as CreatedPreset) + : createdCriteria(parseCreatedFilter(filter.created)) + } else { + payload.created = createdCriteria(filter.created) + } + } + if (filter.eventType !== undefined) payload.event_type = filter.eventType + if (filter.keeperVersion !== undefined) payload.keeper_version = filter.keeperVersion + if (filter.username !== undefined) payload.username = filter.username + if (filter.toUsername !== undefined) payload.to_username = filter.toUsername + if (filter.ipAddress !== undefined) { + payload.ip_address = Array.isArray(filter.ipAddress) ? filter.ipAddress : [filter.ipAddress] + } + if (filter.recordUid !== undefined) payload.record_uid = filter.recordUid + if (filter.sharedFolderUid !== undefined) payload.shared_folder_uid = filter.sharedFolderUid + if (filter.parentId !== undefined) payload.parent_id = filter.parentId + + return Object.keys(payload).length > 0 ? payload : undefined +} + +function createdCriteria(criteria: CreatedFilterCriteria): AuditReportFilterPayload['created'] { + const created: Record = {} + if (criteria.fromDate !== undefined) created.min = criteria.fromDate + if (criteria.excludeFrom === true) created.exclude_min = true + if (criteria.toDate !== undefined) created.max = criteria.toDate + if (criteria.excludeTo === true) created.exclude_max = true + return created as AuditReportFilterPayload['created'] +} + +function advanceCreatedFilter( + filter: AuditReportFilter | undefined, + timestamp: number, + order: AuditReportOrder +): AuditReportFilter { + const next: AuditReportFilter = { ...(filter || {}) } + const criteria: CreatedFilterCriteria = + next.created && typeof next.created === 'object' && !Array.isArray(next.created) + ? { ...next.created } + : {} + if (order === AuditReportOrder.Asc) { + criteria.fromDate = timestamp + criteria.excludeFrom = false + } else { + criteria.toDate = timestamp + criteria.excludeTo = false + } + next.created = criteria + return next +} + +function parseCreatedFilter(value: string): CreatedFilterCriteria { + const trimmed = value.trim() + const betweenMatch = AUDIT_CREATED_BETWEEN_PATTERN.exec(trimmed) + if (betweenMatch) { + return { fromDate: toEpoch(betweenMatch[1]), toDate: toEpoch(betweenMatch[2]) } + } + for (const prefix of ['>=', '<=', '>', '<'] as const) { + if (!trimmed.startsWith(prefix)) continue + const dateValue = toEpoch(trimmed.slice(prefix.length).trim()) + if (prefix === '>=') return { fromDate: dateValue } + if (prefix === '<=') return { toDate: dateValue } + if (prefix === '>') return { fromDate: dateValue, excludeFrom: true } + return { toDate: dateValue, excludeTo: true } + } + throw new KeeperSdkError(`Invalid created filter value "${value}".`, ResultCodes.AUDIT_INVALID_CREATED_FILTER) +} + +function expandCreatedPreset(preset: CreatedPreset): CreatedFilterCriteria { + const today = startOfDay(new Date()) + let fromDate: Date + let toDate: Date + + switch (preset) { + case 'today': + fromDate = today + toDate = addDays(today, 1) + break + case 'yesterday': + fromDate = addDays(today, -1) + toDate = today + break + case 'last_7_days': + fromDate = addDays(today, -7) + toDate = today + break + case 'last_30_days': + fromDate = addDays(today, -30) + toDate = today + break + case 'month_to_date': + fromDate = new Date(today.getFullYear(), today.getMonth(), 1) + toDate = today + break + case 'last_month': + toDate = new Date(today.getFullYear(), today.getMonth(), 1) + fromDate = new Date(toDate.getFullYear(), toDate.getMonth() - 1, 1) + break + case 'last_year': + fromDate = new Date(today.getFullYear() - 1, 0, 1) + toDate = new Date(today.getFullYear(), 0, 1) + break + case 'year_to_date': + fromDate = new Date(today.getFullYear(), 0, 1) + toDate = today + break + default: + throw new KeeperSdkError(`Unknown created preset "${preset}".`, ResultCodes.AUDIT_INVALID_CREATED_FILTER) + } + + return { + fromDate: Math.floor(fromDate.getTime() / 1000), + toDate: Math.floor(toDate.getTime() / 1000), + excludeFrom: false, + excludeTo: true, + } +} + +async function resolveHasAram(auth: Auth, override: boolean | undefined): Promise { + if (override !== undefined) return override + try { + const response = await auth.executeRestCommand(getEnterpriseDataCommand({ include: ['licenses'] })) + assertSucceeded(response, 'get_enterprise_data failed', ResultCodes.AUDIT_LICENSE_CHECK_FAILED) + return hasEnterpriseAuditReporting(response.licenses as EnterpriseAuditLicense[] | undefined) + } catch { + return false + } +} + +function hasEnterpriseAuditReporting(licenses: EnterpriseAuditLicense[] | undefined): boolean { + if (!licenses || licenses.length === 0) return false + for (const license of licenses) { + if (license.audit_and_reporting_enabled === true) return true + for (const addon of license.add_ons || []) { + if (addon.enterprise_audit_and_reporting_enabled === true) return true + if ((addon.name || '').toLowerCase() === 'enterprise_audit_and_reporting') return true + } + } + return false +} + +function dimensionFields(column: string): string[] { + switch (column) { + case 'audit_event_type': + return ['id', 'name', 'category', 'syslog'] + case 'keeper_version': + return ['version_id', 'type_name', 'version', 'type_category'] + case 'ip_address': + return ['ip_address', 'city', 'region', 'country_code'] + case 'geo_location': + return ['geo_location', 'city', 'region', 'country_code', 'ip_count'] + case 'device_type': + return ['type_name', 'type_category'] + default: + return [column] + } +} + +function dimensionCells(row: AuditDimensionRow, fields: readonly string[]): string[] { + if (typeof row === 'string') return [row] + if (!row || typeof row !== 'object') return [''] + return fields.map((field) => { + const value = (row as Record)[field] + return value == null ? '' : String(value) + }) +} + +function formatFieldValue(field: string, value: unknown, reportType: string): string { + if (value == null) return '' + if (field === 'created' || field === 'first_created' || field === 'last_created') { + if (typeof value === 'string') return value + if (typeof value === 'number') { + const dt = new Date(value * 1000) + if (reportType === 'day' || reportType === 'week') return dt.toISOString().slice(0, 10) + if (reportType === 'month') return dt.toLocaleString(undefined, { month: 'long', year: 'numeric' }) + if (reportType === 'hour') { + return `${dt.toISOString().slice(0, 10)} @${String(dt.getUTCHours()).padStart(2, '0')}:00` + } + return dt.toLocaleString() + } + } + return String(value) +} + +function toEpoch(value: string | number | Date): number { + if (value instanceof Date) return Math.floor(value.getTime() / 1000) + if (typeof value === 'number') return Math.floor(value) + const trimmed = value.trim() + const parsed = + trimmed.length <= 10 + ? new Date(`${trimmed}T00:00:00Z`) + : new Date(trimmed.endsWith('Z') ? trimmed : `${trimmed}Z`) + if (Number.isNaN(parsed.getTime())) { + throw new KeeperSdkError(`Invalid date "${value}".`, ResultCodes.AUDIT_INVALID_CREATED_FILTER) + } + return Math.floor(parsed.getTime() / 1000) +} + +function asStrOrInt(propertyName: string, value: string | number): string | number { + if (typeof value === 'number') return value + if (/^\d+$/.test(value)) return Number.parseInt(value, 10) + if (value.trim()) return value + throw new KeeperSdkError(`Invalid "${propertyName}" filter value: ${value}`, ResultCodes.AUDIT_INVALID_FILTER) +} + +function startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) +} + +function addDays(date: Date, days: number): Date { + const copy = new Date(date.getTime()) + copy.setDate(copy.getDate() + days) + return copy +} diff --git a/KeeperSdk/src/enterpriseReport/index.ts b/KeeperSdk/src/enterpriseReport/index.ts new file mode 100644 index 00000000..3acf2bfd --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/index.ts @@ -0,0 +1,54 @@ +export { + runAuditReport, +} from './auditReport' +export { + runActionReport, + getAllowedActions, + getDefaultDaysSince, +} from './actionReport' +export { + runPasswordReport, + getPasswordStrength, + calculatePasswordScore, + parsePasswordPolicy, + isPasswordCompliant, + buildPasswordPolicySummary, +} from './passwordReport' +export { EnterpriseReportManager } from './EnterpriseReportManager' +export { EnterpriseReportManager as AuditReportManager, EnterpriseReportManager as ActionReportManager } from './EnterpriseReportManager' +export type { AuthProvider } from './reportUtils' +export { + AuditReportOrder, + AuditReportFormat, + AuditOutputFormat, + AuditAggregate, + SUMMARY_REPORT_TYPES, + CREATED_PRESETS, + TargetUserStatus, + AdminAction, + ActionReportColumn, + DEFAULT_ACTION_REPORT_COLUMNS, + SUPPORTED_ACTION_REPORT_COLUMNS, + PW_SPECIAL_CHARACTERS, + DEFAULT_TRUNCATION_LENGTH, + SUPPORTED_RECORD_VERSIONS, +} from './reportTypes' +export type { + AuditReportOptions, + AuditReportResult, + AuditReportFilter, + CreatedFilterCriteria, + CreatedPreset, + AuditEventOverviewReportRow, + AuditReportType, + AuditSummaryReportType, + ActionReportEntry, + ActionReportOptions, + ActionReportResult, + ActionResult, + PasswordPolicy, + PasswordStrength, + PasswordReportRow, + PasswordReportOptions, + PasswordReportResult, +} from './reportTypes' diff --git a/KeeperSdk/src/enterpriseReport/passwordReport.ts b/KeeperSdk/src/enterpriseReport/passwordReport.ts new file mode 100644 index 00000000..3cac6a4b --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/passwordReport.ts @@ -0,0 +1,492 @@ +import type { DBWRecord, DRecord } from '@keeper-security/keeperapi' +import { InMemoryStorage } from '../storage/InMemoryStorage' +import { resolveSingleFolder, type VaultFolderSession } from '../folders/changeDirectory' +import { listFolder } from '../folders/listFolder' +import { + getRecordPassword, + getRecordSummary, + getRecordTitle, +} from '../records/RecordUtils' +import { KeeperSdkError, ResultCodes } from '../utils' +import { + DEFAULT_TRUNCATION_LENGTH, + PASSWORD_BREACHWATCH_STATUS_NAMES, + PW_SPECIAL_CHARACTERS, + SUPPORTED_RECORD_VERSIONS, + type PasswordPolicy, + type PasswordReportOptions, + type PasswordReportResult, + type PasswordReportRow, + type PasswordStrength, +} from './reportTypes' + +const POLICY_FIELD_COUNT = 5 +const SPECIAL_CHAR_SET = new Set(PW_SPECIAL_CHARACTERS.split('')) + +const POLICY_SUMMARY_LABELS: ReadonlyArray<{ key: keyof PasswordPolicy; label: string }> = [ + { key: 'length', label: 'length' }, + { key: 'lower', label: 'lowercase' }, + { key: 'upper', label: 'uppercase' }, + { key: 'digits', label: 'digits' }, + { key: 'special', label: 'special' }, +] + +type SupportedRecordVersion = (typeof SUPPORTED_RECORD_VERSIONS)[number] + +type VerboseRowContext = { + storage: InMemoryStorage + passwordCounts: ReadonlyMap + breachWatchPasswordCounts: ReadonlyMap +} + +type BWPasswordEntry = { + value?: string + status?: number | string +} + +type BreachWatchRecordData = { + passwords?: BWPasswordEntry[] + status?: number | string + breachWatchStatus?: number | string +} + +export function getPasswordStrength(password: string): PasswordStrength { + let caps = 0 + let lower = 0 + let digits = 0 + let symbols = 0 + + for (const char of password) { + if (char >= 'A' && char <= 'Z') caps++ + else if (char >= 'a' && char <= 'z') lower++ + else if (char >= '0' && char <= '9') digits++ + else if (SPECIAL_CHAR_SET.has(char)) symbols++ + } + + return { length: password.length, caps, lower, digits, symbols } +} + +export function parsePasswordPolicy(options: PasswordReportOptions): PasswordPolicy { + if (options.policy?.trim()) { + const values = options.policy + .split(',') + .map((part) => part.trim()) + .slice(0, POLICY_FIELD_COUNT) + .map(parsePolicyNumber) + + while (values.length < POLICY_FIELD_COUNT) { + values.push(0) + } + + const [length, lower, upper, digits, special] = values + return { length, lower, upper, digits, special } + } + + return { + length: options.length ?? 0, + lower: options.lower ?? 0, + upper: options.upper ?? 0, + digits: options.digits ?? 0, + special: options.special ?? 0, + } +} + +export function isPasswordCompliant(strength: PasswordStrength, policy: PasswordPolicy): boolean { + return ( + strength.length >= policy.length && + strength.caps >= policy.upper && + strength.lower >= policy.lower && + strength.digits >= policy.digits && + strength.symbols >= policy.special + ) +} + +export function buildPasswordPolicySummary(policy: PasswordPolicy): string { + return POLICY_SUMMARY_LABELS.filter(({ key }) => policy[key] > 0) + .map(({ key, label }) => `${label} >= ${policy[key]}`) + .join(', ') +} + +/** Keeper Commander-compatible password strength score (0–100). */ +export function calculatePasswordScore(password: string): number { + if (!password) return 0 + + let normalized = password + let total = password.length + if (total > 50) { + normalized = password.slice(0, 50) + total = 50 + } + + let uppers = 0 + let lowers = 0 + let digits = 0 + let symbols = 0 + for (const char of normalized) { + if (char >= 'A' && char <= 'Z') uppers++ + else if (char >= 'a' && char <= 'z') lowers++ + else if (char >= '0' && char <= '9') digits++ + else symbols++ + } + + let ds = digits + symbols + if (!/^[A-Za-z]/.test(normalized)) ds-- + if (!/[A-Za-z]$/.test(normalized)) ds-- + if (ds < 0) ds = 0 + + let score = total * 4 + if (uppers > 0) score += (total - uppers) * 2 + if (lowers > 0) score += (total - lowers) * 2 + if (digits > 0) score += digits * 4 + score += symbols * 6 + score += ds * 2 + + let variance = 0 + if (uppers > 0) variance++ + if (lowers > 0) variance++ + if (digits > 0) variance++ + if (symbols > 0) variance++ + if (total >= 8 && variance >= 3) score += (variance + 1) * 2 + + if (digits + symbols === 0) score -= total + if (uppers + lowers + symbols === 0) score -= total + + const pwdLen = normalized.length + let repInc = 0 + let repCount = 0 + for (let i = 0; i < pwdLen; i++) { + let charExists = false + for (let j = 0; j < pwdLen; j++) { + if (i !== j && normalized[i] === normalized[j]) { + charExists = true + repInc += pwdLen / Math.abs(i - j) + } + } + if (charExists) repCount++ + } + const unqCount = pwdLen - repCount + repInc = Math.ceil(unqCount === 0 ? repInc : repInc / unqCount) + if (repCount > 0) score -= repInc + + let consecCount = 0 + const consecPredicates = [ + (char: string) => char >= 'A' && char <= 'Z', + (char: string) => char >= 'a' && char <= 'z', + (char: string) => char >= '0' && char <= '9', + ] + for (const predicate of consecPredicates) { + for (const chunk of chunkText(normalized, predicate)) { + if (chunk.length >= 2) consecCount += chunk.length - 1 + } + } + if (consecCount > 0) score -= 2 * consecCount + + let sequenceCount = 0 + for (const [modulus, predicate] of [ + [26, (char: string) => /[a-z]/i.test(char)] as const, + [10, (char: string) => char >= '0' && char <= '9'] as const, + ]) { + for (const chunk of chunkText(normalized.toLowerCase(), predicate)) { + if (chunk.length < 3) continue + const offsets = offsetChar(chunk, (prev, current) => { + const delta = prev.charCodeAt(0) - current.charCodeAt(0) + return delta >= 0 ? delta : delta + modulus + }) + let op = offsets[0] + for (const oc of offsets.slice(1)) { + if (oc === op) { + if (op !== 0) sequenceCount++ + } else { + op = oc + } + } + } + } + + const symbolMap: Record = {} + '!@#$%^&*()_+[]\\{}\'|;:",./<>?'.split('').forEach((symbol, index) => { + symbolMap[symbol] = index + }) + for (const chunk of chunkText(normalized, (char) => char in symbolMap)) { + if (chunk.length < 3) continue + const offsets = offsetChar(chunk, (prev, current) => { + const delta = symbolMap[prev] - symbolMap[current] + const modulus = Object.keys(symbolMap).length + return delta >= 0 ? delta : delta + modulus + }) + let op = offsets[0] + for (const oc of offsets.slice(1)) { + if (oc === op) { + if (op !== 0) sequenceCount++ + } else { + op = oc + } + } + } + if (sequenceCount > 0) score -= 3 * sequenceCount + + if (score < 0) return 0 + if (score > 100) return 100 + return score +} + +export async function runPasswordReport( + storage: InMemoryStorage, + session: VaultFolderSession, + options: PasswordReportOptions = {} +): Promise { + const policy = parsePasswordPolicy(options) + validatePasswordPolicy(policy) + + const verbose = options.verbose === true + const rowNumbers = options.rowNumbers !== false + + const targetRecords = await resolveTargetRecords(storage, session, options.folder) + const passwordCounts = buildPasswordCountMap(storage.getRecords()) + const breachWatchPasswordCounts = buildBreachWatchPasswordCountMap(storage) + const verboseContext: VerboseRowContext = { + storage, + passwordCounts, + breachWatchPasswordCounts, + } + + const rows = targetRecords.flatMap((record) => + buildNonCompliantRow(record, policy, verbose, verboseContext) + ) + + return { + policy, + policySummary: buildPasswordPolicySummary(policy), + rows, + verbose, + rowNumbers, + } +} + +function parsePolicyNumber(part: string): number { + if (!part) return 0 + const parsed = Number.parseInt(part, 10) + return Number.isFinite(parsed) ? parsed : 0 +} + +function isSupportedRecordVersion(version: number): version is SupportedRecordVersion { + return (SUPPORTED_RECORD_VERSIONS as readonly number[]).includes(version) +} + +function validatePasswordPolicy(policy: PasswordPolicy): void { + const hasConstraint = POLICY_SUMMARY_LABELS.some(({ key }) => policy[key] > 0) + if (!hasConstraint) { + throw new KeeperSdkError( + 'At least one password policy constraint must be set.', + ResultCodes.PASSWORD_REPORT_POLICY_REQUIRED + ) + } +} + +function truncateText(value: string, maxLength = DEFAULT_TRUNCATION_LENGTH): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...` +} + +function getRecordDescription(record: DRecord): string { + const summary = getRecordSummary(record) + const parts: string[] = [] + if (summary.login) parts.push(summary.login) + if (summary.url) parts.push(String(summary.url)) + return parts.join(' @ ') +} + +function chunkText(text: string, predicate: (char: string) => boolean): string[] { + const chunks: string[] = [] + let acc = '' + for (const char of text) { + if (predicate(char)) { + acc += char + } else if (acc) { + chunks.push(acc) + acc = '' + } + } + if (acc) chunks.push(acc) + return chunks +} + +function offsetChar(text: string, compare: (prev: string, current: string) => number): number[] { + if (!text) return [] + const offsets: number[] = [] + let prev = text[0] + for (const char of text.slice(1)) { + offsets.push(compare(prev, char)) + prev = char + } + return offsets +} + +function formatBreachWatchStatus(rawStatus: number | string): string { + if (typeof rawStatus === 'string') { + return rawStatus.toUpperCase() + } + return PASSWORD_BREACHWATCH_STATUS_NAMES[rawStatus] || String(rawStatus) +} + +function getWorstBreachWatchStatus(statuses: Array): number | string | undefined { + const rank = (status: number | string): number => { + if (typeof status === 'string') { + const upper = status.toUpperCase() + if (upper === 'BREACHED') return 4 + if (upper === 'WEAK') return 3 + if (upper === 'CHANGED') return 2 + if (upper === 'IGNORE') return 1 + return 0 + } + return { 3: 4, 2: 3, 1: 2, 4: 1, 0: 0 }[status] ?? 0 + } + + let worst: number | string | undefined + let worstRank = -1 + for (const status of statuses) { + const statusRank = rank(status) + if (statusRank > worstRank) { + worstRank = statusRank + worst = status + } + } + return worst +} + +function getBreachWatchPasswordStatus(storage: InMemoryStorage, recordUid: string, password: string): string { + const bwRecord = storage.getByUid('bw_record', recordUid) + if (!bwRecord?.data) return '' + + const data = bwRecord.data as BreachWatchRecordData + const passwords = data.passwords + if (Array.isArray(passwords) && passwords.length > 0) { + const match = passwords.find((entry) => entry.value === password) + if (match?.status != null && match.status !== '') { + return formatBreachWatchStatus(match.status) + } + + const statuses = passwords + .map((entry) => entry.status) + .filter((status): status is number | string => status != null && status !== '') + const worstStatus = getWorstBreachWatchStatus(statuses) + if (worstStatus != null) { + return formatBreachWatchStatus(worstStatus) + } + } + + const legacyStatus = data.status ?? data.breachWatchStatus + if (legacyStatus != null && legacyStatus !== '') { + return formatBreachWatchStatus(legacyStatus) + } + + return '' +} + +function buildBreachWatchPasswordCountMap(storage: InMemoryStorage): Map { + const counts = new Map() + for (const bwRecord of storage.getAll('bw_record')) { + const passwords = (bwRecord.data as BreachWatchRecordData | undefined)?.passwords + if (!Array.isArray(passwords)) continue + for (const entry of passwords) { + const value = entry.value + if (!value) continue + counts.set(value, (counts.get(value) ?? 0) + 1) + } + } + return counts +} + +async function collectRecordUidsInFolderTree(storage: InMemoryStorage, folderUid: string | null): Promise { + const uids: string[] = [] + const folderQueue = [folderUid] + + while (folderQueue.length > 0) { + const currentFolderUid = folderQueue.shift() + const listed = await listFolder(storage, { + folderUid: currentFolderUid ?? undefined, + showFolders: true, + showRecords: true, + }) + + for (const record of listed.records ?? []) { + uids.push(record.uid) + } + for (const folder of listed.folders) { + folderQueue.push(folder.uid) + } + } + + return uids +} + +async function resolveTargetRecords( + storage: InMemoryStorage, + session: VaultFolderSession, + folder?: string | null +): Promise { + const records = storage.getRecords() + const folderPath = folder?.trim() + if (!folderPath) return records + + const resolved = await resolveSingleFolder(storage, session, folderPath) + const targetUids = new Set(await collectRecordUidsInFolderTree(storage, resolved.folderUid)) + return records.filter((record) => targetUids.has(record.uid)) +} + +function buildPasswordCountMap(records: readonly DRecord[]): Map { + const counts = new Map() + for (const record of records) { + const password = getRecordPassword(record) + if (!password) continue + counts.set(password, (counts.get(password) ?? 0) + 1) + } + return counts +} + +function buildNonCompliantRow( + record: DRecord, + policy: PasswordPolicy, + verbose: boolean, + context: VerboseRowContext +): PasswordReportRow[] { + if (!isSupportedRecordVersion(record.version)) return [] + + const password = getRecordPassword(record) + if (!password) return [] + + const strength = getPasswordStrength(password) + if (isPasswordCompliant(strength, policy)) return [] + + const row: PasswordReportRow = { + recordUid: record.uid, + title: truncateText(getRecordTitle(record)), + description: truncateText(getRecordDescription(record)), + length: strength.length, + lower: strength.lower, + upper: strength.caps, + digits: strength.digits, + special: strength.symbols, + } + + if (verbose) { + applyVerboseFields(row, record.uid, password, context) + } + + return [row] +} + +function applyVerboseFields( + row: PasswordReportRow, + recordUid: string, + password: string, + context: VerboseRowContext +): void { + const { storage, passwordCounts, breachWatchPasswordCounts } = context + row.score = String(calculatePasswordScore(password)) + row.status = getBreachWatchPasswordStatus(storage, recordUid, password) + + const reuseCount = breachWatchPasswordCounts.get(password) ?? passwordCounts.get(password) ?? 0 + if (reuseCount > 1) { + row.reused = String(reuseCount) + } +} diff --git a/KeeperSdk/src/enterpriseReport/reportTypes.ts b/KeeperSdk/src/enterpriseReport/reportTypes.ts new file mode 100644 index 00000000..7000a5e5 --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/reportTypes.ts @@ -0,0 +1,414 @@ +export type AuditReportType = 'raw' | 'dim' | 'hour' | 'day' | 'week' | 'month' | 'span' + +export type AuditSummaryReportType = 'hour' | 'day' | 'week' | 'month' | 'span' + +export type AuditEventOverviewReportRow = { + id?: number + created?: number | string + username?: string + to_username?: string + from_username?: string + ip_address?: string + audit_event_type?: string + keeper_version?: string + keeper_version_category?: string + geo_location?: string + node_id?: number + node?: string + role_id?: string + enforcement?: string + value?: string + record_uid?: string + shared_folder_uid?: string + team_uid?: string + channel?: string + status?: string + recipient?: string + occurrences?: number + first_created?: number | string + last_created?: number | string +} + +export type AuditDimensionIpAddress = { + ip_address?: string + city?: string + region?: string + country?: string + country_code?: string + country_name?: string + geo_location?: string + ip_addresses?: string[] +} + +export type AuditDimensionKeeperVersion = { + version_id?: number + type_id?: number + type_name?: string + type_category?: string + version?: string + version_ids?: number[] +} + +export type AuditDimensionRow = + | AuditDimensionIpAddress + | AuditDimensionKeeperVersion + | string + | Record + +export type AuditReportFilterPayload = { + created?: + | CreatedPreset + | { + min?: number | string + max?: number | string + exclude_min?: boolean + exclude_max?: boolean + } + event_type?: string | number | Array + audit_event_type?: string | number | Array + keeper_version?: number | number[] + username?: string | string[] + to_username?: string | string[] + ip_address?: string[] + record_uid?: string | string[] + shared_folder_uid?: string | string[] + parent_id?: number | number[] +} + +export enum AuditReportOrder { + Asc = 'asc', + Desc = 'desc', +} + +export enum AuditReportFormat { + Message = 'message', + Fields = 'fields', +} + +export enum AuditOutputFormat { + Table = 'table', + Json = 'json', + Csv = 'csv', +} + +export enum AuditAggregate { + Occurrences = 'occurrences', + FirstCreated = 'first_created', + LastCreated = 'last_created', +} + +export const SUMMARY_REPORT_TYPES: readonly AuditSummaryReportType[] = [ + 'hour', + 'day', + 'week', + 'month', + 'span', +] + +export const CREATED_PRESETS = [ + 'today', + 'yesterday', + 'last_7_days', + 'last_30_days', + 'month_to_date', + 'last_month', + 'year_to_date', + 'last_year', +] as const + +export type CreatedPreset = (typeof CREATED_PRESETS)[number] + +export type CreatedFilterCriteria = { + fromDate?: number + toDate?: number + excludeFrom?: boolean + excludeTo?: boolean +} + +export type AuditReportFilter = { + created?: CreatedPreset | string | CreatedFilterCriteria + eventType?: string | number | Array + keeperVersion?: number | number[] + username?: string | string[] + toUsername?: string | string[] + ipAddress?: string | string[] + recordUid?: string | string[] + sharedFolderUid?: string | string[] + geoLocation?: string + deviceType?: string + parentId?: number | number[] +} + +export type AuditReportOptions = { + syntaxHelp?: boolean + reportType?: AuditReportType + reportFormat?: AuditReportFormat + columns?: string[] + aggregates?: AuditAggregate[] + timezone?: string + limit?: number + order?: AuditReportOrder + filter?: AuditReportFilter + hasAram?: boolean +} + +export type AuditReportResult = { + reportType: AuditReportType | null + headers: string[] + rows: string[][] + events?: AuditEventOverviewReportRow[] + syntaxHelp?: string + eventTypeReference?: Array<{ id: number; name: string }> +} + +export enum TargetUserStatus { + NoLogon = 'no-logon', + NoUpdate = 'no-update', + Locked = 'locked', + Invited = 'invited', + NoRecovery = 'no-recovery', +} + +export enum AdminAction { + None = 'none', + Lock = 'lock', + Delete = 'delete', + Transfer = 'transfer', +} + +export enum ActionReportColumn { + UserId = 'user_id', + Email = 'email', + Name = 'name', + Status = 'status', + TransferStatus = 'transfer_status', + Node = 'node', + TeamCount = 'team_count', + Teams = 'teams', + RoleCount = 'role_count', + Roles = 'roles', + Alias = 'alias', + TwoFaEnabled = '2fa_enabled', +} + +export const DEFAULT_ACTION_REPORT_COLUMNS: readonly ActionReportColumn[] = [ + ActionReportColumn.Email, + ActionReportColumn.Name, + ActionReportColumn.Status, + ActionReportColumn.TransferStatus, + ActionReportColumn.Node, +] + +export const SUPPORTED_ACTION_REPORT_COLUMNS: readonly ActionReportColumn[] = [ + ActionReportColumn.UserId, + ...DEFAULT_ACTION_REPORT_COLUMNS, + ActionReportColumn.TeamCount, + ActionReportColumn.Teams, + ActionReportColumn.RoleCount, + ActionReportColumn.Roles, + ActionReportColumn.Alias, + ActionReportColumn.TwoFaEnabled, +] + +export const ACTION_REPORT_COLUMN_ORDER: readonly ActionReportColumn[] = SUPPORTED_ACTION_REPORT_COLUMNS + +export type ActionReportEntry = { + enterpriseUserId: number + email: string + fullName: string + status: string + transferStatus: string + nodePath: string + teams: string[] + roles: string[] + aliases: string[] + tfaEnabled: boolean +} + +export type ActionReportOptions = { + target?: TargetUserStatus + daysSince?: number + columns?: ActionReportColumn[] | `${ActionReportColumn}`[] | string | null + applyAction?: AdminAction + targetUser?: string + dryRun?: boolean + node?: string + timezone?: string +} + +export type ActionResult = { + action: AdminAction | string + status: string + affectedCount: number + serverMessage: string +} + +export type ActionReportResult = { + target: TargetUserStatus + headers: string[] + rows: string[][] + entries: ActionReportEntry[] + actionResult: ActionResult +} + +export const PW_SPECIAL_CHARACTERS = '!@#$%()+;<>=?[]{}^.,' +export const DEFAULT_TRUNCATION_LENGTH = 32 +export const SUPPORTED_RECORD_VERSIONS = [2, 3] as const + +export type PasswordPolicy = { + length: number + lower: number + upper: number + digits: number + special: number +} + +export type PasswordStrength = { + length: number + lower: number + caps: number + digits: number + symbols: number +} + +export type PasswordReportRow = { + recordUid: string + title: string + description: string + length: number + lower: number + upper: number + digits: number + special: number + score?: string + status?: string + reused?: string +} + +export type PasswordReportOptions = { + folder?: string | null + policy?: string | null + length?: number + lower?: number + upper?: number + digits?: number + special?: number + verbose?: boolean + rowNumbers?: boolean +} + +export type PasswordReportResult = { + policy: PasswordPolicy + policySummary: string + rows: PasswordReportRow[] + verbose: boolean + rowNumbers: boolean +} + +export const AUDIT_RAW_FIELDS = [ + 'created', + 'audit_event_type', + 'username', + 'ip_address', + 'keeper_version', + 'geo_location', +] as const + +export const AUDIT_MISC_FIELDS = [ + 'to_username', + 'from_username', + 'record_uid', + 'shared_folder_uid', + 'node', + 'role_id', + 'team_uid', + 'channel', + 'status', + 'recipient', + 'value', +] as const + +export const AUDIT_RAW_PAGE_SIZE = 1000 +export const AUDIT_NO_ARAM_RAW_LIMIT = 1000 +export const AUDIT_DEFAULT_RAW_LIMIT = 50 +export const AUDIT_DEFAULT_SUMMARY_LIMIT = 100 +export const AUDIT_SUMMARY_MAX_LIMIT = 2000 +export const AUDIT_DIMENSION_API_LIMIT = 2000 +export const AUDIT_CREATED_BETWEEN_PATTERN = /^\s*between\s+(\S+)\s+and\s+(.*)$/i +export const AUDIT_VIRTUAL_DIMENSIONS: Readonly> = { + geo_location: 'ip_address', + device_type: 'keeper_version', +} +export const AUDIT_SYNTAX_HELP = [ + 'Audit report types: raw, dim, hour, day, week, month, span', + 'Filters: created, eventType, username, toUsername, ipAddress, recordUid, sharedFolderUid, geoLocation, deviceType', + 'Raw defaults to message format; use reportFormat=fields for all columns.', +].join('\n') + +export const ACTION_STATUS_EVENT_TYPES: Readonly> = { + [TargetUserStatus.NoLogon]: ['login', 'login_console', 'chat_login', 'accept_invitation'], + [TargetUserStatus.NoUpdate]: ['record_add', 'record_update'], + [TargetUserStatus.Locked]: ['lock_user'], + [TargetUserStatus.Invited]: ['send_invitation', 'auto_invite_user'], + [TargetUserStatus.NoRecovery]: ['change_security_question', 'account_recovery_setup'], +} + +export const ACTION_DEFAULT_DAYS_BY_TARGET: Partial> = { + [TargetUserStatus.Locked]: 90, +} + +export const ACTION_DEFAULT_DAYS = 30 +export const ACTION_USERNAME_BATCH_SIZE = 2000 +export const ACTION_EVENT_SUMMARY_ROW_LIMIT = 2000 +export const SECONDS_PER_DAY = 86400 + +export const ACTION_COLUMN_LABELS: Readonly> = { + [ActionReportColumn.UserId]: 'User ID', + [ActionReportColumn.Email]: 'Email', + [ActionReportColumn.Name]: 'Name', + [ActionReportColumn.Status]: 'Status', + [ActionReportColumn.TransferStatus]: 'Transfer Status', + [ActionReportColumn.Node]: 'Node', + [ActionReportColumn.TeamCount]: 'Team Count', + [ActionReportColumn.Teams]: 'Teams', + [ActionReportColumn.RoleCount]: 'Role Count', + [ActionReportColumn.Roles]: 'Roles', + [ActionReportColumn.Alias]: 'Alias', + [ActionReportColumn.TwoFaEnabled]: '2FA Enabled', +} + +export const PASSWORD_REPORT_BASE_HEADERS = [ + 'record_uid', + 'title', + 'description', + 'length', + 'lower', + 'upper', + 'digits', + 'special', +] + +export const PASSWORD_REPORT_VERBOSE_HEADERS = ['score', 'status', 'reused'] + +export const PASSWORD_BREACHWATCH_STATUS_NAMES: Readonly> = { + 0: 'GOOD', + 1: 'CHANGED', + 2: 'WEAK', + 3: 'BREACHED', + 4: 'IGNORE', +} + +export type EnterpriseAuditLicense = { + audit_and_reporting_enabled?: boolean + add_ons?: Array<{ + name?: string + enterprise_audit_and_reporting_enabled?: boolean + }> +} + +export type AuditDimensionEventType = { + id?: number + name?: string + category?: string + syslog?: string +} diff --git a/KeeperSdk/src/enterpriseReport/reportUtils.ts b/KeeperSdk/src/enterpriseReport/reportUtils.ts new file mode 100644 index 00000000..cf647d99 --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/reportUtils.ts @@ -0,0 +1,36 @@ +import type { Auth, KeeperResponse } from '@keeper-security/keeperapi' +import { KeeperSdkError } from '../utils' +import { AuditReportOrder } from './reportTypes' + +export type AuthProvider = () => Auth + +export function resolveTimezone(timezone: string | undefined): string { + if (timezone?.trim()) return timezone.trim() + const hours = -new Date().getTimezoneOffset() / 60 + return `Etc/GMT${hours >= 0 ? '+' : ''}${hours}` +} + +export function assertSucceeded( + response: KeeperResponse, + fallbackMessage: string, + fallbackCode: string +): void { + if ((response.result || '').toLowerCase() === 'fail') { + throw new KeeperSdkError( + response.message || response.result_code || fallbackMessage, + response.result_code || fallbackCode + ) + } +} + +export function toAuditApiOrder(order: AuditReportOrder): 'ascending' | 'descending' { + return order === AuditReportOrder.Asc ? 'ascending' : 'descending' +} + +export function chunkArray(values: T[], size: number): T[][] { + const chunks: T[][] = [] + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)) + } + return chunks +} diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 7847da5c..eb6188ed 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -33,6 +33,9 @@ export { RoleErrorCode, TeamErrorCode, UserErrorCode, + AuditReportErrorCode, + ActionReportErrorCode, + PasswordReportErrorCode, KEEPER_PUBLIC_HOSTS, isBoolean, isString, @@ -284,6 +287,55 @@ export type { EnforcementPair, } from './roles' +export { + runAuditReport, + runActionReport, + runPasswordReport, + getPasswordStrength, + calculatePasswordScore, + parsePasswordPolicy, + isPasswordCompliant, + buildPasswordPolicySummary, + getAllowedActions, + getDefaultDaysSince, + AuditReportManager, + ActionReportManager, + EnterpriseReportManager, + AuditReportOrder, + AuditReportFormat, + AuditOutputFormat, + AuditAggregate, + SUMMARY_REPORT_TYPES, + CREATED_PRESETS, + TargetUserStatus, + AdminAction, + ActionReportColumn, + DEFAULT_ACTION_REPORT_COLUMNS, + SUPPORTED_ACTION_REPORT_COLUMNS, + PW_SPECIAL_CHARACTERS, + DEFAULT_TRUNCATION_LENGTH, + SUPPORTED_RECORD_VERSIONS, +} from './enterpriseReport' +export type { + AuditReportOptions, + AuditReportResult, + AuditReportFilter, + CreatedFilterCriteria, + CreatedPreset, + AuditEventOverviewReportRow, + AuditReportType, + AuditSummaryReportType, + ActionReportEntry, + ActionReportOptions, + ActionReportResult, + ActionResult, + PasswordPolicy, + PasswordStrength, + PasswordReportRow, + PasswordReportOptions, + PasswordReportResult, +} from './enterpriseReport' + export { viewTeam, formatTeamView, teamViewTable } from './teams/viewTeam' export type { TeamView, diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index dfa22bee..6fc04d69 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -88,6 +88,32 @@ export enum TeamErrorCode { TeamNameTooLong = 'team_name_too_long', } +export enum AuditReportErrorCode { + InvalidReportType = 'audit_invalid_report_type', + InvalidCreatedFilter = 'audit_invalid_created_filter', + InvalidFilter = 'audit_invalid_filter', + InvalidLimit = 'audit_invalid_limit', + ColumnsRequired = 'audit_columns_required', + DimensionColumnRequired = 'audit_dimension_column_required', + DimensionFailed = 'audit_dimension_failed', + ReportFailed = 'audit_report_failed', + ReportingNotEnabled = 'audit_reporting_not_enabled', + LicenseCheckFailed = 'audit_license_check_failed', +} + +export enum ActionReportErrorCode { + ReportFailed = 'action_report_failed', + InvalidAction = 'action_report_invalid_action', + TargetUserRequired = 'action_report_target_user_required', + TransferNotSupported = 'action_report_transfer_not_supported', + NodeNotFound = 'action_report_node_not_found', + NodeNotUnique = 'action_report_node_not_unique', +} + +export enum PasswordReportErrorCode { + PolicyRequired = 'password_report_policy_required', +} + export enum UserErrorCode { NoUsersToUpdate = 'no_users_to_update', NoUsersToAdd = 'no_users_to_add', @@ -177,6 +203,23 @@ export const ResultCodes = { NO_TEAMS_FOR_USER_OP: UserErrorCode.NoTeamsForUserOp, TEAM_USER_ADD_FAILED: UserErrorCode.TeamUserAddFailed, TEAM_USER_REMOVE_FAILED: UserErrorCode.TeamUserRemoveFailed, + AUDIT_INVALID_REPORT_TYPE: AuditReportErrorCode.InvalidReportType, + AUDIT_INVALID_CREATED_FILTER: AuditReportErrorCode.InvalidCreatedFilter, + AUDIT_INVALID_FILTER: AuditReportErrorCode.InvalidFilter, + AUDIT_INVALID_LIMIT: AuditReportErrorCode.InvalidLimit, + AUDIT_COLUMNS_REQUIRED: AuditReportErrorCode.ColumnsRequired, + AUDIT_DIMENSION_COLUMN_REQUIRED: AuditReportErrorCode.DimensionColumnRequired, + AUDIT_DIMENSION_FAILED: AuditReportErrorCode.DimensionFailed, + AUDIT_REPORT_FAILED: AuditReportErrorCode.ReportFailed, + AUDIT_REPORTING_NOT_ENABLED: AuditReportErrorCode.ReportingNotEnabled, + AUDIT_LICENSE_CHECK_FAILED: AuditReportErrorCode.LicenseCheckFailed, + ACTION_REPORT_FAILED: ActionReportErrorCode.ReportFailed, + ACTION_REPORT_INVALID_ACTION: ActionReportErrorCode.InvalidAction, + ACTION_REPORT_TARGET_USER_REQUIRED: ActionReportErrorCode.TargetUserRequired, + ACTION_REPORT_TRANSFER_NOT_SUPPORTED: ActionReportErrorCode.TransferNotSupported, + ACTION_REPORT_NODE_NOT_FOUND: ActionReportErrorCode.NodeNotFound, + ACTION_REPORT_NODE_NOT_UNIQUE: ActionReportErrorCode.NodeNotUnique, + PASSWORD_REPORT_POLICY_REQUIRED: PasswordReportErrorCode.PolicyRequired, } as const export const KEEPER_PUBLIC_HOSTS: Record = { diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index cef46a3a..569394a5 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -8,6 +8,9 @@ export { RoleErrorCode, TeamErrorCode, UserErrorCode, + AuditReportErrorCode, + ActionReportErrorCode, + PasswordReportErrorCode, NsfErrorCode, KEEPER_PUBLIC_HOSTS, } from './constants' diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 80f6e5c0..24e3f8c2 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -74,6 +74,16 @@ import { type UpdateRoleInput, type UpdateRoleResult, } from '../roles' +import { + EnterpriseReportManager, + runPasswordReport, + type AuditReportOptions, + type AuditReportResult, + type ActionReportOptions, + type ActionReportResult, + type PasswordReportOptions, + type PasswordReportResult, +} from '../enterpriseReport' import { UserManager } from '../users/UserManager' import { NestedShareFolderManager } from '../nestedShareFolders/NestedShareFolderManager' import type { ListNsfOptions, ListNsfRow, ListNsfFormatInput, FormattedListNsfTable } from '../nestedShareFolders/listNsf' @@ -146,6 +156,7 @@ export class KeeperVault { private readonly sharedFolderManager: SharedFolderManager private readonly teamManager: TeamManager private readonly roleManager: RoleManager + private readonly enterpriseReportManager: EnterpriseReportManager private readonly userManager: UserManager private readonly nestedShareFolderManager: NestedShareFolderManager @@ -170,6 +181,7 @@ export class KeeperVault { this.sharedFolderManager = new SharedFolderManager(this.storage, authProvider) this.teamManager = new TeamManager(authProvider) this.roleManager = new RoleManager(authProvider) + this.enterpriseReportManager = new EnterpriseReportManager(authProvider) this.userManager = new UserManager(authProvider) this.nestedShareFolderManager = new NestedShareFolderManager(this.storage, authProvider) } @@ -194,6 +206,20 @@ export class KeeperVault { return this.roleManager } + public getEnterpriseReportManager(): EnterpriseReportManager { + return this.enterpriseReportManager + } + + /** @deprecated Use getEnterpriseReportManager() */ + public getAuditReportManager(): EnterpriseReportManager { + return this.enterpriseReportManager + } + + /** @deprecated Use getEnterpriseReportManager() */ + public getActionReportManager(): EnterpriseReportManager { + return this.enterpriseReportManager + } + private async createAuth(options?: { useSessionResumption?: boolean }): Promise { const host = this.config.host const baseDeviceConfig = await this.sessionManager.getDeviceConfig(host) @@ -526,6 +552,18 @@ export class KeeperVault { return result } + public async runAuditReport(options?: AuditReportOptions): Promise { + return this.enterpriseReportManager.runAuditReport(options ?? {}) + } + + public async runActionReport(options?: ActionReportOptions): Promise { + return this.enterpriseReportManager.runActionReport(options ?? {}) + } + + public async runPasswordReport(options?: PasswordReportOptions): Promise { + return runPasswordReport(this.storage, this.folderSession, options ?? {}) + } + public async changeDirectory(path: string): Promise { return this.folderManager.changeDirectory(path) } diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 165e6c7b..65441eca 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -46,6 +46,9 @@ "users:action": "ts-node src/users/action_user.ts", "users:alias": "ts-node src/users/alias_user.ts", "users:user-team": "ts-node src/users/team_user.ts", + "reports:audit-report": "ts-node src/enterpriseReport/audit_report.ts", + "reports:action-report": "ts-node src/enterpriseReport/action_report.ts", + "reports:password-report": "ts-node src/enterpriseReport/password_report.ts", "link-local": "cd ../../KeeperSdk && npm link ../keeperapi && cd ../examples/sdk_example && npm link ../../keeperapi", "types": "tsc --watch", "types:ci": "tsc" diff --git a/examples/sdk_example/src/enterpriseReport/action_report.ts b/examples/sdk_example/src/enterpriseReport/action_report.ts new file mode 100644 index 00000000..111afcb9 --- /dev/null +++ b/examples/sdk_example/src/enterpriseReport/action_report.ts @@ -0,0 +1,122 @@ +import { + AdminAction, + AuditOutputFormat, + TargetUserStatus, + cleanup, + extractErrorMessage, + getAllowedActions, + getDefaultDaysSince, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { ActionReportOptions } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' +import { formatActionReportResult } from '../utils/reportFormat' + +async function actionReportExample() { + const vault = await login() + + try { + const targetRaw = ( + await prompt('Target status [no-logon/no-update/locked/invited/no-recovery, default no-logon]: ') + ) + .trim() + .toLowerCase() + const target = (targetRaw || TargetUserStatus.NoLogon) as TargetUserStatus + + const defaultDays = getDefaultDaysSince(target) + const daysRaw = (await prompt(`Days since last event [default ${defaultDays}]: `)).trim() + const daysSince = daysRaw ? Number.parseInt(daysRaw, 10) : defaultDays + if (!Number.isFinite(daysSince) || daysSince <= 0) { + logger.error('Days since must be a positive number.') + process.exitCode = 1 + return + } + + const node = (await prompt('Node filter (name or ID, Enter to skip): ')).trim() + + const columnsRaw = (await prompt('Columns (comma-separated, Enter for default): ')).trim() + const allowedActions = getAllowedActions(target) + const applyRaw = ( + await prompt(`Apply action [${allowedActions.join('/')}, default none]: `) + ) + .trim() + .toLowerCase() + const applyAction = (applyRaw || AdminAction.None) as AdminAction + + let targetUser: string | undefined + if (applyAction === AdminAction.Transfer) { + targetUser = (await prompt('Target user for transfer: ')).trim() + if (!targetUser) { + logger.error('Target user is required for transfer.') + process.exitCode = 1 + return + } + } + + const dryRun = + applyAction !== AdminAction.None && + isYes(await prompt('Dry run (preview only)? [y/N]: ')) + + let force = false + if ( + !dryRun && + (applyAction === AdminAction.Delete || applyAction === AdminAction.Transfer) + ) { + force = isYes(await prompt('Skip confirmation? [y/N]: ')) + if (!force) { + const confirmed = isYes( + await prompt(`Apply "${applyAction}" to matched users? [y/N]: `) + ) + if (!confirmed) { + logger.info('Action cancelled.') + return + } + } + } + + const options: ActionReportOptions = { + target, + daysSince, + applyAction, + dryRun, + ...(node ? { node } : {}), + ...(targetUser ? { targetUser } : {}), + ...(columnsRaw ? { columns: columnsRaw } : {}), + } + + const outputRaw = (await prompt('Output format [table/json/csv, default table]: ')).trim().toLowerCase() + let outputFormat = AuditOutputFormat.Table + if (outputRaw === 'json') outputFormat = AuditOutputFormat.Json + else if (outputRaw === 'csv') outputFormat = AuditOutputFormat.Csv + + const restore = suppressLogs() + let result + try { + result = await vault.runActionReport(options) + } finally { + restore() + } + + logger.info('') + logger.info(formatActionReportResult(result, outputFormat)) + logger.info('') + logger.info(`Rows: ${result.rows.length}`) + logger.info( + `Action: ${result.actionResult.action} (${result.actionResult.status}, affected=${result.actionResult.affectedCount})` + ) + if (result.actionResult.serverMessage && result.actionResult.serverMessage !== 'n/a') { + logger.info(result.actionResult.serverMessage) + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(actionReportExample) diff --git a/examples/sdk_example/src/enterpriseReport/audit_report.ts b/examples/sdk_example/src/enterpriseReport/audit_report.ts new file mode 100644 index 00000000..9e846bee --- /dev/null +++ b/examples/sdk_example/src/enterpriseReport/audit_report.ts @@ -0,0 +1,179 @@ +import { + AuditOutputFormat, + AuditReportFormat, + AuditReportOrder, + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { AuditReportFilter, AuditReportOptions } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { formatAuditReportResult } from '../utils/reportFormat' + +async function promptAuditFilters(): Promise { + const filter: AuditReportFilter = {} + + const created = (await prompt('Created filter (e.g. last_7_days, >=2024-01-01, Enter to skip): ')).trim() + if (created) filter.created = created + + const eventType = (await prompt('Event type filter (name or id, comma-separated, Enter to skip): ')).trim() + if (eventType) { + const values = eventType.split(',').map((v) => v.trim()).filter(Boolean) + filter.eventType = + values.length === 1 + ? /^\d+$/.test(values[0]) + ? Number.parseInt(values[0], 10) + : values[0] + : values.map((v) => (/^\d+$/.test(v) ? Number.parseInt(v, 10) : v)) + } + + const username = (await prompt('Username filter (Enter to skip): ')).trim() + if (username) filter.username = username + + const toUsername = (await prompt('To-username filter (Enter to skip): ')).trim() + if (toUsername) filter.toUsername = toUsername + + const ipAddress = (await prompt('IP address filter (comma-separated, Enter to skip): ')).trim() + if (ipAddress) { + const values = ipAddress.split(',').map((v) => v.trim()).filter(Boolean) + filter.ipAddress = values.length === 1 ? values[0] : values + } + + const recordUid = (await prompt('Record UID filter (Enter to skip): ')).trim() + if (recordUid) filter.recordUid = recordUid + + const sharedFolderUid = (await prompt('Shared folder UID filter (Enter to skip): ')).trim() + if (sharedFolderUid) filter.sharedFolderUid = sharedFolderUid + + const geoLocation = (await prompt('Geo location filter (city, region, country, Enter to skip): ')).trim() + if (geoLocation) filter.geoLocation = geoLocation + + const deviceType = (await prompt('Device type filter (type[,version], Enter to skip): ')).trim() + if (deviceType) filter.deviceType = deviceType + + const parentId = (await prompt('Parent node ID filter (Enter to skip): ')).trim() + if (parentId) { + const values = parentId.split(',').map((v) => v.trim()).filter(Boolean) + filter.parentId = + values.length === 1 + ? Number.parseInt(values[0], 10) + : values.map((v) => Number.parseInt(v, 10)) + } + + return Object.keys(filter).length > 0 ? filter : undefined +} + +async function auditReportExample() { + const vault = await login() + + try { + const reportType = (await prompt('Report type [raw/dim/hour/day/week/month/span]: ')).trim().toLowerCase() + if (!reportType) { + logger.error('Report type is required.') + process.exitCode = 1 + return + } + + const options: AuditReportOptions = { + reportType: reportType as AuditReportOptions['reportType'], + } + + if (reportType === 'raw') { + const formatRaw = (await prompt('Report format [message/fields, default message]: ')).trim().toLowerCase() + if (formatRaw === 'fields') options.reportFormat = AuditReportFormat.Fields + + options.filter = await promptAuditFilters() + + const timezone = (await prompt('Timezone (e.g. America/Los_Angeles, Enter for default): ')).trim() + if (timezone) options.timezone = timezone + + const limitRaw = (await prompt('Limit (Enter for default, -1 for all raw rows): ')).trim() + if (limitRaw) { + const parsed = Number.parseInt(limitRaw, 10) + if (!Number.isFinite(parsed) || parsed === 0 || parsed < -1) { + logger.error('Limit must be a positive number or -1.') + process.exitCode = 1 + return + } + options.limit = parsed + } + + const orderRaw = (await prompt('Order [asc/desc, Enter for desc]: ')).trim().toLowerCase() + if (orderRaw === 'asc') options.order = AuditReportOrder.Asc + else if (orderRaw === 'desc') options.order = AuditReportOrder.Desc + } else if (reportType === 'dim') { + const column = (await prompt('Dimension column (e.g. audit_event_type): ')).trim() + if (!column) { + logger.error('Dimension column is required.') + process.exitCode = 1 + return + } + options.columns = [column] + } else { + const columnsRaw = (await prompt('Columns (comma-separated, e.g. username): ')).trim() + if (!columnsRaw) { + logger.error('Columns are required for summary reports.') + process.exitCode = 1 + return + } + options.columns = columnsRaw.split(',').map((v) => v.trim()).filter(Boolean) + + const aggregatesRaw = ( + await prompt('Aggregates [occurrences/first_created/last_created, comma-separated, Enter for occurrences]: ') + ) + .trim() + .toLowerCase() + if (aggregatesRaw) { + options.aggregates = aggregatesRaw.split(',').map((v) => v.trim()).filter(Boolean) as AuditReportOptions['aggregates'] + } + + options.filter = await promptAuditFilters() + + const timezone = (await prompt('Timezone (e.g. America/Los_Angeles, Enter for default): ')).trim() + if (timezone) options.timezone = timezone + + const limitRaw = (await prompt('Limit (Enter for default): ')).trim() + if (limitRaw) { + const parsed = Number.parseInt(limitRaw, 10) + if (!Number.isFinite(parsed) || parsed < 0) { + logger.error('Limit must be a non-negative number.') + process.exitCode = 1 + return + } + options.limit = parsed + } + + const orderRaw = (await prompt('Order [asc/desc, Enter for default]: ')).trim().toLowerCase() + if (orderRaw === 'asc') options.order = AuditReportOrder.Asc + else if (orderRaw === 'desc') options.order = AuditReportOrder.Desc + } + + const outputRaw = (await prompt('Output format [table/json/csv, default table]: ')).trim().toLowerCase() + let outputFormat = AuditOutputFormat.Table + if (outputRaw === 'json') outputFormat = AuditOutputFormat.Json + else if (outputRaw === 'csv') outputFormat = AuditOutputFormat.Csv + + const restore = suppressLogs() + let result + try { + result = await vault.runAuditReport(options) + } finally { + restore() + } + + logger.info('') + logger.info(formatAuditReportResult(result, outputFormat)) + logger.info('') + logger.info(`Rows: ${result.rows.length}`) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(auditReportExample) diff --git a/examples/sdk_example/src/enterpriseReport/password_report.ts b/examples/sdk_example/src/enterpriseReport/password_report.ts new file mode 100644 index 00000000..4d9fe3c5 --- /dev/null +++ b/examples/sdk_example/src/enterpriseReport/password_report.ts @@ -0,0 +1,79 @@ +import { + AuditOutputFormat, + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { PasswordReportOptions } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { formatPasswordReportResult } from '../utils/reportFormat' + +function parseOptionalInt(value: string): number | undefined { + const trimmed = value.trim() + if (!trimmed) return undefined + const parsed = Number.parseInt(trimmed, 10) + return Number.isFinite(parsed) ? parsed : undefined +} + +async function passwordReportExample() { + const vault = await login() + + try { + const folder = (await prompt('Folder path or UID (Enter for entire vault): ')).trim() + + const policyRaw = (await prompt('Policy Length,Lower,Upper,Digits,Special (e.g. 12,2,2,2,0): ')).trim() + let options: PasswordReportOptions = {} + + if (policyRaw) { + options.policy = policyRaw + } else { + const length = parseOptionalInt(await prompt('Min length [-l]: ')) + const lower = parseOptionalInt(await prompt('Min lowercase [--lower]: ')) + const upper = parseOptionalInt(await prompt('Min uppercase [-u]: ')) + const digits = parseOptionalInt(await prompt('Min digits [-d]: ')) + const special = parseOptionalInt(await prompt('Min special [-s]: ')) + options = { + ...(length != null ? { length } : {}), + ...(lower != null ? { lower } : {}), + ...(upper != null ? { upper } : {}), + ...(digits != null ? { digits } : {}), + ...(special != null ? { special } : {}), + } + } + + if (folder) options.folder = folder + + const verboseRaw = (await prompt('Verbose (score/BreachWatch/reuse)? [y/N]: ')).trim().toLowerCase() + if (verboseRaw === 'y' || verboseRaw === 'yes') options.verbose = true + + const outputRaw = (await prompt('Output format [table/json/csv, default table]: ')).trim().toLowerCase() + let outputFormat = AuditOutputFormat.Table + if (outputRaw === 'json') outputFormat = AuditOutputFormat.Json + else if (outputRaw === 'csv') outputFormat = AuditOutputFormat.Csv + + const restore = suppressLogs() + let result + try { + result = await vault.runPasswordReport(options) + } finally { + restore() + } + + logger.info('') + logger.info(`Policy: ${result.policySummary}`) + logger.info('') + logger.info(formatPasswordReportResult(result, outputFormat)) + logger.info('') + logger.info(`Rows: ${result.rows.length}`) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(passwordReportExample) diff --git a/examples/sdk_example/src/utils/reportFormat.ts b/examples/sdk_example/src/utils/reportFormat.ts new file mode 100644 index 00000000..31f48525 --- /dev/null +++ b/examples/sdk_example/src/utils/reportFormat.ts @@ -0,0 +1,125 @@ +import { + AuditOutputFormat, + type ActionReportResult, + type AuditReportResult, + type PasswordReportResult, + type PasswordReportRow, +} from '@keeper-security/keeper-sdk-javascript' + +const PASSWORD_REPORT_BASE_HEADERS = [ + 'record_uid', + 'title', + 'description', + 'length', + 'lower', + 'upper', + 'digits', + 'special', +] as const + +const PASSWORD_REPORT_VERBOSE_HEADERS = ['score', 'status', 'reused'] as const + +export function fieldToTitle(field: string): string { + return field + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +export function formatReportOutput( + headers: readonly string[], + rows: readonly string[][], + outputFormat: AuditOutputFormat +): string { + if (outputFormat === AuditOutputFormat.Json) { + return JSON.stringify( + rows.map((row) => + Object.fromEntries(headers.map((header, index) => [header, row[index] ?? ''])) + ), + null, + 2 + ) + } + if (outputFormat === AuditOutputFormat.Csv) { + const escape = (value: string) => `"${value.replace(/"/g, '""')}"` + return [ + headers.map(escape).join(','), + ...rows.map((row) => row.map((cell) => escape(cell ?? '')).join(',')), + ].join('\n') + } + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || '').length), 2) + ) + const pad = (cell: string, index: number) => cell + ' '.repeat(Math.max(0, widths[index] - cell.length)) + const formatRow = (cells: readonly string[]) => cells.map((cell, index) => pad(cell, index)).join(' ') + const rule = formatRow(widths.map((width) => '-'.repeat(width))) + return [formatRow(headers), rule, ...rows.map((row) => formatRow(row))].join('\n') +} + +export function formatAuditReportResult( + result: AuditReportResult, + outputFormat: AuditOutputFormat = AuditOutputFormat.Table +): string { + const headers = + outputFormat === AuditOutputFormat.Json ? result.headers : result.headers.map(fieldToTitle) + + if (result.syntaxHelp && result.eventTypeReference) { + const reference = formatReportOutput(headers, result.rows, outputFormat) + return `${result.syntaxHelp}\n\nEvent type ids and names:\n\n${reference}` + } + + return formatReportOutput(headers, result.rows, outputFormat) +} + +export function formatActionReportResult( + result: ActionReportResult, + outputFormat: AuditOutputFormat = AuditOutputFormat.Table +): string { + return formatReportOutput(result.headers, result.rows, outputFormat) +} + +function buildPasswordTableHeaders(verbose: boolean, rowNumbers: boolean): string[] { + const headers: string[] = [...PASSWORD_REPORT_BASE_HEADERS] + if (verbose) headers.push(...PASSWORD_REPORT_VERBOSE_HEADERS) + if (rowNumbers) headers.unshift('#') + return headers +} + +function passwordRowToCells( + row: PasswordReportRow, + verbose: boolean, + rowNumbers: boolean, + rowNumber: number +): string[] { + const cells = [ + row.recordUid, + row.title, + row.description, + String(row.length), + String(row.lower), + String(row.upper), + String(row.digits), + String(row.special), + ] + + if (verbose) { + cells.push(row.score ?? '', row.status ?? '', row.reused ?? '') + } + if (rowNumbers) { + cells.unshift(String(rowNumber)) + } + + return cells +} + +export function formatPasswordReportResult( + result: PasswordReportResult, + outputFormat: AuditOutputFormat = AuditOutputFormat.Table, + rowNumbers = result.rowNumbers +): string { + const headers = buildPasswordTableHeaders(result.verbose, rowNumbers) + const rows = result.rows.map((row, index) => + passwordRowToCells(row, result.verbose, rowNumbers, index + 1) + ) + return formatReportOutput(headers, rows, outputFormat) +} diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index 4b9bff28..14af7a5a 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -419,6 +419,84 @@ export const putEnterpriseSettingCommand = ( request: PutEnterpriseSettingRequest ): RestCommand => createCommand(request, 'put_enterprise_setting') +export type AuditReportFilterPayload = { + created?: + | 'today' + | 'yesterday' + | 'last_7_days' + | 'last_30_days' + | 'month_to_date' + | 'last_month' + | 'year_to_date' + | 'last_year' + | { + min?: number | string + max?: number | string + exclude_min?: boolean + exclude_max?: boolean + } + event_type?: string | number | Array + audit_event_type?: string | number | Array + keeper_version?: number | number[] + username?: string | string[] + to_username?: string | string[] + ip_address?: string[] + record_uid?: string | string[] + shared_folder_uid?: string | string[] + parent_id?: number | number[] +} + +export type GetAuditEventReportsRequest = { + report_type: string + scope: 'enterprise' | 'user' + timezone: string + limit: number + order?: 'ascending' | 'descending' + filter?: AuditReportFilterPayload + aggregate?: string[] + columns?: string[] + report_format?: 'message' | 'fields' +} + +export type GetAuditEventReportsResponse = KeeperResponse & { + timezone?: string + audit_event_overview_report_rows?: Array> +} + +export const getAuditEventReportsCommand = ( + request: GetAuditEventReportsRequest +): RestCommand => + createCommand(request, 'get_audit_event_reports') + +export type GetAuditEventDimensionsRequest = { + report_type: 'dim' + columns: string[] + limit: number + scope: 'enterprise' | 'user' +} + +export type GetAuditEventDimensionsResponse = KeeperResponse & { + dimensions?: Record +} + +export const getAuditEventDimensionsCommand = ( + request: GetAuditEventDimensionsRequest +): RestCommand => + createCommand(request, 'get_audit_event_dimensions') + +export type GetEnterpriseDataRequest = { + include: string[] +} + +export type GetEnterpriseDataCommandResponse = KeeperResponse & { + licenses?: Record[] +} + +export const getEnterpriseDataCommand = ( + request: GetEnterpriseDataRequest +): RestCommand => + createCommand(request, 'get_enterprise_data') + /** export class KeeperCommand { command?: string; From 2c038c9abb75f7cf33d0e5023edbeb21357273ba Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 1 Jul 2026 20:19:06 +0530 Subject: [PATCH 2/3] Improve Polynomial regular expression for password report --- KeeperSdk/src/enterpriseReport/auditReport.ts | 23 +++++++++++++++---- KeeperSdk/src/enterpriseReport/reportTypes.ts | 1 - KeeperSdk/src/index.ts | 1 + KeeperSdk/src/utils/Logger.ts | 13 +++++++++-- KeeperSdk/src/utils/index.ts | 2 +- .../src/enterpriseReport/password_report.ts | 3 ++- 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/KeeperSdk/src/enterpriseReport/auditReport.ts b/KeeperSdk/src/enterpriseReport/auditReport.ts index 71cce733..37c5429a 100644 --- a/KeeperSdk/src/enterpriseReport/auditReport.ts +++ b/KeeperSdk/src/enterpriseReport/auditReport.ts @@ -9,7 +9,6 @@ import { AuditAggregate, AuditReportFormat, AuditReportOrder, - AUDIT_CREATED_BETWEEN_PATTERN, AUDIT_DEFAULT_RAW_LIMIT, AUDIT_DEFAULT_SUMMARY_LIMIT, AUDIT_DIMENSION_API_LIMIT, @@ -559,11 +558,27 @@ function advanceCreatedFilter( return next } +function parseBetweenCreatedFilter(trimmed: string): CreatedFilterCriteria | null { + const betweenPrefix = /^\s*between\s+/i.exec(trimmed) + if (!betweenPrefix) return null + + const rest = trimmed.slice(betweenPrefix[0].length) + const andMarker = ' and ' + const andIndex = rest.toLowerCase().indexOf(andMarker) + if (andIndex <= 0) return null + + const fromDateStr = rest.slice(0, andIndex).trim() + const toDateStr = rest.slice(andIndex + andMarker.length).trim() + if (!fromDateStr || !toDateStr) return null + + return { fromDate: toEpoch(fromDateStr), toDate: toEpoch(toDateStr) } +} + function parseCreatedFilter(value: string): CreatedFilterCriteria { const trimmed = value.trim() - const betweenMatch = AUDIT_CREATED_BETWEEN_PATTERN.exec(trimmed) - if (betweenMatch) { - return { fromDate: toEpoch(betweenMatch[1]), toDate: toEpoch(betweenMatch[2]) } + const betweenFilter = parseBetweenCreatedFilter(trimmed) + if (betweenFilter) { + return betweenFilter } for (const prefix of ['>=', '<=', '>', '<'] as const) { if (!trimmed.startsWith(prefix)) continue diff --git a/KeeperSdk/src/enterpriseReport/reportTypes.ts b/KeeperSdk/src/enterpriseReport/reportTypes.ts index 7000a5e5..4fb152a2 100644 --- a/KeeperSdk/src/enterpriseReport/reportTypes.ts +++ b/KeeperSdk/src/enterpriseReport/reportTypes.ts @@ -334,7 +334,6 @@ export const AUDIT_DEFAULT_RAW_LIMIT = 50 export const AUDIT_DEFAULT_SUMMARY_LIMIT = 100 export const AUDIT_SUMMARY_MAX_LIMIT = 2000 export const AUDIT_DIMENSION_API_LIMIT = 2000 -export const AUDIT_CREATED_BETWEEN_PATTERN = /^\s*between\s+(\S+)\s+and\s+(.*)$/i export const AUDIT_VIRTUAL_DIMENSIONS: Readonly> = { geo_location: 'ip_address', device_type: 'keeper_version', diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index eb6188ed..8e812d8e 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -19,6 +19,7 @@ export { setLogger, getLogger, resetLogger, + writeOutput, KeeperSdkError, isKeeperError, extractErrorMessage, diff --git a/KeeperSdk/src/utils/Logger.ts b/KeeperSdk/src/utils/Logger.ts index 40847e5f..d9d1ceef 100644 --- a/KeeperSdk/src/utils/Logger.ts +++ b/KeeperSdk/src/utils/Logger.ts @@ -15,9 +15,18 @@ export interface ILogger { const CREDENTIAL_PATTERN = /\b(password|passwd|pwd|secret|api[_-]?key|auth[_-]?token)\s*[:=]\s*\S+/gi +function redactSensitiveString(value: string): string { + return value.replace(CREDENTIAL_PATTERN, (_, key: string) => `${key}=[REDACTED]`) +} + function sanitizeArg(arg: unknown): unknown { - if (typeof arg !== 'string') return arg - return arg.replace(CREDENTIAL_PATTERN, (_, key: string) => `${key}=[REDACTED]`) + if (typeof arg === 'string') return redactSensitiveString(arg) + if (Array.isArray(arg)) return arg.map(sanitizeArg) + return arg +} + +export function writeOutput(message: string): void { + process.stdout.write(message.endsWith('\n') ? message : `${message}\n`) } export class ConsoleLogger implements ILogger { diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index 569394a5..65ea9f2b 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -14,7 +14,7 @@ export { NsfErrorCode, KEEPER_PUBLIC_HOSTS, } from './constants' -export { Logger, ConsoleLogger, LogLevel, logger, setLogger, getLogger, resetLogger } from './Logger' +export { Logger, ConsoleLogger, LogLevel, logger, setLogger, getLogger, resetLogger, writeOutput } from './Logger' export type { ILogger } from './Logger' export { KeeperSdkError, isKeeperError, extractErrorMessage, extractResultCode } from './errors' export type { Nullable, Optional, DeepPartial, Immutable } from './types' diff --git a/examples/sdk_example/src/enterpriseReport/password_report.ts b/examples/sdk_example/src/enterpriseReport/password_report.ts index 4d9fe3c5..cd6709ea 100644 --- a/examples/sdk_example/src/enterpriseReport/password_report.ts +++ b/examples/sdk_example/src/enterpriseReport/password_report.ts @@ -6,6 +6,7 @@ import { logger, prompt, suppressLogs, + writeOutput, } from '@keeper-security/keeper-sdk-javascript' import type { PasswordReportOptions } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' @@ -65,7 +66,7 @@ async function passwordReportExample() { logger.info('') logger.info(`Policy: ${result.policySummary}`) logger.info('') - logger.info(formatPasswordReportResult(result, outputFormat)) + writeOutput(formatPasswordReportResult(result, outputFormat)) logger.info('') logger.info(`Rows: ${result.rows.length}`) } catch (err) { From 8d14e024d0a018dde6dfdeba3ff558e306648aab Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Thu, 2 Jul 2026 13:35:56 +0530 Subject: [PATCH 3/3] Use audit_event_type for audit report filters --- KeeperSdk/src/enterpriseReport/auditReport.ts | 2 +- KeeperSdk/src/enterpriseReport/reportTypes.ts | 1 - keeperapi/src/commands.ts | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/KeeperSdk/src/enterpriseReport/auditReport.ts b/KeeperSdk/src/enterpriseReport/auditReport.ts index 37c5429a..c0a975cd 100644 --- a/KeeperSdk/src/enterpriseReport/auditReport.ts +++ b/KeeperSdk/src/enterpriseReport/auditReport.ts @@ -514,7 +514,7 @@ function serializeFilter(filter: AuditReportFilter | undefined): AuditReportFilt payload.created = createdCriteria(filter.created) } } - if (filter.eventType !== undefined) payload.event_type = filter.eventType + if (filter.eventType !== undefined) payload.audit_event_type = filter.eventType if (filter.keeperVersion !== undefined) payload.keeper_version = filter.keeperVersion if (filter.username !== undefined) payload.username = filter.username if (filter.toUsername !== undefined) payload.to_username = filter.toUsername diff --git a/KeeperSdk/src/enterpriseReport/reportTypes.ts b/KeeperSdk/src/enterpriseReport/reportTypes.ts index 4fb152a2..f3cde4f6 100644 --- a/KeeperSdk/src/enterpriseReport/reportTypes.ts +++ b/KeeperSdk/src/enterpriseReport/reportTypes.ts @@ -64,7 +64,6 @@ export type AuditReportFilterPayload = { exclude_min?: boolean exclude_max?: boolean } - event_type?: string | number | Array audit_event_type?: string | number | Array keeper_version?: number | number[] username?: string | string[] diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index 14af7a5a..d8d97c80 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -435,7 +435,6 @@ export type AuditReportFilterPayload = { exclude_min?: boolean exclude_max?: boolean } - event_type?: string | number | Array audit_event_type?: string | number | Array keeper_version?: number | number[] username?: string | string[]