From 7fc3bcccce80a39777f31660ed85c08dbdf6efa8 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 10:09:48 +0100 Subject: [PATCH 01/15] Add end-to-end risk-score-v2 Entity Store test command Introduce a new risk-score-v2 command that seeds realistic entities, ingests events and alerts, runs maintainers, and prints a compact scorecard with clearer diagnostics for missing scores and API failures. Made-with: Cursor --- src/commands/entity_store/index.ts | 36 + src/commands/entity_store/risk_score_v2.ts | 1079 ++++++++++++++++++++ src/commands/shared/elasticsearch.ts | 30 +- src/commands/utils/cli_utils.ts | 60 +- src/constants.ts | 10 + src/generators/create_alerts.ts | 20 +- src/utils/kibana_api.ts | 396 ++++++- 7 files changed, 1621 insertions(+), 10 deletions(-) create mode 100644 src/commands/entity_store/risk_score_v2.ts diff --git a/src/commands/entity_store/index.ts b/src/commands/entity_store/index.ts index 523b74f1..6652beaa 100644 --- a/src/commands/entity_store/index.ts +++ b/src/commands/entity_store/index.ts @@ -17,6 +17,7 @@ import { promptForTextInput, } from '../utils/interactive_prompts.ts'; import { ensureSpace } from '../../utils/index.ts'; +import { riskScoreV2Command } from './risk_score_v2.ts'; export const entityStoreCommands: CommandModule = { register(program: Command) { @@ -206,5 +207,40 @@ export const entityStoreCommands: CommandModule = { }); }), ); + + program + .command('risk-score-v2') + .description('End-to-end Entity Store V2 risk score test command') + .option( + '--entity-kinds ', + 'comma-separated kinds: host,idp_user,local_user,service (default: host,idp_user)', + ) + .option('--users ', 'number of user entities (default 10)') + .option('--hosts ', 'number of host entities (default 10)') + .option('--local-users ', 'number of local user entities when local_user kind is enabled') + .option('--services ', 'number of service entities when service kind is enabled') + .option('--alerts-per-entity ', 'number of alerts per entity (default 5)') + .option('--space ', 'space to use', 'default') + .option('--event-index ', 'event index to ingest source documents into') + .option('--seed-source ', 'entity seed source: basic|org (default basic)') + .option( + '--org-size ', + 'org size when --seed-source org: john_doe|small|medium|enterprise (default small)', + ) + .option( + '--org-productivity-suite ', + 'productivity suite when --seed-source org: microsoft|google (default microsoft)', + ) + .option('--offset-hours ', 'event timestamp offset in hours (default 1)') + .option('--perf', 'scale preset: 1000 users, 1000 hosts, 50 alerts each', false) + .option('--no-setup', 'skip entity store V2 setup') + .option('--no-criticality', 'skip asset criticality assignment') + .option('--no-watchlists', 'skip watchlist creation and assignment') + .option('--no-alerts', 'skip alert generation') + .action( + wrapAction(async (options) => { + await riskScoreV2Command(options); + }), + ); }, }; diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts new file mode 100644 index 00000000..10d6df92 --- /dev/null +++ b/src/commands/entity_store/risk_score_v2.ts @@ -0,0 +1,1079 @@ +import { faker } from '@faker-js/faker'; +import auditbeatMappings from '../../mappings/auditbeat.json' with { type: 'json' }; +import { bulkIngest, bulkUpsert } from '../shared/elasticsearch.ts'; +import { getEsClient } from '../utils/indices.ts'; +import { ensureSpace, getAlertIndex } from '../../utils/index.ts'; +import { ensureSecurityDefaultDataView } from '../../utils/security_default_data_view.ts'; +import { + createWatchlist, + enableEntityStoreV2, + forceLogExtraction, + forceUpdateEntityViaCrud, + getEntityMaintainers, + initEntityMaintainers, + installEntityStoreV2, + runEntityMaintainer, +} from '../../utils/kibana_api.ts'; +import { log } from '../../utils/logger.ts'; +import createAlerts from '../../generators/create_alerts.ts'; +import { type MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; +import { getConfig } from '../../get_config.ts'; +import { generateOrgData } from '../org_data/org_data_generator.ts'; +import type { OrganizationSize, ProductivitySuite } from '../org_data/types.ts'; + +type RiskScoreV2Options = { + users?: string; + hosts?: string; + services?: string; + localUsers?: string; + alertsPerEntity?: string; + entityKinds?: string; + offsetHours?: string; + space?: string; + setup?: boolean; + criticality?: boolean; + watchlists?: boolean; + alerts?: boolean; + perf?: boolean; + eventIndex?: string; + seedSource?: string; + orgSize?: string; + orgProductivitySuite?: string; +}; + +type SeededUser = { userName: string; userId: string; userEmail: string }; +type SeededHost = { hostName: string; hostId: string }; +type SeededLocalUser = { userName: string; hostId: string; hostName: string }; +type SeededService = { serviceName: string }; +type EntityKind = 'host' | 'idp_user' | 'local_user' | 'service'; +type SeedSource = 'basic' | 'org'; + +const sleep = async (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const parseIntOption = (value: string | undefined, fallback: number): number => + value ? parseInt(value, 10) : fallback; + +const SUPPORTED_ENTITY_KINDS: EntityKind[] = ['host', 'idp_user', 'local_user', 'service']; +const SUPPORTED_SEED_SOURCES: SeedSource[] = ['basic', 'org']; +const SUPPORTED_ORG_SIZES: OrganizationSize[] = ['john_doe', 'small', 'medium', 'enterprise']; +const SUPPORTED_PRODUCTIVITY_SUITES: ProductivitySuite[] = ['microsoft', 'google']; + +const parseEntityKinds = (value?: string): EntityKind[] => { + const rawKinds = (value ?? 'host,idp_user') + .split(',') + .map((k) => k.trim()) + .filter(Boolean); + const kinds = [...new Set(rawKinds)] as string[]; + const invalid = kinds.filter((k) => !SUPPORTED_ENTITY_KINDS.includes(k as EntityKind)); + if (invalid.length > 0) { + throw new Error( + `Invalid --entity-kinds value(s): ${invalid.join(', ')}. Supported kinds: ${SUPPORTED_ENTITY_KINDS.join(', ')}`, + ); + } + return kinds as EntityKind[]; +}; + +const parseSeedSource = (value?: string): SeedSource => { + const seedSource = (value ?? 'basic').trim().toLowerCase(); + if (!SUPPORTED_SEED_SOURCES.includes(seedSource as SeedSource)) { + throw new Error( + `Invalid --seed-source value "${value}". Supported values: ${SUPPORTED_SEED_SOURCES.join(', ')}`, + ); + } + return seedSource as SeedSource; +}; + +const parseOrgSize = (value?: string): OrganizationSize => { + const orgSize = (value ?? 'small').trim().toLowerCase(); + if (!SUPPORTED_ORG_SIZES.includes(orgSize as OrganizationSize)) { + throw new Error( + `Invalid --org-size value "${value}". Supported values: ${SUPPORTED_ORG_SIZES.join(', ')}`, + ); + } + return orgSize as OrganizationSize; +}; + +const parseProductivitySuite = (value?: string): ProductivitySuite => { + const suite = (value ?? 'microsoft').trim().toLowerCase(); + if (!SUPPORTED_PRODUCTIVITY_SUITES.includes(suite as ProductivitySuite)) { + throw new Error( + `Invalid --org-productivity-suite value "${value}". Supported values: ${SUPPORTED_PRODUCTIVITY_SUITES.join(', ')}`, + ); + } + return suite as ProductivitySuite; +}; + +const ensureEventTarget = async (eventIndex: string): Promise<'index' | 'create'> => { + const client = getEsClient(); + const exists = await client.indices.exists({ index: eventIndex }); + if (exists) { + try { + await client.indices.getDataStream({ name: eventIndex }); + return 'create'; + } catch { + return 'index'; + } + } + + try { + await client.indices.create({ + index: eventIndex, + settings: { + 'index.mapping.total_fields.limit': 10000, + }, + mappings: auditbeatMappings as MappingTypeMapping, + }); + log.info(`Created event index "${eventIndex}"`); + return 'index'; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Expected when template only allows data streams + if ( + message.includes('creates data streams only') || + message.includes('use create data stream api') + ) { + log.info( + `Event target "${eventIndex}" is data-stream-backed; creating data stream instead of index.`, + ); + await client.indices.createDataStream({ name: eventIndex }); + return 'create'; + } + throw error; + } +}; + +const toUserEuid = (user: SeededUser) => `user:${user.userEmail}@okta`; +const toHostEuid = (host: SeededHost) => `host:${host.hostId}`; + +const compactSeedToken = (value: string, fallback: string): string => { + const normalized = value.replace(/[^a-z0-9]/gi, '').toLowerCase(); + return (normalized || fallback).slice(0, 4); +}; + +const seedUsers = (count: number): SeededUser[] => + Array.from({ length: count }, (_, i) => { + const suffix = faker.string.alphanumeric(4).toLowerCase(); + const userName = `rv2u-${i}-${suffix}`; + return { + userName, + userId: `risk-v2-user-id-${i}-${faker.string.alphanumeric(6)}`, + userEmail: `${userName}@example.com`, + }; + }); + +const seedHosts = (count: number): SeededHost[] => + Array.from({ length: count }, (_, i) => ({ + hostName: `risk-v2-host-${i}-${faker.internet.domainWord()}`, + hostId: `risk-v2-host-id-${i}-${faker.string.alphanumeric(8).toLowerCase()}`, + })); + +const seedLocalUsers = (count: number, hosts: SeededHost[]): SeededLocalUser[] => + Array.from({ length: count }, (_, i) => { + const host = hosts[i % hosts.length] ?? seedHosts(1)[0]; + return { + userName: `risk-v2-local-user-${i}-${faker.internet.username()}`, + hostId: host.hostId, + hostName: host.hostName, + }; + }); + +const seedServices = (count: number): SeededService[] => + Array.from({ length: count }, (_, i) => ({ + serviceName: `risk-v2-service-${i}-${faker.internet.domainWord()}`, + })); + +const topUpToCount = (items: T[], count: number, factory: (remaining: number) => T[]): T[] => { + if (items.length >= count) { + return items.slice(0, count); + } + return [...items, ...factory(count - items.length)]; +}; + +const seedFromOrgData = ({ + usersCount, + hostsCount, + localUsersCount, + servicesCount, + orgSize, + productivitySuite, +}: { + usersCount: number; + hostsCount: number; + localUsersCount: number; + servicesCount: number; + orgSize: OrganizationSize; + productivitySuite: ProductivitySuite; +}): { + users: SeededUser[]; + hosts: SeededHost[]; + localUsers: SeededLocalUser[]; + services: SeededService[]; +} => { + const org = generateOrgData({ + name: 'Risk Score Test Org', + domain: 'risk-score-test.local', + size: orgSize, + productivitySuite, + }); + + const orgUsers: SeededUser[] = org.employees.map((employee, i) => { + const token = compactSeedToken(employee.userName || employee.email, 'user'); + const compactUserName = `rv2u-${i}-${token}`; + return { + userName: compactUserName, + userId: employee.oktaUserId, + userEmail: `${compactUserName}@example.com`, + }; + }); + const users = topUpToCount(orgUsers, usersCount, seedUsers); + + const orgHosts: SeededHost[] = org.hosts.map((host) => ({ + hostName: host.name, + hostId: host.id, + })); + const hosts = topUpToCount(orgHosts, hostsCount, seedHosts); + + const fallbackHosts = hosts.length > 0 ? hosts : seedHosts(Math.max(1, localUsersCount)); + const orgLocalUsers: SeededLocalUser[] = org.employees.map((employee, i) => { + const host = fallbackHosts[i % fallbackHosts.length]; + return { + userName: employee.userName, + hostName: host.hostName, + hostId: host.hostId, + }; + }); + const localUsers = topUpToCount(orgLocalUsers, localUsersCount, (remaining) => + seedLocalUsers(remaining, fallbackHosts), + ); + + const orgServices: SeededService[] = org.cloudIamUsers.map((iamUser) => ({ + serviceName: iamUser.userName, + })); + const services = topUpToCount(orgServices, servicesCount, seedServices); + + return { users, hosts, localUsers, services }; +}; + +const buildUserEvents = (users: SeededUser[], offsetHours: number) => { + const timestamp = new Date(Date.now() - offsetHours * 60 * 60 * 1000).toISOString(); + return users.map((user) => ({ + '@timestamp': timestamp, + message: `Risk score v2 user event for ${user.userName}`, + // Align with user entity definition postAggFilter for IDP path + // (event.kind includes asset OR event.category includes iam + event.type includes user). + 'event.kind': ['asset'], + 'event.category': ['iam'], + 'event.type': ['user'], + 'event.module': 'okta', + 'event.dataset': 'okta.system', + 'service.type': 'system', + 'data_stream.type': 'logs', + 'data_stream.dataset': 'okta.system', + 'data_stream.namespace': 'default', + 'user.name': user.userName, + 'user.id': user.userId, + 'user.email': user.userEmail, + })); +}; + +const buildHostEvents = (hosts: SeededHost[], offsetHours: number) => { + const timestamp = new Date(Date.now() - offsetHours * 60 * 60 * 1000).toISOString(); + return hosts.map((host) => ({ + '@timestamp': timestamp, + message: `Risk score v2 host event for ${host.hostName}`, + 'event.kind': 'event', + 'event.module': 'okta', + 'event.dataset': 'okta.system', + 'service.type': 'system', + 'data_stream.type': 'logs', + 'data_stream.dataset': 'okta.system', + 'data_stream.namespace': 'default', + 'host.name': host.hostName, + 'host.id': host.hostId, + })); +}; + +const buildLocalUserEvents = (localUsers: SeededLocalUser[], offsetHours: number) => { + const timestamp = new Date(Date.now() - offsetHours * 60 * 60 * 1000).toISOString(); + return localUsers.map((user) => ({ + '@timestamp': timestamp, + message: `Risk score v2 local user event for ${user.userName}`, + event: { kind: 'event', category: 'network', outcome: 'success', module: 'local' }, + 'event.module': 'local', + 'event.dataset': 'okta.system', + 'service.type': 'system', + 'data_stream.type': 'logs', + 'data_stream.dataset': 'okta.system', + 'data_stream.namespace': 'default', + 'user.name': user.userName, + 'host.id': user.hostId, + 'host.name': user.hostName, + })); +}; + +const buildServiceEvents = (services: SeededService[], offsetHours: number) => { + const timestamp = new Date(Date.now() - offsetHours * 60 * 60 * 1000).toISOString(); + return services.map((service) => ({ + '@timestamp': timestamp, + message: `Risk score v2 service event for ${service.serviceName}`, + event: { kind: 'event', category: 'network', outcome: 'success' }, + 'event.module': 'okta', + 'event.dataset': 'okta.system', + 'service.type': 'system', + 'service.name': service.serviceName, + 'data_stream.type': 'logs', + 'data_stream.dataset': 'okta.system', + 'data_stream.namespace': 'default', + })); +}; + +const buildAlertOps = ({ + hosts, + idpUsers, + localUsers, + services, + alertsPerEntity, + space, +}: { + hosts: SeededHost[]; + idpUsers: SeededUser[]; + localUsers: SeededLocalUser[]; + services: SeededService[]; + alertsPerEntity: number; + space: string; +}): unknown[] => { + const alertIndex = getAlertIndex(space); + const docs: unknown[] = []; + + for (const user of idpUsers) { + for (let i = 0; i < alertsPerEntity; i++) { + const riskScore = faker.number.int({ min: 20, max: 100 }); + const alert = createAlerts( + { + 'kibana.alert.risk_score': riskScore, + 'kibana.alert.rule.risk_score': riskScore, + 'kibana.alert.rule.parameters': { description: 'risk v2 test', risk_score: riskScore }, + 'event.kind': ['asset'], + 'event.category': ['iam'], + 'event.type': ['user'], + 'user.email': user.userEmail, + }, + { + userName: user.userName, + userId: user.userId, + hostName: `user-alert-host-${i}-${user.userId}`, + eventModule: 'okta', + space, + }, + ); + const id = (alert as Record)['kibana.alert.uuid'] as string; + docs.push({ create: { _index: alertIndex, _id: id } }); + docs.push(alert); + } + } + + for (const user of localUsers) { + for (let i = 0; i < alertsPerEntity; i++) { + const riskScore = faker.number.int({ min: 20, max: 100 }); + const alert = createAlerts( + { + 'kibana.alert.risk_score': riskScore, + 'kibana.alert.rule.risk_score': riskScore, + 'kibana.alert.rule.parameters': { description: 'risk v2 test', risk_score: riskScore }, + 'event.module': 'local', + 'user.name': user.userName, + 'host.id': user.hostId, + 'host.name': user.hostName, + }, + { + userName: user.userName, + hostName: user.hostName, + hostId: user.hostId, + eventModule: 'local', + space, + }, + ); + const id = (alert as Record)['kibana.alert.uuid'] as string; + docs.push({ create: { _index: alertIndex, _id: id } }); + docs.push(alert); + } + } + + for (const host of hosts) { + for (let i = 0; i < alertsPerEntity; i++) { + const riskScore = faker.number.int({ min: 20, max: 100 }); + const alert = createAlerts( + { + 'kibana.alert.risk_score': riskScore, + 'kibana.alert.rule.risk_score': riskScore, + 'kibana.alert.rule.parameters': { description: 'risk v2 test', risk_score: riskScore }, + }, + { + hostName: host.hostName, + hostId: host.hostId, + userName: `host-alert-user-${i}-${host.hostId}`, + eventModule: 'okta', + space, + }, + ); + const id = (alert as Record)['kibana.alert.uuid'] as string; + docs.push({ create: { _index: alertIndex, _id: id } }); + docs.push(alert); + } + } + + for (const service of services) { + for (let i = 0; i < alertsPerEntity; i++) { + const riskScore = faker.number.int({ min: 20, max: 100 }); + const alert = createAlerts( + { + 'kibana.alert.risk_score': riskScore, + 'kibana.alert.rule.risk_score': riskScore, + 'kibana.alert.rule.parameters': { description: 'risk v2 test', risk_score: riskScore }, + 'service.name': service.serviceName, + 'event.kind': 'event', + 'event.category': 'network', + }, + { + userName: `service-alert-user-${i}`, + hostName: `service-alert-host-${i}`, + space, + }, + ); + const id = (alert as Record)['kibana.alert.uuid'] as string; + docs.push({ create: { _index: alertIndex, _id: id } }); + docs.push(alert); + } + } + + return docs; +}; + +const waitForMaintainerRun = async (space: string, maintainerId: string = 'risk-score') => { + let baselineRuns: number; + try { + const baseline = await getEntityMaintainers(space, [maintainerId]); + const existing = baseline.maintainers.find((m) => m.id === maintainerId); + baselineRuns = existing?.runs ?? 0; + } catch { + baselineRuns = 0; + } + + log.info( + `Triggering maintainer "${maintainerId}" in space "${space}" (baseline runs=${baselineRuns})...`, + ); + await runEntityMaintainer(maintainerId, space); + + const deadline = Date.now() + 90_000; + let lastHeartbeat = 0; + while (Date.now() < deadline) { + const response = await getEntityMaintainers(space, [maintainerId]); + const maintainer = response.maintainers.find((m) => m.id === maintainerId); + if (maintainer && maintainer.runs > baselineRuns) { + log.info( + `Maintainer "${maintainerId}" run observed (runs=${maintainer.runs}, taskStatus=${maintainer.taskStatus}).`, + ); + const settleDeadline = Date.now() + 15_000; + while (Date.now() < settleDeadline) { + const settleResponse = await getEntityMaintainers(space, [maintainerId]); + const settleMaintainer = settleResponse.maintainers.find((m) => m.id === maintainerId); + if (!settleMaintainer || settleMaintainer.taskStatus !== 'started') { + log.info( + `Maintainer "${maintainerId}" appears settled (taskStatus=${settleMaintainer?.taskStatus ?? 'unknown'}).`, + ); + return maintainer.runs; + } + await sleep(2000); + } + log.warn( + `Maintainer "${maintainerId}" still reports taskStatus=started after short settle wait; continuing with summary.`, + ); + return maintainer.runs; + } + const now = Date.now(); + if (now - lastHeartbeat >= 10_000) { + const remainingMs = Math.max(0, deadline - now); + log.info( + `Waiting for maintainer "${maintainerId}" run (baseline=${baselineRuns}, current=${maintainer?.runs ?? 0}, remaining_timeout_ms=${remainingMs})...`, + ); + lastHeartbeat = now; + } + await sleep(3000); + } + + throw new Error(`Timed out waiting for maintainer "${maintainerId}" run`); +}; + +const applyCriticality = async (entityIds: string[], space: string) => { + const levels = ['low_impact', 'medium_impact', 'high_impact', 'extreme_impact'] as const; + const concurrency = 5; + log.info(`Applying criticality to ${entityIds.length} entities (concurrency=${concurrency})...`); + let processed = 0; + for (let i = 0; i < entityIds.length; i += concurrency) { + const batch = entityIds.slice(i, i + concurrency); + await Promise.all( + batch.map(async (entityId) => { + const entityType = entityId.startsWith('user:') ? 'user' : 'host'; + const criticality = faker.helpers.arrayElement(levels); + await forceUpdateEntityViaCrud({ + entityType, + space, + body: { + entity: { id: entityId }, + asset: { criticality }, + }, + }); + }), + ); + processed += batch.length; + if (processed % 10 === 0 || processed === entityIds.length) { + log.info(`Criticality progress: ${processed}/${entityIds.length}`); + } + } + log.info('Criticality assignment complete.'); +}; + +const createWatchlistsForRun = async (space: string) => { + const suffix = Date.now(); + return Promise.all([ + createWatchlist({ name: `high-risk-vendors-${suffix}`, riskModifier: 1.8, space }), + createWatchlist({ name: `departing-employees-${suffix}`, riskModifier: 1.5, space }), + createWatchlist({ name: `insider-threat-${suffix}`, riskModifier: 2.0, space }), + ]); +}; + +const applyWatchlists = async (entityIds: string[], watchlistIds: string[], space: string) => { + const targetCount = Math.max(1, Math.floor(entityIds.length * 0.4)); + const selected = faker.helpers.arrayElements(entityIds, targetCount); + const concurrency = 5; + log.info( + `Applying watchlists to ${selected.length}/${entityIds.length} entities (watchlists=${watchlistIds.length}, concurrency=${concurrency})...`, + ); + + let processed = 0; + for (let i = 0; i < selected.length; i += concurrency) { + const batch = selected.slice(i, i + concurrency); + await Promise.all( + batch.map(async (entityId) => { + const entityType = entityId.startsWith('user:') ? 'user' : 'host'; + const memberships = faker.helpers.arrayElements( + watchlistIds, + faker.number.int({ min: 1, max: 2 }), + ); + await forceUpdateEntityViaCrud({ + entityType, + space, + body: { + entity: { + id: entityId, + attributes: { + watchlists: memberships, + }, + }, + }, + }); + }), + ); + processed += batch.length; + if (processed % 10 === 0 || processed === selected.length) { + log.info(`Watchlist progress: ${processed}/${selected.length}`); + } + } + log.info('Watchlist assignment complete.'); +}; + +const formatCell = (value: string, width: number): string => { + if (value.length === width) return value; + if (value.length < width) return value.padEnd(width, ' '); + if (width <= 3) return value.slice(0, width); + return `${value.slice(0, width - 3)}...`; +}; + +const normalizeWatchlists = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string'); + } + if (typeof value === 'string') { + return [value]; + } + return []; +}; + +const reportRiskSummary = async ({ + space, + baselineRiskScoreCount, + baselineEntityCount, + expectedRiskDelta, + entityIds, +}: { + space: string; + baselineRiskScoreCount: number; + baselineEntityCount: number; + expectedRiskDelta: number; + entityIds: string[]; +}) => { + const client = getEsClient(); + const riskIndex = `risk-score.risk-score-${space}`; + const riskSearch = await client.search({ + index: riskIndex, + size: 1000, + query: { match_all: {} }, + sort: [{ '@timestamp': { order: 'desc' } }], + }); + + const docs = riskSearch.hits.hits.map((hit) => hit._source as Record); + const watchlistModifierDocs = docs.filter((doc) => { + const risk = ((doc.user as Record)?.risk ?? + (doc.host as Record)?.risk ?? + {}) as Record; + const modifiers = (risk.modifiers as Array> | undefined) ?? []; + return modifiers.some((m) => m.type === 'watchlist'); + }).length; + + const total = + typeof riskSearch.hits.total === 'number' + ? riskSearch.hits.total + : (riskSearch.hits.total?.value ?? docs.length); + const entityCount = await getEntityStoreDocCount(space); + const riskDelta = Math.max(0, total - baselineRiskScoreCount); + const entityDelta = Math.max(0, entityCount - baselineEntityCount); + + log.info( + `Run summary (${space}): entities ${baselineEntityCount} -> ${entityCount} (delta +${entityDelta})`, + ); + log.info( + `Run summary (${space}): risk scores ${baselineRiskScoreCount} -> ${total} (delta +${riskDelta})`, + ); + if (riskDelta < expectedRiskDelta) { + log.warn( + `Risk score delta lower than expected for this run (${riskDelta}/${expectedRiskDelta}). This can happen when existing score docs are updated in-place or scoring configuration limits entity types.`, + ); + } + log.info(`Docs with watchlist modifiers: ${watchlistModifierDocs}`); + + const uniqueEntityIds = [...new Set(entityIds)]; + if (uniqueEntityIds.length === 0) { + return; + } + + const entityIndex = `.entities.v2.latest.security_${space}`; + const entityResponse = await client.search({ + index: entityIndex, + size: uniqueEntityIds.length, + query: { + terms: { + 'entity.id': uniqueEntityIds, + }, + }, + _source: ['entity.id', 'entity.type', 'entity.attributes.watchlists', 'asset.criticality'], + }); + + const entityById = new Map< + string, + { entityType: string; criticality: string; watchlists: string[] } + >(); + for (const hit of entityResponse.hits.hits) { + const source = hit._source as + | { + entity?: { id?: string; type?: string; attributes?: { watchlists?: string[] } }; + asset?: { criticality?: string }; + } + | undefined; + const id = source?.entity?.id; + if (!id) continue; + const watchlists = normalizeWatchlists(source?.entity?.attributes?.watchlists); + entityById.set(id, { + entityType: source.entity?.type ?? 'unknown', + criticality: source.asset?.criticality ?? '-', + watchlists, + }); + } + + const riskResponse = await client.search({ + index: riskIndex, + size: Math.max(100, uniqueEntityIds.length * 4), + sort: [{ '@timestamp': { order: 'desc' } }], + query: { + bool: { + should: [ + { terms: { 'host.name': uniqueEntityIds } }, + { terms: { 'user.name': uniqueEntityIds } }, + ], + minimum_should_match: 1, + }, + }, + _source: ['host.name', 'host.risk', 'user.name', 'user.risk'], + }); + + const riskById = new Map(); + for (const hit of riskResponse.hits.hits) { + const source = hit._source as + | { + host?: { + name?: string; + risk?: { calculated_score_norm?: number; calculated_level?: string }; + }; + user?: { + name?: string; + risk?: { calculated_score_norm?: number; calculated_level?: string }; + }; + } + | undefined; + const hostId = source?.host?.name; + const userId = source?.user?.name; + const id = hostId ?? userId; + const risk = source?.host?.risk ?? source?.user?.risk; + if (!id || !risk || riskById.has(id)) continue; + riskById.set(id, { + score: + typeof risk.calculated_score_norm === 'number' + ? risk.calculated_score_norm.toFixed(2) + : '-', + level: risk.calculated_level ?? '-', + }); + } + + const rows = uniqueEntityIds.map((id) => { + const entity = entityById.get(id); + const risk = riskById.get(id); + return { + id, + score: risk?.score ?? '-', + level: risk?.level ?? '-', + criticality: entity?.criticality ?? '-', + watchlistsCount: entity?.watchlists?.length ?? 0, + }; + }); + + const idWidth = 66; + const scoreWidth = 7; + const levelWidth = 8; + const critWidth = 14; + const watchWidth = 5; + const header = [ + formatCell('Entity ID', idWidth), + formatCell('Score', scoreWidth), + formatCell('Lvl', levelWidth), + formatCell('Criticality', critWidth), + formatCell('WL', watchWidth), + ].join(' | '); + const separator = `${'-'.repeat(idWidth)}-+-${'-'.repeat(scoreWidth)}-+-${'-'.repeat(levelWidth)}-+-${'-'.repeat(critWidth)}-+-${'-'.repeat(watchWidth)}`; + + const maxRows = 200; + const rowsToPrint = rows.slice(0, maxRows); + log.info(`Risk docs matched for seeded IDs: ${riskById.size}/${rows.length}`); + log.info( + `Entity scorecard (${rows.length} seeded entities${rows.length > maxRows ? `, showing first ${maxRows}` : ''}):`, + ); + const printLine = (line: string) => { + // eslint-disable-next-line no-console + console.log(line); + }; + printLine(header); + printLine(separator); + for (const row of rowsToPrint) { + printLine( + [ + formatCell(row.id, idWidth), + formatCell(row.score, scoreWidth), + formatCell(row.level, levelWidth), + formatCell(row.criticality, critWidth), + formatCell(String(row.watchlistsCount), watchWidth), + ].join(' | '), + ); + } + if (rows.length > maxRows) { + log.info(`... truncated ${rows.length - maxRows} additional entities from scorecard output.`); + } + const missingRiskDocIds = rows + .filter((row) => row.score === '-' && row.level === '-') + .map((row) => row.id); + if (missingRiskDocIds.length > 0) { + const preview = missingRiskDocIds.slice(0, 5).join(', '); + log.warn( + `Missing risk score docs for ${missingRiskDocIds.length}/${rows.length} seeded entities. Missing examples: ${preview}${missingRiskDocIds.length > 5 ? ', ...' : ''}`, + ); + } else { + log.info(`All ${rows.length}/${rows.length} seeded entities have risk score docs.`); + } +}; + +const getRiskScoreDocCount = async (space: string): Promise => { + const client = getEsClient(); + const index = `risk-score.risk-score-${space}`; + try { + const response = await client.count({ index }); + return response.count; + } catch { + return 0; + } +}; + +const getEntityStoreDocCount = async (space: string): Promise => { + const client = getEsClient(); + const index = `.entities.v2.latest.security_${space}`; + try { + const response = await client.count({ index }); + return response.count; + } catch { + return 0; + } +}; + +const getPresentEntityIds = async (space: string, entityIds: string[]): Promise> => { + if (entityIds.length === 0) { + return new Set(); + } + + const client = getEsClient(); + const index = `.entities.v2.latest.security_${space}`; + const present = new Set(); + const chunkSize = 500; + + for (let i = 0; i < entityIds.length; i += chunkSize) { + const chunk = entityIds.slice(i, i + chunkSize); + try { + const response = await client.search({ + index, + size: chunk.length, + query: { + terms: { + 'entity.id': chunk, + }, + }, + _source: ['entity.id'], + }); + + for (const hit of response.hits.hits) { + const source = hit._source as { entity?: { id?: string } } | undefined; + const id = source?.entity?.id; + if (id) { + present.add(id); + } + } + } catch { + // Index may not exist yet; treat as no matches + } + } + + return present; +}; + +const waitForExpectedEntityIds = async ({ + space, + expectedEntityIds, + timeoutMs = 120000, +}: { + space: string; + expectedEntityIds: string[]; + timeoutMs?: number; +}) => { + if (expectedEntityIds.length === 0) { + return; + } + + const deadline = Date.now() + timeoutMs; + let lastPresent = -1; + let lastHeartbeat = 0; + log.info( + `Waiting for entity extraction in space "${space}" for ${expectedEntityIds.length} expected entity IDs...`, + ); + + while (Date.now() < deadline) { + const present = await getPresentEntityIds(space, expectedEntityIds); + const now = Date.now(); + if (present.size !== lastPresent) { + log.info( + `Entity ID progress (.entities.v2.latest.security_${space}): present=${present.size}/${expectedEntityIds.length}`, + ); + lastPresent = present.size; + lastHeartbeat = now; + } else if (now - lastHeartbeat >= 10_000) { + const remainingMs = Math.max(0, deadline - now); + log.info( + `Still waiting for entity extraction: present=${present.size}/${expectedEntityIds.length}, remaining_timeout_ms=${remainingMs}`, + ); + lastHeartbeat = now; + } + + if (present.size >= expectedEntityIds.length) { + log.info('Entity extraction stage complete.'); + return; + } + await sleep(3000); + } + + const present = await getPresentEntityIds(space, expectedEntityIds); + const missing = expectedEntityIds.filter((id) => !present.has(id)); + const sample = missing.slice(0, 10); + throw new Error( + `Timed out waiting for expected entity IDs in space "${space}". Missing ${missing.length}/${expectedEntityIds.length}. Sample missing IDs: ${sample.join(', ')}`, + ); +}; + +export const riskScoreV2Command = async (options: RiskScoreV2Options) => { + const space = await ensureSpace(options.space ?? 'default'); + const config = getConfig(); + const perf = Boolean(options.perf); + const seedSource = parseSeedSource(options.seedSource); + const orgSize = parseOrgSize(options.orgSize); + const productivitySuite = parseProductivitySuite(options.orgProductivitySuite); + const entityKinds = parseEntityKinds(options.entityKinds); + const usersCount = entityKinds.includes('idp_user') + ? perf + ? 1000 + : parseIntOption(options.users, 10) + : 0; + const hostsCount = entityKinds.includes('host') + ? perf + ? 1000 + : parseIntOption(options.hosts, 10) + : 0; + const localUsersCount = entityKinds.includes('local_user') + ? perf + ? 1000 + : parseIntOption(options.localUsers, 10) + : 0; + const servicesCount = entityKinds.includes('service') + ? perf + ? 1000 + : parseIntOption(options.services, 10) + : 0; + const alertsPerEntity = perf ? 50 : parseIntOption(options.alertsPerEntity, 5); + const offsetHours = parseIntOption(options.offsetHours, 1); + const eventIndex = options.eventIndex || config.eventIndex || 'logs-testlogs-default'; + + log.info( + `Starting risk-score-v2 in space "${space}" with seedSource=${seedSource}, kinds=${entityKinds.join(',')}, idp_users=${usersCount}, local_users=${localUsersCount}, hosts=${hostsCount}, services=${servicesCount}, alertsPerEntity=${alertsPerEntity}, eventIndex=${eventIndex}`, + ); + + if (options.setup !== false) { + await ensureSecurityDefaultDataView(space); + await enableEntityStoreV2(space); + await installEntityStoreV2(space); + } + + const baselineEntityCount = await getEntityStoreDocCount(space); + const baselineRiskScoreCount = await getRiskScoreDocCount(space); + log.info( + `Baselines in space "${space}": entities=${baselineEntityCount}, risk_scores=${baselineRiskScoreCount}`, + ); + + const seeded = + seedSource === 'org' + ? seedFromOrgData({ + usersCount, + hostsCount, + localUsersCount, + servicesCount, + orgSize, + productivitySuite, + }) + : { + users: seedUsers(usersCount), + hosts: seedHosts(hostsCount), + localUsers: [] as SeededLocalUser[], + services: seedServices(servicesCount), + }; + const users = seeded.users; + const hosts = seeded.hosts; + const localUsers = + seedSource === 'org' + ? seeded.localUsers + : seedLocalUsers(localUsersCount, hosts.length > 0 ? hosts : seedHosts(1)); + const services = seeded.services; + const allEntityIds = [ + ...users.map(toUserEuid), + ...localUsers.map((user) => `user:${user.userName}@${user.hostId}@local`), + ...hosts.map(toHostEuid), + ]; + const uniqueEntityIds = [...new Set(allEntityIds)]; + const baselinePresentEntityIds = await getPresentEntityIds(space, uniqueEntityIds); + const expectedNewEntityIds = uniqueEntityIds.filter((id) => !baselinePresentEntityIds.has(id)); + log.info( + `Entity ID baseline overlap in "${space}": existing=${baselinePresentEntityIds.size}, expected_new=${expectedNewEntityIds.length}`, + ); + + const userEvents = buildUserEvents(users, offsetHours); + const hostEvents = buildHostEvents(hosts, offsetHours); + const localUserEvents = buildLocalUserEvents(localUsers, offsetHours); + const serviceEvents = buildServiceEvents(services, offsetHours); + const sourceIngestAction = await ensureEventTarget(eventIndex); + log.info( + `Ingesting ${userEvents.length + hostEvents.length + localUserEvents.length + serviceEvents.length} source events into "${eventIndex}" (bulk action=${sourceIngestAction})...`, + ); + await bulkIngest({ + index: eventIndex, + documents: [...userEvents, ...hostEvents, ...localUserEvents, ...serviceEvents], + action: sourceIngestAction, + }); + log.info('Source event ingest complete.'); + + const fromDateISO = new Date(Date.now() - (offsetHours + 4) * 60 * 60 * 1000).toISOString(); + const toDateISO = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + const extractionTypes = new Set<'user' | 'host' | 'service'>(); + if (entityKinds.includes('idp_user') || entityKinds.includes('local_user')) + extractionTypes.add('user'); + if (entityKinds.includes('host')) extractionTypes.add('host'); + if (entityKinds.includes('service')) extractionTypes.add('service'); + log.info( + `Forcing log extraction for [${[...extractionTypes].join(', ')}] from ${fromDateISO} to ${toDateISO}...`, + ); + for (const extractionType of extractionTypes) { + log.info(`Requesting force log extraction for "${extractionType}"...`); + await forceLogExtraction(extractionType, { fromDateISO, toDateISO, space }); + } + await waitForExpectedEntityIds({ space, expectedEntityIds: expectedNewEntityIds }); + + if (options.watchlists !== false) { + log.info('Creating watchlists...'); + const watchlists = await createWatchlistsForRun(space); + log.info(`Created ${watchlists.length} watchlists.`); + await applyWatchlists( + allEntityIds, + watchlists.map((w) => w.id), + space, + ); + } + + if (options.criticality !== false) { + await applyCriticality(allEntityIds, space); + } + + if (options.alerts !== false) { + log.info('Generating and indexing alerts for seeded entities...'); + const ops = buildAlertOps({ + idpUsers: users, + localUsers, + hosts, + services, + alertsPerEntity, + space, + }); + const chunkSize = 5000 * 2; + const totalChunks = Math.ceil(ops.length / chunkSize); + log.info( + `Alert bulk indexing: total_operations=${ops.length}, chunk_size=${chunkSize}, chunks=${totalChunks}`, + ); + for (let i = 0; i < ops.length; i += chunkSize) { + await bulkUpsert({ documents: ops.slice(i, i + chunkSize) }); + log.info( + `Alert bulk indexing progress: chunk ${Math.floor(i / chunkSize) + 1}/${totalChunks}`, + ); + } + log.info('Alert indexing stage complete.'); + } + + await initEntityMaintainers(space); + await waitForMaintainerRun(space, 'risk-score'); + log.info( + 'Maintainer run requested once. Collecting risk summary directly (without strict risk-score count gating).', + ); + await reportRiskSummary({ + space, + baselineRiskScoreCount, + baselineEntityCount, + expectedRiskDelta: Math.max(1, expectedNewEntityIds.length), + entityIds: allEntityIds, + }); +}; diff --git a/src/commands/shared/elasticsearch.ts b/src/commands/shared/elasticsearch.ts index 4f091266..0b3e4223 100644 --- a/src/commands/shared/elasticsearch.ts +++ b/src/commands/shared/elasticsearch.ts @@ -17,7 +17,35 @@ export const logBulkErrors = (result: BulkResponse, context: string): void => { if (!result.errors) { return; } - const failedItems = result.items?.filter((item) => 'error' in item && item.error); + const failedItems = + result.items + ?.map((item) => { + const op = Object.keys(item)[0] as keyof typeof item; + const opResult = item[op] as + | { + _index?: string; + status?: number; + error?: { type?: string; reason?: string; caused_by?: { reason?: string } }; + } + | undefined; + if (!opResult?.error) { + return null; + } + return { + operation: op, + index: opResult._index, + status: opResult.status, + type: opResult.error.type, + reason: opResult.error.reason, + cause: opResult.error.caused_by?.reason, + }; + }) + .filter(Boolean) ?? []; + + if (failedItems.length === 0) { + log.warn(`${context} Bulk response had errors=true, but no item-level errors were parsed.`); + return; + } log.error(context, failedItems); }; diff --git a/src/commands/utils/cli_utils.ts b/src/commands/utils/cli_utils.ts index 359aac32..044171af 100644 --- a/src/commands/utils/cli_utils.ts +++ b/src/commands/utils/cli_utils.ts @@ -6,8 +6,66 @@ export const parseIntBase10 = (input: string) => parseInt(input, 10); export const parseOptionInt = (input: string | undefined, fallback: number): number => input ? parseIntBase10(input) : fallback; +const formatCauseChain = (error: unknown): string[] => { + const chain: string[] = []; + let cursor: unknown = error; + let guard = 0; + + while (cursor && guard < 6) { + guard += 1; + if (cursor instanceof Error) { + const record = cursor as Error & { + code?: string; + errno?: number | string; + address?: string; + port?: number; + cause?: unknown; + }; + const details = [ + `${record.name}: ${record.message}`, + record.code ? `code=${record.code}` : undefined, + record.errno !== undefined ? `errno=${String(record.errno)}` : undefined, + record.address ? `address=${record.address}` : undefined, + record.port !== undefined ? `port=${String(record.port)}` : undefined, + ] + .filter(Boolean) + .join(', '); + chain.push(details); + cursor = record.cause; + continue; + } + + chain.push(String(cursor)); + break; + } + + return chain; +}; + export function handleCommandError(error: unknown, message?: string): never { - log.error(message ?? 'Command failed:', error); + const prefix = message ?? 'Command failed'; + if (error instanceof Error) { + const e = error as Error & { + statusCode?: number; + responseData?: unknown; + }; + log.error(`${prefix}: ${e.name}: ${e.message}`); + + if (e.statusCode !== undefined) { + log.error(`HTTP status: ${e.statusCode}`); + } + if (e.responseData !== undefined) { + log.error('HTTP response body:', e.responseData); + } + + const causeChain = formatCauseChain(e.cause); + if (causeChain.length > 0) { + log.error('Cause chain:'); + causeChain.forEach((cause, idx) => log.error(` [${idx + 1}] ${cause}`)); + } + } else { + log.error(`${prefix}:`, error); + } process.exit(1); } diff --git a/src/constants.ts b/src/constants.ts index 9d32ecc6..c96f3433 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -108,6 +108,16 @@ export const KIBANA_SETTINGS_INTERNAL_URL = '/internal/kibana/settings'; // Entity Store V2 (ESQL) internal API export const ENTITY_STORE_V2_INSTALL_URL = '/internal/security/entity_store/install'; +export const ENTITY_STORE_V2_FORCE_LOG_EXTRACTION_URL = (entityType: 'user' | 'host' | 'service') => + `/internal/security/entity_store/${entityType}/force_log_extraction`; +export const ENTITY_STORE_V2_CRUD_URL = (entityType: 'user' | 'host') => + `/internal/security/entity_store/entities/${entityType}`; +export const ENTITY_MAINTAINERS_INIT_URL = + '/internal/security/entity_store/entity_maintainers/init'; +export const ENTITY_MAINTAINERS_URL = '/internal/security/entity_store/entity_maintainers'; +export const ENTITY_MAINTAINERS_RUN_URL = (id: string) => + `/internal/security/entity_store/entity_maintainers/run/${id}`; +export const WATCHLISTS_URL = '/api/entity_analytics/watchlists'; // ML module group used by Security export const ML_GROUP_ID = 'security'; diff --git a/src/generators/create_alerts.ts b/src/generators/create_alerts.ts index 6f11dc24..b463bbfd 100644 --- a/src/generators/create_alerts.ts +++ b/src/generators/create_alerts.ts @@ -3,17 +3,26 @@ import { faker } from '@faker-js/faker'; function baseCreateAlerts({ userName = 'user-1', hostName = 'host-1', + userId, + hostId, + eventModule, space = 'default', }: { userName?: string; hostName?: string; + userId?: string; + hostId?: string; + eventModule?: string; space?: string; } = {}) { const risk_score = faker.number.int({ min: 0, max: 100 }); const severity = ['low', 'medium', 'high', 'critical'][faker.number.int({ min: 0, max: 3 })]; return { 'host.name': hostName, + ...(hostId ? { 'host.id': hostId } : {}), 'user.name': userName, + ...(userId ? { 'user.id': userId } : {}), + ...(eventModule ? { 'event.module': eventModule } : {}), 'kibana.alert.start': '2023-04-11T20:18:15.816Z', 'kibana.alert.last_detected': '2023-04-11T20:18:15.816Z', 'kibana.version': '8.7.0', @@ -109,12 +118,21 @@ export default function createAlerts( { userName, hostName, + userId, + hostId, + eventModule, space, }: { userName?: string; hostName?: string; + userId?: string; + hostId?: string; + eventModule?: string; space?: string; } = {}, ): O & BaseCreateAlertsReturnType { - return { ...baseCreateAlerts({ userName, hostName, space }), ...override }; + return { + ...baseCreateAlerts({ userName, hostName, userId, hostId, eventModule, space }), + ...override, + }; } diff --git a/src/utils/kibana_api.ts b/src/utils/kibana_api.ts index 3404a222..7476739d 100644 --- a/src/utils/kibana_api.ts +++ b/src/utils/kibana_api.ts @@ -4,6 +4,7 @@ import { log } from './logger.ts'; import { faker } from '@faker-js/faker'; import fs from 'fs'; import FormData from 'form-data'; +import { Client } from '@elastic/elasticsearch'; import { RISK_SCORE_SCORES_URL, RISK_SCORE_ENGINE_INIT_URL, @@ -25,6 +26,12 @@ import { KIBANA_SETTINGS_INTERNAL_URL, ENTITY_STORE_ENTITIES_URL, ENTITY_STORE_V2_INSTALL_URL, + ENTITY_STORE_V2_FORCE_LOG_EXTRACTION_URL, + ENTITY_MAINTAINERS_INIT_URL, + ENTITY_MAINTAINERS_URL, + ENTITY_MAINTAINERS_RUN_URL, + WATCHLISTS_URL, + ENTITY_STORE_V2_CRUD_URL, ML_GROUP_ID, } from '../constants.ts'; @@ -33,6 +40,7 @@ const ENTITY_STORE_V2_POLL_TIMEOUT_MS = 60_000; const ENTITY_STORE_V2_POLL_INTERVAL_MS = 5_000; let insecureDispatcher: Agent | undefined; +let elasticClient: Client | undefined; const getDispatcher = () => { const config = getConfig(); @@ -45,6 +53,27 @@ const getDispatcher = () => { return undefined; }; +const getElasticClient = () => { + if (elasticClient) { + return elasticClient; + } + + const config = getConfig(); + elasticClient = new Client({ + node: config.elastic.node, + auth: + 'apiKey' in config.elastic + ? { apiKey: config.elastic.apiKey } + : { + username: config.elastic.username, + password: config.elastic.password, + }, + ...(config.allowSelfSignedCerts && { tls: { rejectUnauthorized: false } }), + }); + + return elasticClient; +}; + const joinUrl = (...parts: string[]) => parts.map((p, i) => (i === 0 ? p.replace(/\/+$/, '') : p.replace(/^\/+/, ''))).join('/'); @@ -56,6 +85,7 @@ export const buildKibanaUrl = (opts: { path: string; space?: string }) => { }; type ResponseError = Error & { statusCode: number; responseData: unknown }; +type ErrorWithCause = Error & { cause?: unknown }; const getAuthorizationHeader = () => { const config = getConfig(); @@ -75,6 +105,27 @@ const throwResponseError = (message: string, statusCode: number, response: unkno throw error; }; +const formatCauseDetails = (error: unknown): string => { + if (!(error instanceof Error)) { + return String(error); + } + + const details: string[] = [error.message]; + const causeRecord = error as ErrorWithCause & { + code?: string; + errno?: number | string; + address?: string; + port?: number; + }; + + if (causeRecord.code) details.push(`code=${causeRecord.code}`); + if (causeRecord.errno !== undefined) details.push(`errno=${String(causeRecord.errno)}`); + if (causeRecord.address) details.push(`address=${causeRecord.address}`); + if (causeRecord.port !== undefined) details.push(`port=${String(causeRecord.port)}`); + + return details.join(', '); +}; + export const kibanaFetch = async ( path: string, params: object, @@ -86,6 +137,7 @@ export const kibanaFetch = async ( ): Promise => { const { ignoreStatuses, apiVersion = '1', space } = opts; const url = buildKibanaUrl({ path, space }); + const method = ((params as { method?: string }).method ?? 'GET').toUpperCase(); const ignoreStatusesArray = Array.isArray(ignoreStatuses) ? ignoreStatuses : [ignoreStatuses]; const headers = new Headers(); headers.append('Content-Type', 'application/json'); @@ -94,11 +146,18 @@ export const kibanaFetch = async ( headers.set('x-elastic-internal-origin', 'kibana'); headers.set('elastic-api-version', apiVersion); - const result = await fetch(url, { - headers: headers, - ...params, - dispatcher: getDispatcher(), - } as RequestInit); + let result: Response; + try { + result = await fetch(url, { + headers: headers, + ...params, + dispatcher: getDispatcher(), + } as RequestInit); + } catch (error) { + const details = formatCauseDetails(error); + const message = `Network request failed for ${method} ${url}. Details: ${details}. Check Kibana URL, credentials, and whether Kibana is running.`; + throw new Error(message, { cause: error }); + } const rawResponse = await result.text(); // log response status let data: unknown; @@ -108,12 +167,14 @@ export const kibanaFetch = async ( data = { message: rawResponse }; } if (!data || typeof data !== 'object') { - throw new Error(); + throw new Error( + `Unexpected non-object response from ${method} ${url}. Raw response: ${rawResponse.slice(0, 500)}`, + ); } if (result.status >= 400 && !ignoreStatusesArray.includes(result.status)) { throwResponseError( - `Failed to fetch data from ${url}, status: ${result.status}`, + `Request failed for ${method} ${url}, status: ${result.status}`, result.status, data, ); @@ -676,6 +737,327 @@ export const installEntityStoreV2 = async (space: string = 'default'): Promise { + const spacePath = getEntityStoreV2SpacePath(space); + const path = `${spacePath}${ENTITY_STORE_V2_FORCE_LOG_EXTRACTION_URL(entityType)}`; + return kibanaFetch( + path, + { + method: 'POST', + body: JSON.stringify({ fromDateISO, toDateISO }), + }, + { apiVersion: '2' }, + ); +}; + +export const initEntityMaintainers = async (space: string = 'default') => { + const spacePath = getEntityStoreV2SpacePath(space); + const path = `${spacePath}${ENTITY_MAINTAINERS_INIT_URL}?apiVersion=2`; + return kibanaFetch( + path, + { + method: 'POST', + body: JSON.stringify({}), + }, + { apiVersion: '2' }, + ); +}; + +export interface EntityMaintainerStatus { + id: string; + runs: number; + taskStatus: string; +} + +export const getEntityMaintainers = async (space: string = 'default', ids?: string[]) => { + const spacePath = getEntityStoreV2SpacePath(space); + const query = new URLSearchParams(); + query.set('apiVersion', '2'); + if (ids && ids.length > 0) { + query.set('ids', ids.join(',')); + } + const path = `${spacePath}${ENTITY_MAINTAINERS_URL}?${query.toString()}`; + + return kibanaFetch<{ maintainers: EntityMaintainerStatus[] }>( + path, + { method: 'GET' }, + { apiVersion: '2' }, + ); +}; + +export const runEntityMaintainer = async (maintainerId: string, space: string = 'default') => { + const spacePath = getEntityStoreV2SpacePath(space); + const path = `${spacePath}${ENTITY_MAINTAINERS_RUN_URL(maintainerId)}?apiVersion=2`; + return kibanaFetch( + path, + { + method: 'POST', + body: JSON.stringify({}), + }, + { apiVersion: '2' }, + ); +}; + +export const waitForEntityStoreEntities = async ({ + expectedCount, + space = 'default', + timeoutMs = 120000, +}: { + expectedCount: number; + space?: string; + timeoutMs?: number; +}) => { + const index = `.entities.v2.latest.security_${space}`; + const deadline = Date.now() + timeoutMs; + const client = getElasticClient(); + let lastTotal = -1; + let lastHost = -1; + let lastIdentity = -1; + let lastIdentityIdp = -1; + let lastIdentityLocal = -1; + let lastService = -1; + + while (Date.now() < deadline) { + try { + const response = await client.search({ + index, + size: 0, + query: { match_all: {} }, + aggs: { + entity_types: { + terms: { + field: 'entity.type', + size: 10, + }, + }, + identity_split: { + filters: { + filters: { + idp: { + bool: { + filter: [{ term: { 'entity.type': 'Identity' } }], + must_not: [{ wildcard: { 'entity.id': '*@local' } }], + }, + }, + local: { + bool: { + filter: [ + { term: { 'entity.type': 'Identity' } }, + { wildcard: { 'entity.id': '*@local' } }, + ], + }, + }, + }, + }, + }, + }, + }); + + const total = + typeof response.hits.total === 'number' + ? response.hits.total + : (response.hits.total?.value ?? 0); + const buckets = + ( + response.aggregations as + | { entity_types?: { buckets?: Array<{ key: string; doc_count: number }> } } + | undefined + )?.entity_types?.buckets ?? []; + const hostCount = buckets.find((b) => b.key === 'Host')?.doc_count ?? 0; + const identityCount = buckets.find((b) => b.key === 'Identity')?.doc_count ?? 0; + const serviceCount = buckets.find((b) => b.key === 'Service')?.doc_count ?? 0; + const identityBuckets = ( + response.aggregations as + | { + identity_split?: { + buckets?: { + idp?: { doc_count?: number }; + local?: { doc_count?: number }; + }; + }; + } + | undefined + )?.identity_split?.buckets; + const identityIdpCount = identityBuckets?.idp?.doc_count ?? 0; + const identityLocalCount = identityBuckets?.local?.doc_count ?? 0; + + if ( + total !== lastTotal || + hostCount !== lastHost || + identityCount !== lastIdentity || + identityIdpCount !== lastIdentityIdp || + identityLocalCount !== lastIdentityLocal || + serviceCount !== lastService + ) { + log.info( + `Entity store progress (${index}): total=${total}, Host=${hostCount}, Identity=${identityCount} (idp=${identityIdpCount}, local=${identityLocalCount}), Service=${serviceCount}, expected>=${expectedCount}`, + ); + lastTotal = total; + lastHost = hostCount; + lastIdentity = identityCount; + lastIdentityIdp = identityIdpCount; + lastIdentityLocal = identityLocalCount; + lastService = serviceCount; + } + + if (total >= expectedCount) { + return total; + } + } catch (error) { + log.info( + `Entity store progress (${index}): waiting for index/aggregation to be available (${String(error)})`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + + let finalDetails = ''; + try { + const finalResponse = await client.search({ + index, + size: 0, + query: { match_all: {} }, + aggs: { + entity_types: { + terms: { + field: 'entity.type', + size: 10, + }, + }, + identity_split: { + filters: { + filters: { + idp: { + bool: { + filter: [{ term: { 'entity.type': 'Identity' } }], + must_not: [{ wildcard: { 'entity.id': '*@local' } }], + }, + }, + local: { + bool: { + filter: [ + { term: { 'entity.type': 'Identity' } }, + { wildcard: { 'entity.id': '*@local' } }, + ], + }, + }, + }, + }, + }, + }, + }); + const finalTotal = + typeof finalResponse.hits.total === 'number' + ? finalResponse.hits.total + : (finalResponse.hits.total?.value ?? 0); + const buckets = + ( + finalResponse.aggregations as + | { entity_types?: { buckets?: Array<{ key: string; doc_count: number }> } } + | undefined + )?.entity_types?.buckets ?? []; + const hostCount = buckets.find((b) => b.key === 'Host')?.doc_count ?? 0; + const identityCount = buckets.find((b) => b.key === 'Identity')?.doc_count ?? 0; + const serviceCount = buckets.find((b) => b.key === 'Service')?.doc_count ?? 0; + const identityBuckets = ( + finalResponse.aggregations as + | { + identity_split?: { + buckets?: { + idp?: { doc_count?: number }; + local?: { doc_count?: number }; + }; + }; + } + | undefined + )?.identity_split?.buckets; + const identityIdpCount = identityBuckets?.idp?.doc_count ?? 0; + const identityLocalCount = identityBuckets?.local?.doc_count ?? 0; + finalDetails = ` Final counts: total=${finalTotal}, Host=${hostCount}, Identity=${identityCount} (idp=${identityIdpCount}, local=${identityLocalCount}), Service=${serviceCount}.`; + } catch { + // no-op; keep base error + } + + throw new Error( + `Timed out waiting for entity store entities in ${index}. Expected >= ${expectedCount}.${finalDetails} Check that user/host source events are extractable and entity engines are healthy.`, + ); +}; + +export const waitForRiskScores = async ({ + expectedCount, + space = 'default', + timeoutMs = 120000, +}: { + expectedCount: number; + space?: string; + timeoutMs?: number; +}) => { + const index = `risk-score.risk-score-${space}`; + const deadline = Date.now() + timeoutMs; + const client = getElasticClient(); + + while (Date.now() < deadline) { + try { + const response = await client.count({ index }); + if (response.count >= expectedCount) { + return response.count; + } + } catch { + // keep polling while backing resources initialize + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + + throw new Error(`Timed out waiting for risk scores in ${index}`); +}; + +export const createWatchlist = async ({ + name, + riskModifier, + space = 'default', +}: { + name: string; + riskModifier: number; + space?: string; +}) => { + return kibanaFetch<{ id: string; name: string }>( + WATCHLISTS_URL, + { + method: 'POST', + body: JSON.stringify({ name, riskModifier }), + }, + { apiVersion: API_VERSIONS.public.v1, space }, + ); +}; + +export const forceUpdateEntityViaCrud = async ({ + entityType, + body, + space = 'default', +}: { + entityType: 'user' | 'host'; + body: Record; + space?: string; +}) => { + const spacePath = getEntityStoreV2SpacePath(space); + const path = `${spacePath}${ENTITY_STORE_V2_CRUD_URL(entityType)}?apiVersion=2&force=true`; + return kibanaFetch( + path, + { + method: 'PUT', + body: JSON.stringify(body), + }, + { apiVersion: '2' }, + ); +}; + /** * Enables the Asset Inventory feature in Kibana. * This is required for generic entity types to work. From 265656b8c2ed0edd934ae75c5a6b73a1309071a2 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 10:22:30 +0100 Subject: [PATCH 02/15] Address PR cleanup review feedback Reuse shared CLI/sleep utilities in the risk-score-v2 command and remove unused entity/risk polling helpers plus duplicated ES client setup from kibana_api. Made-with: Cursor --- src/commands/entity_store/risk_score_v2.ts | 19 +- src/utils/kibana_api.ts | 236 --------------------- 2 files changed, 8 insertions(+), 247 deletions(-) diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index 10d6df92..e8bf6306 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -15,11 +15,13 @@ import { runEntityMaintainer, } from '../../utils/kibana_api.ts'; import { log } from '../../utils/logger.ts'; +import { sleep } from '../../utils/sleep.ts'; import createAlerts from '../../generators/create_alerts.ts'; import { type MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; import { getConfig } from '../../get_config.ts'; import { generateOrgData } from '../org_data/org_data_generator.ts'; import type { OrganizationSize, ProductivitySuite } from '../org_data/types.ts'; +import { parseOptionInt } from '../utils/cli_utils.ts'; type RiskScoreV2Options = { users?: string; @@ -48,11 +50,6 @@ type SeededService = { serviceName: string }; type EntityKind = 'host' | 'idp_user' | 'local_user' | 'service'; type SeedSource = 'basic' | 'org'; -const sleep = async (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -const parseIntOption = (value: string | undefined, fallback: number): number => - value ? parseInt(value, 10) : fallback; - const SUPPORTED_ENTITY_KINDS: EntityKind[] = ['host', 'idp_user', 'local_user', 'service']; const SUPPORTED_SEED_SOURCES: SeedSource[] = ['basic', 'org']; const SUPPORTED_ORG_SIZES: OrganizationSize[] = ['john_doe', 'small', 'medium', 'enterprise']; @@ -922,25 +919,25 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { const usersCount = entityKinds.includes('idp_user') ? perf ? 1000 - : parseIntOption(options.users, 10) + : parseOptionInt(options.users, 10) : 0; const hostsCount = entityKinds.includes('host') ? perf ? 1000 - : parseIntOption(options.hosts, 10) + : parseOptionInt(options.hosts, 10) : 0; const localUsersCount = entityKinds.includes('local_user') ? perf ? 1000 - : parseIntOption(options.localUsers, 10) + : parseOptionInt(options.localUsers, 10) : 0; const servicesCount = entityKinds.includes('service') ? perf ? 1000 - : parseIntOption(options.services, 10) + : parseOptionInt(options.services, 10) : 0; - const alertsPerEntity = perf ? 50 : parseIntOption(options.alertsPerEntity, 5); - const offsetHours = parseIntOption(options.offsetHours, 1); + const alertsPerEntity = perf ? 50 : parseOptionInt(options.alertsPerEntity, 5); + const offsetHours = parseOptionInt(options.offsetHours, 1); const eventIndex = options.eventIndex || config.eventIndex || 'logs-testlogs-default'; log.info( diff --git a/src/utils/kibana_api.ts b/src/utils/kibana_api.ts index 7476739d..75cc0c42 100644 --- a/src/utils/kibana_api.ts +++ b/src/utils/kibana_api.ts @@ -4,7 +4,6 @@ import { log } from './logger.ts'; import { faker } from '@faker-js/faker'; import fs from 'fs'; import FormData from 'form-data'; -import { Client } from '@elastic/elasticsearch'; import { RISK_SCORE_SCORES_URL, RISK_SCORE_ENGINE_INIT_URL, @@ -40,7 +39,6 @@ const ENTITY_STORE_V2_POLL_TIMEOUT_MS = 60_000; const ENTITY_STORE_V2_POLL_INTERVAL_MS = 5_000; let insecureDispatcher: Agent | undefined; -let elasticClient: Client | undefined; const getDispatcher = () => { const config = getConfig(); @@ -53,27 +51,6 @@ const getDispatcher = () => { return undefined; }; -const getElasticClient = () => { - if (elasticClient) { - return elasticClient; - } - - const config = getConfig(); - elasticClient = new Client({ - node: config.elastic.node, - auth: - 'apiKey' in config.elastic - ? { apiKey: config.elastic.apiKey } - : { - username: config.elastic.username, - password: config.elastic.password, - }, - ...(config.allowSelfSignedCerts && { tls: { rejectUnauthorized: false } }), - }); - - return elasticClient; -}; - const joinUrl = (...parts: string[]) => parts.map((p, i) => (i === 0 ? p.replace(/\/+$/, '') : p.replace(/^\/+/, ''))).join('/'); @@ -805,219 +782,6 @@ export const runEntityMaintainer = async (maintainerId: string, space: string = ); }; -export const waitForEntityStoreEntities = async ({ - expectedCount, - space = 'default', - timeoutMs = 120000, -}: { - expectedCount: number; - space?: string; - timeoutMs?: number; -}) => { - const index = `.entities.v2.latest.security_${space}`; - const deadline = Date.now() + timeoutMs; - const client = getElasticClient(); - let lastTotal = -1; - let lastHost = -1; - let lastIdentity = -1; - let lastIdentityIdp = -1; - let lastIdentityLocal = -1; - let lastService = -1; - - while (Date.now() < deadline) { - try { - const response = await client.search({ - index, - size: 0, - query: { match_all: {} }, - aggs: { - entity_types: { - terms: { - field: 'entity.type', - size: 10, - }, - }, - identity_split: { - filters: { - filters: { - idp: { - bool: { - filter: [{ term: { 'entity.type': 'Identity' } }], - must_not: [{ wildcard: { 'entity.id': '*@local' } }], - }, - }, - local: { - bool: { - filter: [ - { term: { 'entity.type': 'Identity' } }, - { wildcard: { 'entity.id': '*@local' } }, - ], - }, - }, - }, - }, - }, - }, - }); - - const total = - typeof response.hits.total === 'number' - ? response.hits.total - : (response.hits.total?.value ?? 0); - const buckets = - ( - response.aggregations as - | { entity_types?: { buckets?: Array<{ key: string; doc_count: number }> } } - | undefined - )?.entity_types?.buckets ?? []; - const hostCount = buckets.find((b) => b.key === 'Host')?.doc_count ?? 0; - const identityCount = buckets.find((b) => b.key === 'Identity')?.doc_count ?? 0; - const serviceCount = buckets.find((b) => b.key === 'Service')?.doc_count ?? 0; - const identityBuckets = ( - response.aggregations as - | { - identity_split?: { - buckets?: { - idp?: { doc_count?: number }; - local?: { doc_count?: number }; - }; - }; - } - | undefined - )?.identity_split?.buckets; - const identityIdpCount = identityBuckets?.idp?.doc_count ?? 0; - const identityLocalCount = identityBuckets?.local?.doc_count ?? 0; - - if ( - total !== lastTotal || - hostCount !== lastHost || - identityCount !== lastIdentity || - identityIdpCount !== lastIdentityIdp || - identityLocalCount !== lastIdentityLocal || - serviceCount !== lastService - ) { - log.info( - `Entity store progress (${index}): total=${total}, Host=${hostCount}, Identity=${identityCount} (idp=${identityIdpCount}, local=${identityLocalCount}), Service=${serviceCount}, expected>=${expectedCount}`, - ); - lastTotal = total; - lastHost = hostCount; - lastIdentity = identityCount; - lastIdentityIdp = identityIdpCount; - lastIdentityLocal = identityLocalCount; - lastService = serviceCount; - } - - if (total >= expectedCount) { - return total; - } - } catch (error) { - log.info( - `Entity store progress (${index}): waiting for index/aggregation to be available (${String(error)})`, - ); - } - await new Promise((resolve) => setTimeout(resolve, 3000)); - } - - let finalDetails = ''; - try { - const finalResponse = await client.search({ - index, - size: 0, - query: { match_all: {} }, - aggs: { - entity_types: { - terms: { - field: 'entity.type', - size: 10, - }, - }, - identity_split: { - filters: { - filters: { - idp: { - bool: { - filter: [{ term: { 'entity.type': 'Identity' } }], - must_not: [{ wildcard: { 'entity.id': '*@local' } }], - }, - }, - local: { - bool: { - filter: [ - { term: { 'entity.type': 'Identity' } }, - { wildcard: { 'entity.id': '*@local' } }, - ], - }, - }, - }, - }, - }, - }, - }); - const finalTotal = - typeof finalResponse.hits.total === 'number' - ? finalResponse.hits.total - : (finalResponse.hits.total?.value ?? 0); - const buckets = - ( - finalResponse.aggregations as - | { entity_types?: { buckets?: Array<{ key: string; doc_count: number }> } } - | undefined - )?.entity_types?.buckets ?? []; - const hostCount = buckets.find((b) => b.key === 'Host')?.doc_count ?? 0; - const identityCount = buckets.find((b) => b.key === 'Identity')?.doc_count ?? 0; - const serviceCount = buckets.find((b) => b.key === 'Service')?.doc_count ?? 0; - const identityBuckets = ( - finalResponse.aggregations as - | { - identity_split?: { - buckets?: { - idp?: { doc_count?: number }; - local?: { doc_count?: number }; - }; - }; - } - | undefined - )?.identity_split?.buckets; - const identityIdpCount = identityBuckets?.idp?.doc_count ?? 0; - const identityLocalCount = identityBuckets?.local?.doc_count ?? 0; - finalDetails = ` Final counts: total=${finalTotal}, Host=${hostCount}, Identity=${identityCount} (idp=${identityIdpCount}, local=${identityLocalCount}), Service=${serviceCount}.`; - } catch { - // no-op; keep base error - } - - throw new Error( - `Timed out waiting for entity store entities in ${index}. Expected >= ${expectedCount}.${finalDetails} Check that user/host source events are extractable and entity engines are healthy.`, - ); -}; - -export const waitForRiskScores = async ({ - expectedCount, - space = 'default', - timeoutMs = 120000, -}: { - expectedCount: number; - space?: string; - timeoutMs?: number; -}) => { - const index = `risk-score.risk-score-${space}`; - const deadline = Date.now() + timeoutMs; - const client = getElasticClient(); - - while (Date.now() < deadline) { - try { - const response = await client.count({ index }); - if (response.count >= expectedCount) { - return response.count; - } - } catch { - // keep polling while backing resources initialize - } - await new Promise((resolve) => setTimeout(resolve, 3000)); - } - - throw new Error(`Timed out waiting for risk scores in ${index}`); -}; - export const createWatchlist = async ({ name, riskModifier, From a6586fd97a7c640489d0d2a37120e0b197ada172 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 10:29:32 +0100 Subject: [PATCH 03/15] Improve risk-score-v2 performance at larger entity counts Increase modifier update concurrency in perf mode, run force extraction requests in parallel, and avoid broad risk-index scans in summary by using targeted queries for seeded entities. Made-with: Cursor --- src/commands/entity_store/risk_score_v2.ts | 70 ++++++++++++---------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index e8bf6306..e1e60b70 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -501,9 +501,8 @@ const waitForMaintainerRun = async (space: string, maintainerId: string = 'risk- throw new Error(`Timed out waiting for maintainer "${maintainerId}" run`); }; -const applyCriticality = async (entityIds: string[], space: string) => { +const applyCriticality = async (entityIds: string[], space: string, concurrency: number) => { const levels = ['low_impact', 'medium_impact', 'high_impact', 'extreme_impact'] as const; - const concurrency = 5; log.info(`Applying criticality to ${entityIds.length} entities (concurrency=${concurrency})...`); let processed = 0; for (let i = 0; i < entityIds.length; i += concurrency) { @@ -539,10 +538,14 @@ const createWatchlistsForRun = async (space: string) => { ]); }; -const applyWatchlists = async (entityIds: string[], watchlistIds: string[], space: string) => { +const applyWatchlists = async ( + entityIds: string[], + watchlistIds: string[], + space: string, + concurrency: number, +) => { const targetCount = Math.max(1, Math.floor(entityIds.length * 0.4)); const selected = faker.helpers.arrayElements(entityIds, targetCount); - const concurrency = 5; log.info( `Applying watchlists to ${selected.length}/${entityIds.length} entities (watchlists=${watchlistIds.length}, concurrency=${concurrency})...`, ); @@ -611,26 +614,7 @@ const reportRiskSummary = async ({ }) => { const client = getEsClient(); const riskIndex = `risk-score.risk-score-${space}`; - const riskSearch = await client.search({ - index: riskIndex, - size: 1000, - query: { match_all: {} }, - sort: [{ '@timestamp': { order: 'desc' } }], - }); - - const docs = riskSearch.hits.hits.map((hit) => hit._source as Record); - const watchlistModifierDocs = docs.filter((doc) => { - const risk = ((doc.user as Record)?.risk ?? - (doc.host as Record)?.risk ?? - {}) as Record; - const modifiers = (risk.modifiers as Array> | undefined) ?? []; - return modifiers.some((m) => m.type === 'watchlist'); - }).length; - - const total = - typeof riskSearch.hits.total === 'number' - ? riskSearch.hits.total - : (riskSearch.hits.total?.value ?? docs.length); + const total = await getRiskScoreDocCount(space); const entityCount = await getEntityStoreDocCount(space); const riskDelta = Math.max(0, total - baselineRiskScoreCount); const entityDelta = Math.max(0, entityCount - baselineEntityCount); @@ -646,7 +630,6 @@ const reportRiskSummary = async ({ `Risk score delta lower than expected for this run (${riskDelta}/${expectedRiskDelta}). This can happen when existing score docs are updated in-place or scoring configuration limits entity types.`, ); } - log.info(`Docs with watchlist modifiers: ${watchlistModifierDocs}`); const uniqueEntityIds = [...new Set(entityIds)]; if (uniqueEntityIds.length === 0) { @@ -699,9 +682,28 @@ const reportRiskSummary = async ({ minimum_should_match: 1, }, }, - _source: ['host.name', 'host.risk', 'user.name', 'user.risk'], + _source: [ + 'host.name', + 'host.risk.calculated_score_norm', + 'host.risk.calculated_level', + 'host.risk.modifiers', + 'user.name', + 'user.risk.calculated_score_norm', + 'user.risk.calculated_level', + 'user.risk.modifiers', + ], }); + const riskDocs = riskResponse.hits.hits.map((hit) => hit._source as Record); + const watchlistModifierDocs = riskDocs.filter((doc) => { + const risk = ((doc.user as Record)?.risk ?? + (doc.host as Record)?.risk ?? + {}) as Record; + const modifiers = (risk.modifiers as Array> | undefined) ?? []; + return modifiers.some((m) => m.type === 'watchlist'); + }).length; + log.info(`Docs with watchlist modifiers: ${watchlistModifierDocs}`); + const riskById = new Map(); for (const hit of riskResponse.hits.hits) { const source = hit._source as @@ -756,7 +758,7 @@ const reportRiskSummary = async ({ ].join(' | '); const separator = `${'-'.repeat(idWidth)}-+-${'-'.repeat(scoreWidth)}-+-${'-'.repeat(levelWidth)}-+-${'-'.repeat(critWidth)}-+-${'-'.repeat(watchWidth)}`; - const maxRows = 200; + const maxRows = 100; const rowsToPrint = rows.slice(0, maxRows); log.info(`Risk docs matched for seeded IDs: ${riskById.size}/${rows.length}`); log.info( @@ -939,6 +941,7 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { const alertsPerEntity = perf ? 50 : parseOptionInt(options.alertsPerEntity, 5); const offsetHours = parseOptionInt(options.offsetHours, 1); const eventIndex = options.eventIndex || config.eventIndex || 'logs-testlogs-default'; + const modifierConcurrency = perf ? 20 : 5; log.info( `Starting risk-score-v2 in space "${space}" with seedSource=${seedSource}, kinds=${entityKinds.join(',')}, idp_users=${usersCount}, local_users=${localUsersCount}, hosts=${hostsCount}, services=${servicesCount}, alertsPerEntity=${alertsPerEntity}, eventIndex=${eventIndex}`, @@ -1016,10 +1019,12 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { log.info( `Forcing log extraction for [${[...extractionTypes].join(', ')}] from ${fromDateISO} to ${toDateISO}...`, ); - for (const extractionType of extractionTypes) { - log.info(`Requesting force log extraction for "${extractionType}"...`); - await forceLogExtraction(extractionType, { fromDateISO, toDateISO, space }); - } + await Promise.all( + [...extractionTypes].map(async (extractionType) => { + log.info(`Requesting force log extraction for "${extractionType}"...`); + await forceLogExtraction(extractionType, { fromDateISO, toDateISO, space }); + }), + ); await waitForExpectedEntityIds({ space, expectedEntityIds: expectedNewEntityIds }); if (options.watchlists !== false) { @@ -1030,11 +1035,12 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { allEntityIds, watchlists.map((w) => w.id), space, + modifierConcurrency, ); } if (options.criticality !== false) { - await applyCriticality(allEntityIds, space); + await applyCriticality(allEntityIds, space, modifierConcurrency); } if (options.alerts !== false) { From 85efe22e143072c4491f93118e4faca449f6e9c9 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 10:35:47 +0100 Subject: [PATCH 04/15] Combine modifier writes into single entity updates Build per-entity modifier assignments once and apply watchlists and criticality in a single CRUD update per entity to reduce request volume and improve consistency for large runs. Made-with: Cursor --- src/commands/entity_store/risk_score_v2.ts | 146 +++++++++++++-------- 1 file changed, 88 insertions(+), 58 deletions(-) diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index e1e60b70..ed745ffe 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -501,34 +501,6 @@ const waitForMaintainerRun = async (space: string, maintainerId: string = 'risk- throw new Error(`Timed out waiting for maintainer "${maintainerId}" run`); }; -const applyCriticality = async (entityIds: string[], space: string, concurrency: number) => { - const levels = ['low_impact', 'medium_impact', 'high_impact', 'extreme_impact'] as const; - log.info(`Applying criticality to ${entityIds.length} entities (concurrency=${concurrency})...`); - let processed = 0; - for (let i = 0; i < entityIds.length; i += concurrency) { - const batch = entityIds.slice(i, i + concurrency); - await Promise.all( - batch.map(async (entityId) => { - const entityType = entityId.startsWith('user:') ? 'user' : 'host'; - const criticality = faker.helpers.arrayElement(levels); - await forceUpdateEntityViaCrud({ - entityType, - space, - body: { - entity: { id: entityId }, - asset: { criticality }, - }, - }); - }), - ); - processed += batch.length; - if (processed % 10 === 0 || processed === entityIds.length) { - log.info(`Criticality progress: ${processed}/${entityIds.length}`); - } - } - log.info('Criticality assignment complete.'); -}; - const createWatchlistsForRun = async (space: string) => { const suffix = Date.now(); return Promise.all([ @@ -538,48 +510,100 @@ const createWatchlistsForRun = async (space: string) => { ]); }; -const applyWatchlists = async ( - entityIds: string[], - watchlistIds: string[], - space: string, - concurrency: number, -) => { - const targetCount = Math.max(1, Math.floor(entityIds.length * 0.4)); - const selected = faker.helpers.arrayElements(entityIds, targetCount); +const CRITICALITY_LEVELS = [ + 'low_impact', + 'medium_impact', + 'high_impact', + 'extreme_impact', +] as const; +type CriticalityLevel = (typeof CRITICALITY_LEVELS)[number]; +type EntityModifierAssignment = { criticality?: CriticalityLevel; watchlists?: string[] }; + +const buildEntityModifierAssignments = ({ + entityIds, + watchlistIds, + applyCriticality: applyCriticalityFlag, +}: { + entityIds: string[]; + watchlistIds: string[]; + applyCriticality: boolean; +}): Map => { + const assignments = new Map(); + if (applyCriticalityFlag) { + for (const entityId of entityIds) { + assignments.set(entityId, { + criticality: faker.helpers.arrayElement(CRITICALITY_LEVELS), + }); + } + } + + if (watchlistIds.length > 0) { + const targetCount = Math.max(1, Math.floor(entityIds.length * 0.4)); + const selected = faker.helpers.arrayElements(entityIds, targetCount); + for (const entityId of selected) { + const memberships = faker.helpers.arrayElements( + watchlistIds, + faker.number.int({ min: 1, max: 2 }), + ); + assignments.set(entityId, { + ...(assignments.get(entityId) ?? {}), + watchlists: memberships, + }); + } + } + + return assignments; +}; + +const applyEntityModifiers = async ({ + assignments, + totalEntities, + space, + concurrency, +}: { + assignments: Map; + totalEntities: number; + space: string; + concurrency: number; +}) => { + if (assignments.size === 0) { + return; + } + + const entries = [...assignments.entries()]; + const withCriticality = entries.filter(([, assignment]) => assignment.criticality).length; + const withWatchlists = entries.filter(([, assignment]) => assignment.watchlists?.length).length; log.info( - `Applying watchlists to ${selected.length}/${entityIds.length} entities (watchlists=${watchlistIds.length}, concurrency=${concurrency})...`, + `Applying entity modifiers to ${entries.length}/${totalEntities} entities (criticality=${withCriticality}, watchlists=${withWatchlists}, concurrency=${concurrency})...`, ); let processed = 0; - for (let i = 0; i < selected.length; i += concurrency) { - const batch = selected.slice(i, i + concurrency); + for (let i = 0; i < entries.length; i += concurrency) { + const batch = entries.slice(i, i + concurrency); await Promise.all( - batch.map(async (entityId) => { + batch.map(async ([entityId, assignment]) => { const entityType = entityId.startsWith('user:') ? 'user' : 'host'; - const memberships = faker.helpers.arrayElements( - watchlistIds, - faker.number.int({ min: 1, max: 2 }), - ); await forceUpdateEntityViaCrud({ entityType, space, body: { entity: { id: entityId, - attributes: { - watchlists: memberships, - }, + ...(assignment.watchlists + ? { attributes: { watchlists: assignment.watchlists } } + : {}), }, + ...(assignment.criticality ? { asset: { criticality: assignment.criticality } } : {}), }, }); }), ); processed += batch.length; - if (processed % 10 === 0 || processed === selected.length) { - log.info(`Watchlist progress: ${processed}/${selected.length}`); + if (processed % 10 === 0 || processed === entries.length) { + log.info(`Modifier update progress: ${processed}/${entries.length}`); } } - log.info('Watchlist assignment complete.'); + log.info('Entity modifier assignment complete.'); }; const formatCell = (value: string, width: number): string => { @@ -1027,20 +1051,26 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { ); await waitForExpectedEntityIds({ space, expectedEntityIds: expectedNewEntityIds }); + let watchlistIds: string[] = []; if (options.watchlists !== false) { log.info('Creating watchlists...'); const watchlists = await createWatchlistsForRun(space); log.info(`Created ${watchlists.length} watchlists.`); - await applyWatchlists( - allEntityIds, - watchlists.map((w) => w.id), - space, - modifierConcurrency, - ); + watchlistIds = watchlists.map((w) => w.id); } - if (options.criticality !== false) { - await applyCriticality(allEntityIds, space, modifierConcurrency); + if (options.watchlists !== false || options.criticality !== false) { + const assignments = buildEntityModifierAssignments({ + entityIds: allEntityIds, + watchlistIds, + applyCriticality: options.criticality !== false, + }); + await applyEntityModifiers({ + assignments, + totalEntities: allEntityIds.length, + space, + concurrency: modifierConcurrency, + }); } if (options.alerts !== false) { From 24c3502ec0ae76ecb50470055db2df4aaf876827 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 10:40:26 +0100 Subject: [PATCH 05/15] Stream alert bulk ops and broaden default entity coverage Generate alert bulk operations in chunks to reduce peak memory usage, include service entities in ID tracking/summary matching, and make risk-score-v2 default kinds include host, idp_user, local_user, and service. Made-with: Cursor --- src/commands/entity_store/index.ts | 2 +- src/commands/entity_store/risk_score_v2.ts | 114 +++++++++++++++------ 2 files changed, 81 insertions(+), 35 deletions(-) diff --git a/src/commands/entity_store/index.ts b/src/commands/entity_store/index.ts index 6652beaa..8b9292b9 100644 --- a/src/commands/entity_store/index.ts +++ b/src/commands/entity_store/index.ts @@ -213,7 +213,7 @@ export const entityStoreCommands: CommandModule = { .description('End-to-end Entity Store V2 risk score test command') .option( '--entity-kinds ', - 'comma-separated kinds: host,idp_user,local_user,service (default: host,idp_user)', + 'comma-separated kinds: host,idp_user,local_user,service (default: host,idp_user,local_user,service)', ) .option('--users ', 'number of user entities (default 10)') .option('--hosts ', 'number of host entities (default 10)') diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index ed745ffe..1ca3c999 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -56,7 +56,7 @@ const SUPPORTED_ORG_SIZES: OrganizationSize[] = ['john_doe', 'small', 'medium', const SUPPORTED_PRODUCTIVITY_SUITES: ProductivitySuite[] = ['microsoft', 'google']; const parseEntityKinds = (value?: string): EntityKind[] => { - const rawKinds = (value ?? 'host,idp_user') + const rawKinds = (value ?? 'host,idp_user,local_user,service') .split(',') .map((k) => k.trim()) .filter(Boolean); @@ -141,6 +141,7 @@ const ensureEventTarget = async (eventIndex: string): Promise<'index' | 'create' const toUserEuid = (user: SeededUser) => `user:${user.userEmail}@okta`; const toHostEuid = (host: SeededHost) => `host:${host.hostId}`; +const toServiceEuid = (service: SeededService) => `service:${service.serviceName}`; const compactSeedToken = (value: string, fallback: string): string => { const normalized = value.replace(/[^a-z0-9]/gi, '').toLowerCase(); @@ -324,13 +325,20 @@ const buildServiceEvents = (services: SeededService[], offsetHours: number) => { })); }; -const buildAlertOps = ({ +const appendAlertOp = (ops: unknown[], alertIndex: string, alert: unknown) => { + const id = (alert as Record)['kibana.alert.uuid'] as string; + ops.push({ create: { _index: alertIndex, _id: id } }); + ops.push(alert); +}; + +function* buildAlertOpChunks({ hosts, idpUsers, localUsers, services, alertsPerEntity, space, + maxOperationsPerChunk, }: { hosts: SeededHost[]; idpUsers: SeededUser[]; @@ -338,9 +346,10 @@ const buildAlertOps = ({ services: SeededService[]; alertsPerEntity: number; space: string; -}): unknown[] => { + maxOperationsPerChunk: number; +}): Generator { const alertIndex = getAlertIndex(space); - const docs: unknown[] = []; + let ops: unknown[] = []; for (const user of idpUsers) { for (let i = 0; i < alertsPerEntity; i++) { @@ -363,9 +372,11 @@ const buildAlertOps = ({ space, }, ); - const id = (alert as Record)['kibana.alert.uuid'] as string; - docs.push({ create: { _index: alertIndex, _id: id } }); - docs.push(alert); + appendAlertOp(ops, alertIndex, alert); + if (ops.length >= maxOperationsPerChunk) { + yield ops; + ops = []; + } } } @@ -390,9 +401,11 @@ const buildAlertOps = ({ space, }, ); - const id = (alert as Record)['kibana.alert.uuid'] as string; - docs.push({ create: { _index: alertIndex, _id: id } }); - docs.push(alert); + appendAlertOp(ops, alertIndex, alert); + if (ops.length >= maxOperationsPerChunk) { + yield ops; + ops = []; + } } } @@ -413,9 +426,11 @@ const buildAlertOps = ({ space, }, ); - const id = (alert as Record)['kibana.alert.uuid'] as string; - docs.push({ create: { _index: alertIndex, _id: id } }); - docs.push(alert); + appendAlertOp(ops, alertIndex, alert); + if (ops.length >= maxOperationsPerChunk) { + yield ops; + ops = []; + } } } @@ -437,14 +452,18 @@ const buildAlertOps = ({ space, }, ); - const id = (alert as Record)['kibana.alert.uuid'] as string; - docs.push({ create: { _index: alertIndex, _id: id } }); - docs.push(alert); + appendAlertOp(ops, alertIndex, alert); + if (ops.length >= maxOperationsPerChunk) { + yield ops; + ops = []; + } } } - return docs; -}; + if (ops.length > 0) { + yield ops; + } +} const waitForMaintainerRun = async (space: string, maintainerId: string = 'risk-score') => { let baselineRuns: number; @@ -518,6 +537,12 @@ const CRITICALITY_LEVELS = [ ] as const; type CriticalityLevel = (typeof CRITICALITY_LEVELS)[number]; type EntityModifierAssignment = { criticality?: CriticalityLevel; watchlists?: string[] }; +type ModifierEntityType = 'user' | 'host'; +const toModifierEntityType = (entityId: string): ModifierEntityType | null => { + if (entityId.startsWith('user:')) return 'user'; + if (entityId.startsWith('host:')) return 'host'; + return null; +}; const buildEntityModifierAssignments = ({ entityIds, @@ -582,7 +607,10 @@ const applyEntityModifiers = async ({ const batch = entries.slice(i, i + concurrency); await Promise.all( batch.map(async ([entityId, assignment]) => { - const entityType = entityId.startsWith('user:') ? 'user' : 'host'; + const entityType = toModifierEntityType(entityId); + if (!entityType) { + return; + } await forceUpdateEntityViaCrud({ entityType, space, @@ -702,6 +730,7 @@ const reportRiskSummary = async ({ should: [ { terms: { 'host.name': uniqueEntityIds } }, { terms: { 'user.name': uniqueEntityIds } }, + { terms: { 'service.name': uniqueEntityIds } }, ], minimum_should_match: 1, }, @@ -715,6 +744,10 @@ const reportRiskSummary = async ({ 'user.risk.calculated_score_norm', 'user.risk.calculated_level', 'user.risk.modifiers', + 'service.name', + 'service.risk.calculated_score_norm', + 'service.risk.calculated_level', + 'service.risk.modifiers', ], }); @@ -740,12 +773,17 @@ const reportRiskSummary = async ({ name?: string; risk?: { calculated_score_norm?: number; calculated_level?: string }; }; + service?: { + name?: string; + risk?: { calculated_score_norm?: number; calculated_level?: string }; + }; } | undefined; const hostId = source?.host?.name; const userId = source?.user?.name; - const id = hostId ?? userId; - const risk = source?.host?.risk ?? source?.user?.risk; + const serviceId = source?.service?.name; + const id = hostId ?? userId ?? serviceId; + const risk = source?.host?.risk ?? source?.user?.risk ?? source?.service?.risk; if (!id || !risk || riskById.has(id)) continue; riskById.set(id, { score: @@ -1010,6 +1048,7 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { ...users.map(toUserEuid), ...localUsers.map((user) => `user:${user.userName}@${user.hostId}@local`), ...hosts.map(toHostEuid), + ...services.map(toServiceEuid), ]; const uniqueEntityIds = [...new Set(allEntityIds)]; const baselinePresentEntityIds = await getPresentEntityIds(space, uniqueEntityIds); @@ -1060,8 +1099,11 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { } if (options.watchlists !== false || options.criticality !== false) { + const modifierEntityIds = allEntityIds.filter( + (entityId) => toModifierEntityType(entityId) !== null, + ); const assignments = buildEntityModifierAssignments({ - entityIds: allEntityIds, + entityIds: modifierEntityIds, watchlistIds, applyCriticality: options.criticality !== false, }); @@ -1075,24 +1117,28 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { if (options.alerts !== false) { log.info('Generating and indexing alerts for seeded entities...'); - const ops = buildAlertOps({ + const totalAlerts = + (users.length + localUsers.length + hosts.length + services.length) * alertsPerEntity; + const maxOperationsPerChunk = 5000 * 2; + const totalOperations = totalAlerts * 2; + const totalChunks = + totalOperations > 0 ? Math.ceil(totalOperations / maxOperationsPerChunk) : 0; + log.info( + `Alert bulk indexing: total_operations=${totalOperations}, chunk_size=${maxOperationsPerChunk}, chunks=${totalChunks}`, + ); + let chunkIndex = 0; + for (const chunkOps of buildAlertOpChunks({ idpUsers: users, localUsers, hosts, services, alertsPerEntity, space, - }); - const chunkSize = 5000 * 2; - const totalChunks = Math.ceil(ops.length / chunkSize); - log.info( - `Alert bulk indexing: total_operations=${ops.length}, chunk_size=${chunkSize}, chunks=${totalChunks}`, - ); - for (let i = 0; i < ops.length; i += chunkSize) { - await bulkUpsert({ documents: ops.slice(i, i + chunkSize) }); - log.info( - `Alert bulk indexing progress: chunk ${Math.floor(i / chunkSize) + 1}/${totalChunks}`, - ); + maxOperationsPerChunk, + })) { + chunkIndex += 1; + await bulkUpsert({ documents: chunkOps }); + log.info(`Alert bulk indexing progress: chunk ${chunkIndex}/${totalChunks}`); } log.info('Alert indexing stage complete.'); } From 01b98333bbcb27271abeea5514f41e99e814c532 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 10:47:11 +0100 Subject: [PATCH 06/15] Add service modifier parity and shorten local-user IDs Enable service entities for CRUD modifier updates so watchlists/criticality can be applied across all entity kinds, and shorten local-user generated identifiers to keep EUIDs compact in logs and summaries. Made-with: Cursor --- src/commands/entity_store/risk_score_v2.ts | 8 +++++--- src/constants.ts | 4 ++-- src/utils/kibana_api.ts | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index 1ca3c999..f4f0c020 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -169,7 +169,7 @@ const seedLocalUsers = (count: number, hosts: SeededHost[]): SeededLocalUser[] = Array.from({ length: count }, (_, i) => { const host = hosts[i % hosts.length] ?? seedHosts(1)[0]; return { - userName: `risk-v2-local-user-${i}-${faker.internet.username()}`, + userName: `rv2lu-${i}-${faker.string.alphanumeric(4).toLowerCase()}`, hostId: host.hostId, hostName: host.hostName, }; @@ -234,8 +234,9 @@ const seedFromOrgData = ({ const fallbackHosts = hosts.length > 0 ? hosts : seedHosts(Math.max(1, localUsersCount)); const orgLocalUsers: SeededLocalUser[] = org.employees.map((employee, i) => { const host = fallbackHosts[i % fallbackHosts.length]; + const token = compactSeedToken(employee.userName || employee.email, 'user'); return { - userName: employee.userName, + userName: `rv2lu-${i}-${token}`, hostName: host.hostName, hostId: host.hostId, }; @@ -537,10 +538,11 @@ const CRITICALITY_LEVELS = [ ] as const; type CriticalityLevel = (typeof CRITICALITY_LEVELS)[number]; type EntityModifierAssignment = { criticality?: CriticalityLevel; watchlists?: string[] }; -type ModifierEntityType = 'user' | 'host'; +type ModifierEntityType = 'user' | 'host' | 'service'; const toModifierEntityType = (entityId: string): ModifierEntityType | null => { if (entityId.startsWith('user:')) return 'user'; if (entityId.startsWith('host:')) return 'host'; + if (entityId.startsWith('service:')) return 'service'; return null; }; diff --git a/src/constants.ts b/src/constants.ts index c96f3433..b40af68a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -99,7 +99,7 @@ export const ENTITY_ENGINES_URL = '/api/entity_store/engines'; export const ENTITY_ENGINE_URL = (engineType: string) => `${ENTITY_ENGINES_URL}/${engineType}`; export const INIT_ENTITY_ENGINE_URL = (engineType: string) => `${ENTITY_ENGINE_URL(engineType)}/init`; -export const ENTITY_STORE_ENTITIES_URL = (entityType: 'user' | 'host') => +export const ENTITY_STORE_ENTITIES_URL = (entityType: 'user' | 'host' | 'service') => `/api/entity_store/entities/${entityType}`; // Kibana Settings API endpoints @@ -110,7 +110,7 @@ export const KIBANA_SETTINGS_INTERNAL_URL = '/internal/kibana/settings'; export const ENTITY_STORE_V2_INSTALL_URL = '/internal/security/entity_store/install'; export const ENTITY_STORE_V2_FORCE_LOG_EXTRACTION_URL = (entityType: 'user' | 'host' | 'service') => `/internal/security/entity_store/${entityType}/force_log_extraction`; -export const ENTITY_STORE_V2_CRUD_URL = (entityType: 'user' | 'host') => +export const ENTITY_STORE_V2_CRUD_URL = (entityType: 'user' | 'host' | 'service') => `/internal/security/entity_store/entities/${entityType}`; export const ENTITY_MAINTAINERS_INIT_URL = '/internal/security/entity_store/entity_maintainers/init'; diff --git a/src/utils/kibana_api.ts b/src/utils/kibana_api.ts index 75cc0c42..d535ac9a 100644 --- a/src/utils/kibana_api.ts +++ b/src/utils/kibana_api.ts @@ -806,7 +806,7 @@ export const forceUpdateEntityViaCrud = async ({ body, space = 'default', }: { - entityType: 'user' | 'host'; + entityType: 'user' | 'host' | 'service'; body: Record; space?: string; }) => { From 51a83bc06b2111576453a5cb95b0ad8d7252f35d Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 10:51:29 +0100 Subject: [PATCH 07/15] Use Entity Store bulk CRUD updates for modifiers Switch modifier application to batched CRUD bulk update requests for better scale performance and remove the now-unused single-entity CRUD update helper/constants. Made-with: Cursor --- src/commands/entity_store/risk_score_v2.ts | 63 ++++++++++++---------- src/constants.ts | 3 +- src/utils/kibana_api.ts | 19 +++---- 3 files changed, 46 insertions(+), 39 deletions(-) diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index f4f0c020..9fd56422 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -8,7 +8,7 @@ import { createWatchlist, enableEntityStoreV2, forceLogExtraction, - forceUpdateEntityViaCrud, + forceBulkUpdateEntitiesViaCrud, getEntityMaintainers, initEntityMaintainers, installEntityStoreV2, @@ -586,12 +586,12 @@ const applyEntityModifiers = async ({ assignments, totalEntities, space, - concurrency, + batchSize, }: { assignments: Map; totalEntities: number; space: string; - concurrency: number; + batchSize: number; }) => { if (assignments.size === 0) { return; @@ -601,33 +601,40 @@ const applyEntityModifiers = async ({ const withCriticality = entries.filter(([, assignment]) => assignment.criticality).length; const withWatchlists = entries.filter(([, assignment]) => assignment.watchlists?.length).length; log.info( - `Applying entity modifiers to ${entries.length}/${totalEntities} entities (criticality=${withCriticality}, watchlists=${withWatchlists}, concurrency=${concurrency})...`, + `Applying entity modifiers to ${entries.length}/${totalEntities} entities (criticality=${withCriticality}, watchlists=${withWatchlists}, bulk_batch_size=${batchSize})...`, ); let processed = 0; - for (let i = 0; i < entries.length; i += concurrency) { - const batch = entries.slice(i, i + concurrency); - await Promise.all( - batch.map(async ([entityId, assignment]) => { - const entityType = toModifierEntityType(entityId); - if (!entityType) { - return; - } - await forceUpdateEntityViaCrud({ - entityType, - space, - body: { - entity: { - id: entityId, - ...(assignment.watchlists - ? { attributes: { watchlists: assignment.watchlists } } - : {}), - }, - ...(assignment.criticality ? { asset: { criticality: assignment.criticality } } : {}), + for (let i = 0; i < entries.length; i += batchSize) { + const batch = entries.slice(i, i + batchSize); + const entities: Array<{ type: ModifierEntityType; doc: Record }> = []; + for (const [entityId, assignment] of batch) { + const entityType = toModifierEntityType(entityId); + if (!entityType) { + continue; + } + entities.push({ + type: entityType, + doc: { + entity: { + id: entityId, + ...(assignment.watchlists ? { attributes: { watchlists: assignment.watchlists } } : {}), }, - }); - }), - ); + ...(assignment.criticality ? { asset: { criticality: assignment.criticality } } : {}), + }, + }); + } + + if (entities.length > 0) { + const response = await forceBulkUpdateEntitiesViaCrud({ entities, space }); + if (response.errors && response.errors.length > 0) { + log.warn( + `Bulk modifier update reported ${response.errors.length} error(s) for batch ${ + Math.floor(i / batchSize) + 1 + }: ${JSON.stringify(response.errors).slice(0, 1000)}`, + ); + } + } processed += batch.length; if (processed % 10 === 0 || processed === entries.length) { log.info(`Modifier update progress: ${processed}/${entries.length}`); @@ -1005,7 +1012,7 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { const alertsPerEntity = perf ? 50 : parseOptionInt(options.alertsPerEntity, 5); const offsetHours = parseOptionInt(options.offsetHours, 1); const eventIndex = options.eventIndex || config.eventIndex || 'logs-testlogs-default'; - const modifierConcurrency = perf ? 20 : 5; + const modifierBulkBatchSize = perf ? 500 : 200; log.info( `Starting risk-score-v2 in space "${space}" with seedSource=${seedSource}, kinds=${entityKinds.join(',')}, idp_users=${usersCount}, local_users=${localUsersCount}, hosts=${hostsCount}, services=${servicesCount}, alertsPerEntity=${alertsPerEntity}, eventIndex=${eventIndex}`, @@ -1113,7 +1120,7 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { assignments, totalEntities: allEntityIds.length, space, - concurrency: modifierConcurrency, + batchSize: modifierBulkBatchSize, }); } diff --git a/src/constants.ts b/src/constants.ts index b40af68a..c37ba40a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -110,8 +110,7 @@ export const KIBANA_SETTINGS_INTERNAL_URL = '/internal/kibana/settings'; export const ENTITY_STORE_V2_INSTALL_URL = '/internal/security/entity_store/install'; export const ENTITY_STORE_V2_FORCE_LOG_EXTRACTION_URL = (entityType: 'user' | 'host' | 'service') => `/internal/security/entity_store/${entityType}/force_log_extraction`; -export const ENTITY_STORE_V2_CRUD_URL = (entityType: 'user' | 'host' | 'service') => - `/internal/security/entity_store/entities/${entityType}`; +export const ENTITY_STORE_V2_CRUD_BULK_URL = '/internal/security/entity_store/entities/bulk'; export const ENTITY_MAINTAINERS_INIT_URL = '/internal/security/entity_store/entity_maintainers/init'; export const ENTITY_MAINTAINERS_URL = '/internal/security/entity_store/entity_maintainers'; diff --git a/src/utils/kibana_api.ts b/src/utils/kibana_api.ts index d535ac9a..a0966a0e 100644 --- a/src/utils/kibana_api.ts +++ b/src/utils/kibana_api.ts @@ -30,7 +30,7 @@ import { ENTITY_MAINTAINERS_URL, ENTITY_MAINTAINERS_RUN_URL, WATCHLISTS_URL, - ENTITY_STORE_V2_CRUD_URL, + ENTITY_STORE_V2_CRUD_BULK_URL, ML_GROUP_ID, } from '../constants.ts'; @@ -801,22 +801,23 @@ export const createWatchlist = async ({ ); }; -export const forceUpdateEntityViaCrud = async ({ - entityType, - body, +export const forceBulkUpdateEntitiesViaCrud = async ({ + entities, space = 'default', }: { - entityType: 'user' | 'host' | 'service'; - body: Record; + entities: Array<{ + type: 'user' | 'host' | 'service'; + doc: Record; + }>; space?: string; }) => { const spacePath = getEntityStoreV2SpacePath(space); - const path = `${spacePath}${ENTITY_STORE_V2_CRUD_URL(entityType)}?apiVersion=2&force=true`; - return kibanaFetch( + const path = `${spacePath}${ENTITY_STORE_V2_CRUD_BULK_URL}?apiVersion=2&force=true`; + return kibanaFetch<{ ok: boolean; errors?: unknown[] }>( path, { method: 'PUT', - body: JSON.stringify(body), + body: JSON.stringify({ entities }), }, { apiVersion: '2' }, ); From 55d7086e671da9bea3a8fc950f4442305a966c36 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 11:00:03 +0100 Subject: [PATCH 08/15] Polish run output with timing and colored status cues Add per-stage timing logs, maintainer outcome and PASS/WARN footer, plus subtle TTY-aware color/emoji formatting for summary lines and risk level cells in the scorecard. Made-with: Cursor --- src/commands/entity_store/risk_score_v2.ts | 256 +++++++++++++++------ 1 file changed, 180 insertions(+), 76 deletions(-) diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index 9fd56422..a0be3b4f 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -466,7 +466,10 @@ function* buildAlertOpChunks({ } } -const waitForMaintainerRun = async (space: string, maintainerId: string = 'risk-score') => { +const waitForMaintainerRun = async ( + space: string, + maintainerId: string = 'risk-score', +): Promise<{ runs: number; taskStatus: string; settled: boolean }> => { let baselineRuns: number; try { const baseline = await getEntityMaintainers(space, [maintainerId]); @@ -498,14 +501,18 @@ const waitForMaintainerRun = async (space: string, maintainerId: string = 'risk- log.info( `Maintainer "${maintainerId}" appears settled (taskStatus=${settleMaintainer?.taskStatus ?? 'unknown'}).`, ); - return maintainer.runs; + return { + runs: maintainer.runs, + taskStatus: settleMaintainer?.taskStatus ?? maintainer.taskStatus, + settled: true, + }; } await sleep(2000); } log.warn( `Maintainer "${maintainerId}" still reports taskStatus=started after short settle wait; continuing with summary.`, ); - return maintainer.runs; + return { runs: maintainer.runs, taskStatus: maintainer.taskStatus, settled: false }; } const now = Date.now(); if (now - lastHeartbeat >= 10_000) { @@ -650,6 +657,43 @@ const formatCell = (value: string, width: number): string => { return `${value.slice(0, width - 3)}...`; }; +const ANSI = { + reset: '\x1b[0m', + bold: '\x1b[1m', + cyan: '\x1b[36m', + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', +} as const; + +const colorize = (message: string, color: keyof typeof ANSI): string => { + if (!process.stdout.isTTY) { + return message; + } + return `${ANSI.bold}${ANSI[color]}${message}${ANSI.reset}`; +}; + +const colorizeRiskLevel = (paddedCell: string, level: string): string => { + const normalized = level.trim().toLowerCase(); + if (normalized === 'high' || normalized === 'critical') { + return colorize(paddedCell, 'red'); + } + if (normalized === 'moderate' || normalized === 'medium') { + return colorize(paddedCell, 'yellow'); + } + if (normalized === 'low') { + return colorize(paddedCell, 'green'); + } + return paddedCell; +}; + +const formatDurationMs = (ms: number): string => { + if (ms < 1000) { + return `${ms}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; +}; + const normalizeWatchlists = (value: unknown): string[] => { if (Array.isArray(value)) { return value.filter((item): item is string => typeof item === 'string'); @@ -672,7 +716,7 @@ const reportRiskSummary = async ({ baselineEntityCount: number; expectedRiskDelta: number; entityIds: string[]; -}) => { +}): Promise<{ missingRiskDocs: number; totalEntities: number }> => { const client = getEsClient(); const riskIndex = `risk-score.risk-score-${space}`; const total = await getRiskScoreDocCount(space); @@ -694,7 +738,7 @@ const reportRiskSummary = async ({ const uniqueEntityIds = [...new Set(entityIds)]; if (uniqueEntityIds.length === 0) { - return; + return { missingRiskDocs: 0, totalEntities: 0 }; } const entityIndex = `.entities.v2.latest.security_${space}`; @@ -831,7 +875,10 @@ const reportRiskSummary = async ({ const maxRows = 100; const rowsToPrint = rows.slice(0, maxRows); - log.info(`Risk docs matched for seeded IDs: ${riskById.size}/${rows.length}`); + // eslint-disable-next-line no-console + console.log( + colorize(`📊 Risk docs matched for seeded IDs: ${riskById.size}/${rows.length}`, 'cyan'), + ); log.info( `Entity scorecard (${rows.length} seeded entities${rows.length > maxRows ? `, showing first ${maxRows}` : ''}):`, ); @@ -842,11 +889,12 @@ const reportRiskSummary = async ({ printLine(header); printLine(separator); for (const row of rowsToPrint) { + const levelCell = formatCell(row.level, levelWidth); printLine( [ formatCell(row.id, idWidth), formatCell(row.score, scoreWidth), - formatCell(row.level, levelWidth), + colorizeRiskLevel(levelCell, row.level), formatCell(row.criticality, critWidth), formatCell(String(row.watchlistsCount), watchWidth), ].join(' | '), @@ -866,6 +914,10 @@ const reportRiskSummary = async ({ } else { log.info(`All ${rows.length}/${rows.length} seeded entities have risk score docs.`); } + return { + missingRiskDocs: missingRiskDocIds.length, + totalEntities: rows.length, + }; }; const getRiskScoreDocCount = async (space: string): Promise => { @@ -982,6 +1034,17 @@ const waitForExpectedEntityIds = async ({ }; export const riskScoreV2Command = async (options: RiskScoreV2Options) => { + const overallStartMs = Date.now(); + const stageTimings: Array<{ stage: string; ms: number }> = []; + const runTimedStage = async (stage: string, fn: () => Promise): Promise => { + const startMs = Date.now(); + const result = await fn(); + const elapsedMs = Date.now() - startMs; + stageTimings.push({ stage, ms: elapsedMs }); + log.info(`Stage complete: ${stage} (${formatDurationMs(elapsedMs)})`); + return result; + }; + const space = await ensureSpace(options.space ?? 'default'); const config = getConfig(); const perf = Boolean(options.perf); @@ -1019,9 +1082,11 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { ); if (options.setup !== false) { - await ensureSecurityDefaultDataView(space); - await enableEntityStoreV2(space); - await installEntityStoreV2(space); + await runTimedStage('setup', async () => { + await ensureSecurityDefaultDataView(space); + await enableEntityStoreV2(space); + await installEntityStoreV2(space); + }); } const baselineEntityCount = await getEntityStoreDocCount(space); @@ -1070,16 +1135,18 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { const hostEvents = buildHostEvents(hosts, offsetHours); const localUserEvents = buildLocalUserEvents(localUsers, offsetHours); const serviceEvents = buildServiceEvents(services, offsetHours); - const sourceIngestAction = await ensureEventTarget(eventIndex); - log.info( - `Ingesting ${userEvents.length + hostEvents.length + localUserEvents.length + serviceEvents.length} source events into "${eventIndex}" (bulk action=${sourceIngestAction})...`, - ); - await bulkIngest({ - index: eventIndex, - documents: [...userEvents, ...hostEvents, ...localUserEvents, ...serviceEvents], - action: sourceIngestAction, + await runTimedStage('source_ingest', async () => { + const sourceIngestAction = await ensureEventTarget(eventIndex); + log.info( + `Ingesting ${userEvents.length + hostEvents.length + localUserEvents.length + serviceEvents.length} source events into "${eventIndex}" (bulk action=${sourceIngestAction})...`, + ); + await bulkIngest({ + index: eventIndex, + documents: [...userEvents, ...hostEvents, ...localUserEvents, ...serviceEvents], + action: sourceIngestAction, + }); + log.info('Source event ingest complete.'); }); - log.info('Source event ingest complete.'); const fromDateISO = new Date(Date.now() - (offsetHours + 4) * 60 * 60 * 1000).toISOString(); const toDateISO = new Date(Date.now() + 5 * 60 * 1000).toISOString(); @@ -1088,16 +1155,18 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { extractionTypes.add('user'); if (entityKinds.includes('host')) extractionTypes.add('host'); if (entityKinds.includes('service')) extractionTypes.add('service'); - log.info( - `Forcing log extraction for [${[...extractionTypes].join(', ')}] from ${fromDateISO} to ${toDateISO}...`, - ); - await Promise.all( - [...extractionTypes].map(async (extractionType) => { - log.info(`Requesting force log extraction for "${extractionType}"...`); - await forceLogExtraction(extractionType, { fromDateISO, toDateISO, space }); - }), - ); - await waitForExpectedEntityIds({ space, expectedEntityIds: expectedNewEntityIds }); + await runTimedStage('extract_entities', async () => { + log.info( + `Forcing log extraction for [${[...extractionTypes].join(', ')}] from ${fromDateISO} to ${toDateISO}...`, + ); + await Promise.all( + [...extractionTypes].map(async (extractionType) => { + log.info(`Requesting force log extraction for "${extractionType}"...`); + await forceLogExtraction(extractionType, { fromDateISO, toDateISO, space }); + }), + ); + await waitForExpectedEntityIds({ space, expectedEntityIds: expectedNewEntityIds }); + }); let watchlistIds: string[] = []; if (options.watchlists !== false) { @@ -1108,60 +1177,95 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { } if (options.watchlists !== false || options.criticality !== false) { - const modifierEntityIds = allEntityIds.filter( - (entityId) => toModifierEntityType(entityId) !== null, - ); - const assignments = buildEntityModifierAssignments({ - entityIds: modifierEntityIds, - watchlistIds, - applyCriticality: options.criticality !== false, - }); - await applyEntityModifiers({ - assignments, - totalEntities: allEntityIds.length, - space, - batchSize: modifierBulkBatchSize, + await runTimedStage('apply_modifiers', async () => { + const modifierEntityIds = allEntityIds.filter( + (entityId) => toModifierEntityType(entityId) !== null, + ); + const assignments = buildEntityModifierAssignments({ + entityIds: modifierEntityIds, + watchlistIds, + applyCriticality: options.criticality !== false, + }); + await applyEntityModifiers({ + assignments, + totalEntities: allEntityIds.length, + space, + batchSize: modifierBulkBatchSize, + }); }); } if (options.alerts !== false) { - log.info('Generating and indexing alerts for seeded entities...'); - const totalAlerts = - (users.length + localUsers.length + hosts.length + services.length) * alertsPerEntity; - const maxOperationsPerChunk = 5000 * 2; - const totalOperations = totalAlerts * 2; - const totalChunks = - totalOperations > 0 ? Math.ceil(totalOperations / maxOperationsPerChunk) : 0; - log.info( - `Alert bulk indexing: total_operations=${totalOperations}, chunk_size=${maxOperationsPerChunk}, chunks=${totalChunks}`, - ); - let chunkIndex = 0; - for (const chunkOps of buildAlertOpChunks({ - idpUsers: users, - localUsers, - hosts, - services, - alertsPerEntity, - space, - maxOperationsPerChunk, - })) { - chunkIndex += 1; - await bulkUpsert({ documents: chunkOps }); - log.info(`Alert bulk indexing progress: chunk ${chunkIndex}/${totalChunks}`); - } - log.info('Alert indexing stage complete.'); + await runTimedStage('index_alerts', async () => { + log.info('Generating and indexing alerts for seeded entities...'); + const totalAlerts = + (users.length + localUsers.length + hosts.length + services.length) * alertsPerEntity; + const maxOperationsPerChunk = 5000 * 2; + const totalOperations = totalAlerts * 2; + const totalChunks = + totalOperations > 0 ? Math.ceil(totalOperations / maxOperationsPerChunk) : 0; + log.info( + `Alert bulk indexing: total_operations=${totalOperations}, chunk_size=${maxOperationsPerChunk}, chunks=${totalChunks}`, + ); + let chunkIndex = 0; + for (const chunkOps of buildAlertOpChunks({ + idpUsers: users, + localUsers, + hosts, + services, + alertsPerEntity, + space, + maxOperationsPerChunk, + })) { + chunkIndex += 1; + await bulkUpsert({ documents: chunkOps }); + log.info(`Alert bulk indexing progress: chunk ${chunkIndex}/${totalChunks}`); + } + log.info('Alert indexing stage complete.'); + }); } - await initEntityMaintainers(space); - await waitForMaintainerRun(space, 'risk-score'); + const maintainerOutcome = await runTimedStage('run_maintainer', async () => { + await initEntityMaintainers(space); + return waitForMaintainerRun(space, 'risk-score'); + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); log.info( 'Maintainer run requested once. Collecting risk summary directly (without strict risk-score count gating).', ); - await reportRiskSummary({ - space, - baselineRiskScoreCount, - baselineEntityCount, - expectedRiskDelta: Math.max(1, expectedNewEntityIds.length), - entityIds: allEntityIds, - }); + const summary = await runTimedStage('report_summary', async () => + reportRiskSummary({ + space, + baselineRiskScoreCount, + baselineEntityCount, + expectedRiskDelta: Math.max(1, expectedNewEntityIds.length), + entityIds: allEntityIds, + }), + ); + if (summary.totalEntities > 0 && summary.missingRiskDocs === 0) { + // eslint-disable-next-line no-console + console.log( + colorize( + `✅ PASS: Risk docs present for all ${summary.totalEntities} seeded entities.`, + 'green', + ), + ); + } else { + // eslint-disable-next-line no-console + console.log( + colorize( + `⚠️ WARN: Missing risk docs for ${summary.missingRiskDocs}/${summary.totalEntities} seeded entities.`, + 'yellow', + ), + ); + } + if (stageTimings.length > 0) { + log.info( + `Stage timings: ${stageTimings.map((timing) => `${timing.stage}=${formatDurationMs(timing.ms)}`).join(', ')}`, + ); + } + const totalRuntimeMs = Date.now() - overallStartMs; + log.info(`Total runtime: ${formatDurationMs(totalRuntimeMs)}.`); }; From e8b3a57fb2ea7b41fba3226767d19c051e6683c7 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 12:28:54 +0100 Subject: [PATCH 09/15] Expand risk-score-v2 follow-on actions and inspection workflow Add an interactive post-run control loop with dynamic entity expansion, single-entity tweaking, risk doc viewing/export, and paged comparison/summary output so users can iteratively validate scoring changes without rerunning the command from scratch. Made-with: Cursor --- .gitignore | 1 + src/commands/entity_store/README.md | 33 + src/commands/entity_store/index.ts | 2 + src/commands/entity_store/risk_score_v2.ts | 1835 +++++++++++++++++--- 4 files changed, 1654 insertions(+), 217 deletions(-) diff --git a/.gitignore b/.gitignore index 4b97a7af..5b9bf1cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Output and Data output/ reports/ +tmp/risk-score-v2/exports/ # Data directory exceptions data/* diff --git a/src/commands/entity_store/README.md b/src/commands/entity_store/README.md index d7b28d93..d4799ec0 100644 --- a/src/commands/entity_store/README.md +++ b/src/commands/entity_store/README.md @@ -58,3 +58,36 @@ Clean Entity Store data and related generated artifacts. ```bash yarn start clean-entity-store ``` + +## `risk-score-v2` + +End-to-end Entity Store V2 risk scoring test flow with optional interactive follow-on actions. + +### Usage + +```bash +yarn start risk-score-v2 [options] +``` + +### Common options + +- `--entity-kinds `: `host,idp_user,local_user,service` +- `--users `, `--hosts `, `--local-users `, `--services ` +- `--alerts-per-entity ` +- `--seed-source `: `basic|org` +- `--perf`: high-volume preset +- `--no-setup`, `--no-criticality`, `--no-watchlists`, `--no-alerts` +- `--follow-on` / `--no-follow-on`: enable or skip interactive post-run action menu + +### Follow-on actions + +After the initial summary (TTY mode), you can choose: + +- reset to zero (delete seeded alerts, rerun maintainer) +- post more alerts (same seeded entities, rerun maintainer) +- remove modifiers (clear watchlists and criticality, rerun maintainer) +- re-apply modifiers (new watchlists and criticality, rerun maintainer) +- refresh table (no data mutations; re-read latest risk/entity docs) +- run maintainer and refresh table (no data mutations beyond maintainer recalculation) + +Each action prints a compact before/after comparison table with score and modifier deltas. diff --git a/src/commands/entity_store/index.ts b/src/commands/entity_store/index.ts index 8b9292b9..e701f455 100644 --- a/src/commands/entity_store/index.ts +++ b/src/commands/entity_store/index.ts @@ -237,6 +237,8 @@ export const entityStoreCommands: CommandModule = { .option('--no-criticality', 'skip asset criticality assignment') .option('--no-watchlists', 'skip watchlist creation and assignment') .option('--no-alerts', 'skip alert generation') + .option('--follow-on', 'enable interactive follow-on actions after initial summary') + .option('--no-follow-on', 'disable interactive follow-on actions') .action( wrapAction(async (options) => { await riskScoreV2Command(options); diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index a0be3b4f..fee42b4f 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -3,6 +3,8 @@ import auditbeatMappings from '../../mappings/auditbeat.json' with { type: 'json import { bulkIngest, bulkUpsert } from '../shared/elasticsearch.ts'; import { getEsClient } from '../utils/indices.ts'; import { ensureSpace, getAlertIndex } from '../../utils/index.ts'; +import fs from 'fs/promises'; +import path from 'path'; import { ensureSecurityDefaultDataView } from '../../utils/security_default_data_view.ts'; import { createWatchlist, @@ -22,6 +24,7 @@ import { getConfig } from '../../get_config.ts'; import { generateOrgData } from '../org_data/org_data_generator.ts'; import type { OrganizationSize, ProductivitySuite } from '../org_data/types.ts'; import { parseOptionInt } from '../utils/cli_utils.ts'; +import { checkbox, input, select } from '@inquirer/prompts'; type RiskScoreV2Options = { users?: string; @@ -41,6 +44,7 @@ type RiskScoreV2Options = { seedSource?: string; orgSize?: string; orgProductivitySuite?: string; + followOn?: boolean; }; type SeededUser = { userName: string; userId: string; userEmail: string }; @@ -49,6 +53,29 @@ type SeededLocalUser = { userName: string; hostId: string; hostName: string }; type SeededService = { serviceName: string }; type EntityKind = 'host' | 'idp_user' | 'local_user' | 'service'; type SeedSource = 'basic' | 'org'; +type RiskSummaryRow = { + id: string; + score: string; + level: string; + criticality: string; + watchlistsCount: number; +}; +type RiskSnapshot = { + rows: RiskSummaryRow[]; + totalRiskDocs: number; + totalEntityDocs: number; + riskDocsMatched: number; + watchlistModifierDocs: number; +}; +type RiskDocSummary = { + entityId: string; + timestamp: string; + score: number | null; + level: string; + scoreType: string; + calculationRunId: string; + source: Record; +}; const SUPPORTED_ENTITY_KINDS: EntityKind[] = ['host', 'idp_user', 'local_user', 'service']; const SUPPORTED_SEED_SOURCES: SeedSource[] = ['basic', 'org']; @@ -148,36 +175,41 @@ const compactSeedToken = (value: string, fallback: string): string => { return (normalized || fallback).slice(0, 4); }; -const seedUsers = (count: number): SeededUser[] => +const seedUsers = (count: number, startAt: number = 0): SeededUser[] => Array.from({ length: count }, (_, i) => { + const index = startAt + i; const suffix = faker.string.alphanumeric(4).toLowerCase(); - const userName = `rv2u-${i}-${suffix}`; + const userName = `rv2u-${index}-${suffix}`; return { userName, - userId: `risk-v2-user-id-${i}-${faker.string.alphanumeric(6)}`, + userId: `risk-v2-user-id-${index}-${faker.string.alphanumeric(6)}`, userEmail: `${userName}@example.com`, }; }); -const seedHosts = (count: number): SeededHost[] => +const seedHosts = (count: number, startAt: number = 0): SeededHost[] => Array.from({ length: count }, (_, i) => ({ - hostName: `risk-v2-host-${i}-${faker.internet.domainWord()}`, - hostId: `risk-v2-host-id-${i}-${faker.string.alphanumeric(8).toLowerCase()}`, + hostName: `risk-v2-host-${startAt + i}-${faker.internet.domainWord()}`, + hostId: `risk-v2-host-id-${startAt + i}-${faker.string.alphanumeric(8).toLowerCase()}`, })); -const seedLocalUsers = (count: number, hosts: SeededHost[]): SeededLocalUser[] => +const seedLocalUsers = ( + count: number, + hosts: SeededHost[], + startAt: number = 0, +): SeededLocalUser[] => Array.from({ length: count }, (_, i) => { const host = hosts[i % hosts.length] ?? seedHosts(1)[0]; return { - userName: `rv2lu-${i}-${faker.string.alphanumeric(4).toLowerCase()}`, + userName: `rv2lu-${startAt + i}-${faker.string.alphanumeric(4).toLowerCase()}`, hostId: host.hostId, hostName: host.hostName, }; }); -const seedServices = (count: number): SeededService[] => +const seedServices = (count: number, startAt: number = 0): SeededService[] => Array.from({ length: count }, (_, i) => ({ - serviceName: `risk-v2-service-${i}-${faker.internet.domainWord()}`, + serviceName: `risk-v2-service-${startAt + i}-${faker.internet.domainWord()}`, })); const topUpToCount = (items: T[], count: number, factory: (remaining: number) => T[]): T[] => { @@ -326,12 +358,62 @@ const buildServiceEvents = (services: SeededService[], offsetHours: number) => { })); }; +const getAllEntityIds = ({ + users, + localUsers, + hosts, + services, +}: { + users: SeededUser[]; + localUsers: SeededLocalUser[]; + hosts: SeededHost[]; + services: SeededService[]; +}): string[] => [ + ...users.map(toUserEuid), + ...localUsers.map((user) => `user:${user.userName}@${user.hostId}@local`), + ...hosts.map(toHostEuid), + ...services.map(toServiceEuid), +]; + +const forceExtractExpectedEntities = async ({ + space, + entityKinds, + expectedEntityIds, + offsetHours, +}: { + space: string; + entityKinds: EntityKind[]; + expectedEntityIds: string[]; + offsetHours: number; +}) => { + const fromDateISO = new Date(Date.now() - (offsetHours + 4) * 60 * 60 * 1000).toISOString(); + const toDateISO = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + const extractionTypes = new Set<'user' | 'host' | 'service'>(); + if (entityKinds.includes('idp_user') || entityKinds.includes('local_user')) + extractionTypes.add('user'); + if (entityKinds.includes('host')) extractionTypes.add('host'); + if (entityKinds.includes('service')) extractionTypes.add('service'); + + log.info( + `Forcing log extraction for [${[...extractionTypes].join(', ')}] from ${fromDateISO} to ${toDateISO}...`, + ); + await Promise.all( + [...extractionTypes].map(async (extractionType) => { + log.info(`Requesting force log extraction for "${extractionType}"...`); + await forceLogExtraction(extractionType, { fromDateISO, toDateISO, space }); + }), + ); + await waitForExpectedEntityIds({ space, expectedEntityIds }); +}; + const appendAlertOp = (ops: unknown[], alertIndex: string, alert: unknown) => { const id = (alert as Record)['kibana.alert.uuid'] as string; ops.push({ create: { _index: alertIndex, _id: id } }); ops.push(alert); }; +const ALERT_TARGET_ENTITY_ID_FIELD = 'labels.risk_v2_target_entity_id'; + function* buildAlertOpChunks({ hosts, idpUsers, @@ -364,6 +446,7 @@ function* buildAlertOpChunks({ 'event.category': ['iam'], 'event.type': ['user'], 'user.email': user.userEmail, + [ALERT_TARGET_ENTITY_ID_FIELD]: toUserEuid(user), }, { userName: user.userName, @@ -393,6 +476,7 @@ function* buildAlertOpChunks({ 'user.name': user.userName, 'host.id': user.hostId, 'host.name': user.hostName, + [ALERT_TARGET_ENTITY_ID_FIELD]: `user:${user.userName}@${user.hostId}@local`, }, { userName: user.userName, @@ -418,6 +502,7 @@ function* buildAlertOpChunks({ 'kibana.alert.risk_score': riskScore, 'kibana.alert.rule.risk_score': riskScore, 'kibana.alert.rule.parameters': { description: 'risk v2 test', risk_score: riskScore }, + [ALERT_TARGET_ENTITY_ID_FIELD]: toHostEuid(host), }, { hostName: host.hostName, @@ -446,6 +531,7 @@ function* buildAlertOpChunks({ 'service.name': service.serviceName, 'event.kind': 'event', 'event.category': 'network', + [ALERT_TARGET_ENTITY_ID_FIELD]: toServiceEuid(service), }, { userName: `service-alert-user-${i}`, @@ -687,6 +773,20 @@ const colorizeRiskLevel = (paddedCell: string, level: string): string => { return paddedCell; }; +const colorizeDelta = (paddedCell: string, delta: string): string => { + const value = Number.parseFloat(delta); + if (!Number.isFinite(value)) { + return paddedCell; + } + if (value < 0) { + return colorize(paddedCell, 'red'); + } + if (value > 0) { + return colorize(paddedCell, 'green'); + } + return paddedCell; +}; + const formatDurationMs = (ms: number): string => { if (ms < 1000) { return `${ms}ms`; @@ -704,41 +804,32 @@ const normalizeWatchlists = (value: unknown): string[] => { return []; }; -const reportRiskSummary = async ({ +const toNumericScore = (value: string): number | null => { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const collectRiskSnapshot = async ({ space, - baselineRiskScoreCount, - baselineEntityCount, - expectedRiskDelta, entityIds, }: { space: string; - baselineRiskScoreCount: number; - baselineEntityCount: number; - expectedRiskDelta: number; entityIds: string[]; -}): Promise<{ missingRiskDocs: number; totalEntities: number }> => { +}): Promise => { const client = getEsClient(); const riskIndex = `risk-score.risk-score-${space}`; - const total = await getRiskScoreDocCount(space); - const entityCount = await getEntityStoreDocCount(space); - const riskDelta = Math.max(0, total - baselineRiskScoreCount); - const entityDelta = Math.max(0, entityCount - baselineEntityCount); - - log.info( - `Run summary (${space}): entities ${baselineEntityCount} -> ${entityCount} (delta +${entityDelta})`, - ); - log.info( - `Run summary (${space}): risk scores ${baselineRiskScoreCount} -> ${total} (delta +${riskDelta})`, - ); - if (riskDelta < expectedRiskDelta) { - log.warn( - `Risk score delta lower than expected for this run (${riskDelta}/${expectedRiskDelta}). This can happen when existing score docs are updated in-place or scoring configuration limits entity types.`, - ); - } - const uniqueEntityIds = [...new Set(entityIds)]; + const totalRiskDocs = await getRiskScoreDocCount(space); + const totalEntityDocs = await getEntityStoreDocCount(space); + if (uniqueEntityIds.length === 0) { - return { missingRiskDocs: 0, totalEntities: 0 }; + return { + rows: [], + totalRiskDocs, + totalEntityDocs, + riskDocsMatched: 0, + watchlistModifierDocs: 0, + }; } const entityIndex = `.entities.v2.latest.security_${space}`; @@ -766,11 +857,10 @@ const reportRiskSummary = async ({ | undefined; const id = source?.entity?.id; if (!id) continue; - const watchlists = normalizeWatchlists(source?.entity?.attributes?.watchlists); entityById.set(id, { entityType: source.entity?.type ?? 'unknown', criticality: source.asset?.criticality ?? '-', - watchlists, + watchlists: normalizeWatchlists(source?.entity?.attributes?.watchlists), }); } @@ -812,7 +902,6 @@ const reportRiskSummary = async ({ const modifiers = (risk.modifiers as Array> | undefined) ?? []; return modifiers.some((m) => m.type === 'watchlist'); }).length; - log.info(`Docs with watchlist modifiers: ${watchlistModifierDocs}`); const riskById = new Map(); for (const hit of riskResponse.hits.hits) { @@ -832,10 +921,7 @@ const reportRiskSummary = async ({ }; } | undefined; - const hostId = source?.host?.name; - const userId = source?.user?.name; - const serviceId = source?.service?.name; - const id = hostId ?? userId ?? serviceId; + const id = source?.host?.name ?? source?.user?.name ?? source?.service?.name; const risk = source?.host?.risk ?? source?.user?.risk ?? source?.service?.risk; if (!id || !risk || riskById.has(id)) continue; riskById.set(id, { @@ -847,23 +933,28 @@ const reportRiskSummary = async ({ }); } - const rows = uniqueEntityIds.map((id) => { - const entity = entityById.get(id); - const risk = riskById.get(id); - return { + return { + rows: uniqueEntityIds.map((id) => ({ id, - score: risk?.score ?? '-', - level: risk?.level ?? '-', - criticality: entity?.criticality ?? '-', - watchlistsCount: entity?.watchlists?.length ?? 0, - }; - }); + score: riskById.get(id)?.score ?? '-', + level: riskById.get(id)?.level ?? '-', + criticality: entityById.get(id)?.criticality ?? '-', + watchlistsCount: entityById.get(id)?.watchlists.length ?? 0, + })), + totalRiskDocs, + totalEntityDocs, + riskDocsMatched: riskById.size, + watchlistModifierDocs, + }; +}; +const printRiskRows = async (rows: RiskSummaryRow[], riskDocsMatched: number): Promise => { const idWidth = 66; const scoreWidth = 7; const levelWidth = 8; const critWidth = 14; const watchWidth = 5; + const pageSize = 20; const header = [ formatCell('Entity ID', idWidth), formatCell('Score', scoreWidth), @@ -873,51 +964,285 @@ const reportRiskSummary = async ({ ].join(' | '); const separator = `${'-'.repeat(idWidth)}-+-${'-'.repeat(scoreWidth)}-+-${'-'.repeat(levelWidth)}-+-${'-'.repeat(critWidth)}-+-${'-'.repeat(watchWidth)}`; - const maxRows = 100; - const rowsToPrint = rows.slice(0, maxRows); // eslint-disable-next-line no-console console.log( - colorize(`📊 Risk docs matched for seeded IDs: ${riskById.size}/${rows.length}`, 'cyan'), - ); - log.info( - `Entity scorecard (${rows.length} seeded entities${rows.length > maxRows ? `, showing first ${maxRows}` : ''}):`, + colorize(`📊 Risk docs matched for seeded IDs: ${riskDocsMatched}/${rows.length}`, 'cyan'), ); + log.info(`Entity scorecard (${rows.length} seeded entities):`); const printLine = (line: string) => { // eslint-disable-next-line no-console console.log(line); }; - printLine(header); - printLine(separator); - for (const row of rowsToPrint) { - const levelCell = formatCell(row.level, levelWidth); - printLine( - [ - formatCell(row.id, idWidth), - formatCell(row.score, scoreWidth), - colorizeRiskLevel(levelCell, row.level), - formatCell(row.criticality, critWidth), - formatCell(String(row.watchlistsCount), watchWidth), - ].join(' | '), - ); + + if (!process.stdout.isTTY || rows.length <= pageSize) { + printLine(header); + printLine(separator); + for (const row of rows) { + const levelCell = formatCell(row.level, levelWidth); + printLine( + [ + formatCell(row.id, idWidth), + formatCell(row.score, scoreWidth), + colorizeRiskLevel(levelCell, row.level), + formatCell(row.criticality, critWidth), + formatCell(String(row.watchlistsCount), watchWidth), + ].join(' | '), + ); + } + return; } - if (rows.length > maxRows) { - log.info(`... truncated ${rows.length - maxRows} additional entities from scorecard output.`); + + let page = 0; + const totalPages = Math.ceil(rows.length / pageSize); + while (true) { + const start = page * pageSize; + const end = Math.min(start + pageSize, rows.length); + const pageRows = rows.slice(start, end); + + printLine(header); + printLine(separator); + for (const row of pageRows) { + const levelCell = formatCell(row.level, levelWidth); + printLine( + [ + formatCell(row.id, idWidth), + formatCell(row.score, scoreWidth), + colorizeRiskLevel(levelCell, row.level), + formatCell(row.criticality, critWidth), + formatCell(String(row.watchlistsCount), watchWidth), + ].join(' | '), + ); + } + + log.info( + `Scorecard page ${page + 1}/${totalPages} (rows ${start + 1}-${end} of ${rows.length}).`, + ); + const canNext = page < totalPages - 1; + const canPrev = page > 0; + const navHint = + canPrev && canNext + ? '[n] next, [p] previous, [q] continue' + : canNext + ? '[n] next, [q] continue' + : canPrev + ? '[p] previous, [q] continue' + : '[q] continue'; + const nav = ( + await input({ + message: `Table navigation: ${navHint}`, + default: 'q', + }) + ) + .trim() + .toLowerCase(); + + if (nav === 'n' && canNext) { + page += 1; + continue; + } + if (nav === 'p' && canPrev) { + page -= 1; + continue; + } + if (nav === 'q' || nav === '') { + break; + } + log.warn(`Invalid table navigation "${nav}" for this page.`); } - const missingRiskDocIds = rows +}; + +const printSnapshotResult = ( + snapshot: RiskSnapshot, +): { missingRiskDocs: number; totalEntities: number } => { + const missingRiskDocIds = snapshot.rows .filter((row) => row.score === '-' && row.level === '-') .map((row) => row.id); if (missingRiskDocIds.length > 0) { const preview = missingRiskDocIds.slice(0, 5).join(', '); log.warn( - `Missing risk score docs for ${missingRiskDocIds.length}/${rows.length} seeded entities. Missing examples: ${preview}${missingRiskDocIds.length > 5 ? ', ...' : ''}`, + `Missing risk score docs for ${missingRiskDocIds.length}/${snapshot.rows.length} seeded entities. Missing examples: ${preview}${missingRiskDocIds.length > 5 ? ', ...' : ''}`, ); } else { - log.info(`All ${rows.length}/${rows.length} seeded entities have risk score docs.`); + log.info( + `All ${snapshot.rows.length}/${snapshot.rows.length} seeded entities have risk score docs.`, + ); } - return { - missingRiskDocs: missingRiskDocIds.length, - totalEntities: rows.length, - }; + if (snapshot.rows.length > 0 && missingRiskDocIds.length === 0) { + // eslint-disable-next-line no-console + console.log( + colorize( + `✅ PASS: Risk docs present for all ${snapshot.rows.length} seeded entities.`, + 'green', + ), + ); + } else { + // eslint-disable-next-line no-console + console.log( + colorize( + `⚠️ WARN: Missing risk docs for ${missingRiskDocIds.length}/${snapshot.rows.length} seeded entities.`, + 'yellow', + ), + ); + } + return { missingRiskDocs: missingRiskDocIds.length, totalEntities: snapshot.rows.length }; +}; + +const reportRiskSummary = async ({ + space, + baselineRiskScoreCount, + baselineEntityCount, + expectedRiskDelta, + entityIds, +}: { + space: string; + baselineRiskScoreCount: number; + baselineEntityCount: number; + expectedRiskDelta: number; + entityIds: string[]; +}): Promise<{ missingRiskDocs: number; totalEntities: number; snapshot: RiskSnapshot }> => { + const snapshot = await collectRiskSnapshot({ space, entityIds }); + const riskDelta = Math.max(0, snapshot.totalRiskDocs - baselineRiskScoreCount); + const entityDelta = Math.max(0, snapshot.totalEntityDocs - baselineEntityCount); + + log.info( + `Run summary (${space}): entities ${baselineEntityCount} -> ${snapshot.totalEntityDocs} (delta +${entityDelta})`, + ); + log.info( + `Run summary (${space}): risk scores ${baselineRiskScoreCount} -> ${snapshot.totalRiskDocs} (delta +${riskDelta})`, + ); + if (riskDelta < expectedRiskDelta) { + log.warn( + `Risk score delta lower than expected for this run (${riskDelta}/${expectedRiskDelta}). This can happen when existing score docs are updated in-place or scoring configuration limits entity types.`, + ); + } + + log.info(`Docs with watchlist modifiers: ${snapshot.watchlistModifierDocs}`); + await printRiskRows(snapshot.rows, snapshot.riskDocsMatched); + const result = printSnapshotResult(snapshot); + return { ...result, snapshot }; +}; + +const printBeforeAfterComparison = ({ + actionTitle, + before, + after, +}: { + actionTitle: string; + before: RiskSnapshot; + after: RiskSnapshot; +}): string[] => { + const beforeById = new Map(before.rows.map((row) => [row.id, row])); + const afterById = new Map(after.rows.map((row) => [row.id, row])); + const allIds = [...new Set([...beforeById.keys(), ...afterById.keys()])]; + const deltaRows = allIds.map((id) => { + const beforeRow = beforeById.get(id) ?? { + id, + score: '-', + level: '-', + criticality: '-', + watchlistsCount: 0, + }; + const afterRow = afterById.get(id) ?? { + id, + score: '-', + level: '-', + criticality: '-', + watchlistsCount: 0, + }; + const beforeScore = toNumericScore(beforeRow.score); + const afterScore = toNumericScore(afterRow.score); + const delta = + beforeScore !== null && afterScore !== null ? (afterScore - beforeScore).toFixed(2) : '-'; + return { + id, + beforeScore: beforeRow.score, + afterScore: afterRow.score, + beforeLevel: beforeRow.level, + afterLevel: afterRow.level, + beforeCriticality: beforeRow.criticality, + afterCriticality: afterRow.criticality, + beforeWatchlistsCount: beforeRow.watchlistsCount, + afterWatchlistsCount: afterRow.watchlistsCount, + scoreTransition: `${beforeRow.score}->${afterRow.score}`, + delta, + levelTransition: `${beforeRow.level}->${afterRow.level}`, + criticalityTransition: `${beforeRow.criticality}->${afterRow.criticality}`, + watchlistTransition: `${beforeRow.watchlistsCount}->${afterRow.watchlistsCount}`, + }; + }); + const changedRows = deltaRows.filter((row) => { + return ( + row.beforeScore !== row.afterScore || + row.beforeLevel !== row.afterLevel || + row.beforeCriticality !== row.afterCriticality || + row.beforeWatchlistsCount !== row.afterWatchlistsCount + ); + }); + + const idWidth = 52; + const scoreWidth = 17; + const deltaWidth = 7; + const levelWidth = 17; + const critWidth = 27; + const wlWidth = 9; + const header = [ + formatCell('Entity ID', idWidth), + formatCell('Score b->a', scoreWidth), + formatCell('Delta', deltaWidth), + formatCell('Lvl b->a', levelWidth), + formatCell('Crit b->a', critWidth), + formatCell('WL b->a', wlWidth), + ].join(' | '); + const separator = `${'-'.repeat(idWidth)}-+-${'-'.repeat(scoreWidth)}-+-${'-'.repeat(deltaWidth)}-+-${'-'.repeat(levelWidth)}-+-${'-'.repeat(critWidth)}-+-${'-'.repeat(wlWidth)}`; + // eslint-disable-next-line no-console + console.log(colorize(`🔄 Before/After (${actionTitle})`, 'cyan')); + if (changedRows.length === 0) { + log.info('No entity changes detected between snapshots.'); + return []; + } + // eslint-disable-next-line no-console + console.log(header); + // eslint-disable-next-line no-console + console.log(separator); + const maxRows = 100; + for (const row of changedRows.slice(0, maxRows)) { + const afterLevel = row.afterLevel ?? '-'; + const beforeLevel = row.beforeLevel ?? '-'; + const beforeLevelWidth = Math.floor((levelWidth - 2) / 2); + const afterLevelWidth = levelWidth - 2 - beforeLevelWidth; + const beforeLevelCell = formatCell(beforeLevel, beforeLevelWidth); + const afterLevelCell = formatCell(afterLevel, afterLevelWidth); + const levelCell = `${colorizeRiskLevel(beforeLevelCell, beforeLevel)}->${colorizeRiskLevel(afterLevelCell, afterLevel)}`; + const deltaCell = formatCell(row.delta, deltaWidth); + // eslint-disable-next-line no-console + console.log( + [ + formatCell(row.id, idWidth), + formatCell(row.scoreTransition, scoreWidth), + colorizeDelta(deltaCell, row.delta), + levelCell, + formatCell(row.criticalityTransition, critWidth), + formatCell(row.watchlistTransition, wlWidth), + ].join(' | '), + ); + } + if (changedRows.length > maxRows) { + log.info( + `... truncated ${changedRows.length - maxRows} additional changed entities from comparison output.`, + ); + } + + const topMovers = changedRows + .map((row) => ({ ...row, absDelta: Math.abs(toNumericScore(row.delta) ?? 0) })) + .sort((a, b) => b.absDelta - a.absDelta) + .slice(0, 5) + .filter((row) => row.absDelta > 0); + if (topMovers.length > 0) { + log.info(`Top score changes: ${topMovers.map((row) => `${row.id}(${row.delta})`).join(', ')}`); + } else { + log.info('Top score changes: no score delta detected.'); + } + return changedRows.map((row) => row.id); }; const getRiskScoreDocCount = async (space: string): Promise => { @@ -1033,83 +1358,1183 @@ const waitForExpectedEntityIds = async ({ ); }; -export const riskScoreV2Command = async (options: RiskScoreV2Options) => { - const overallStartMs = Date.now(); - const stageTimings: Array<{ stage: string; ms: number }> = []; - const runTimedStage = async (stage: string, fn: () => Promise): Promise => { - const startMs = Date.now(); - const result = await fn(); - const elapsedMs = Date.now() - startMs; - stageTimings.push({ stage, ms: elapsedMs }); - log.info(`Stage complete: ${stage} (${formatDurationMs(elapsedMs)})`); - return result; - }; - - const space = await ensureSpace(options.space ?? 'default'); - const config = getConfig(); - const perf = Boolean(options.perf); - const seedSource = parseSeedSource(options.seedSource); - const orgSize = parseOrgSize(options.orgSize); - const productivitySuite = parseProductivitySuite(options.orgProductivitySuite); - const entityKinds = parseEntityKinds(options.entityKinds); - const usersCount = entityKinds.includes('idp_user') - ? perf - ? 1000 - : parseOptionInt(options.users, 10) - : 0; - const hostsCount = entityKinds.includes('host') - ? perf - ? 1000 - : parseOptionInt(options.hosts, 10) - : 0; - const localUsersCount = entityKinds.includes('local_user') - ? perf - ? 1000 - : parseOptionInt(options.localUsers, 10) - : 0; - const servicesCount = entityKinds.includes('service') - ? perf - ? 1000 - : parseOptionInt(options.services, 10) - : 0; - const alertsPerEntity = perf ? 50 : parseOptionInt(options.alertsPerEntity, 5); - const offsetHours = parseOptionInt(options.offsetHours, 1); - const eventIndex = options.eventIndex || config.eventIndex || 'logs-testlogs-default'; - const modifierBulkBatchSize = perf ? 500 : 200; - +const indexAlertsForSeededEntities = async ({ + users, + localUsers, + hosts, + services, + alertsPerEntity, + space, +}: { + users: SeededUser[]; + localUsers: SeededLocalUser[]; + hosts: SeededHost[]; + services: SeededService[]; + alertsPerEntity: number; + space: string; +}) => { + log.info('Generating and indexing alerts for seeded entities...'); + const totalAlerts = + (users.length + localUsers.length + hosts.length + services.length) * alertsPerEntity; + const maxOperationsPerChunk = 5000 * 2; + const totalOperations = totalAlerts * 2; + const totalChunks = totalOperations > 0 ? Math.ceil(totalOperations / maxOperationsPerChunk) : 0; log.info( - `Starting risk-score-v2 in space "${space}" with seedSource=${seedSource}, kinds=${entityKinds.join(',')}, idp_users=${usersCount}, local_users=${localUsersCount}, hosts=${hostsCount}, services=${servicesCount}, alertsPerEntity=${alertsPerEntity}, eventIndex=${eventIndex}`, + `Alert bulk indexing: total_operations=${totalOperations}, chunk_size=${maxOperationsPerChunk}, chunks=${totalChunks}`, ); - - if (options.setup !== false) { - await runTimedStage('setup', async () => { - await ensureSecurityDefaultDataView(space); - await enableEntityStoreV2(space); - await installEntityStoreV2(space); - }); + let chunkIndex = 0; + for (const chunkOps of buildAlertOpChunks({ + idpUsers: users, + localUsers, + hosts, + services, + alertsPerEntity, + space, + maxOperationsPerChunk, + })) { + chunkIndex += 1; + await bulkUpsert({ documents: chunkOps }); + log.info(`Alert bulk indexing progress: chunk ${chunkIndex}/${totalChunks}`); } + log.info('Alert indexing stage complete.'); +}; - const baselineEntityCount = await getEntityStoreDocCount(space); - const baselineRiskScoreCount = await getRiskScoreDocCount(space); - log.info( - `Baselines in space "${space}": entities=${baselineEntityCount}, risk_scores=${baselineRiskScoreCount}`, - ); - - const seeded = - seedSource === 'org' - ? seedFromOrgData({ - usersCount, - hostsCount, - localUsersCount, - servicesCount, - orgSize, - productivitySuite, - }) - : { - users: seedUsers(usersCount), - hosts: seedHosts(hostsCount), - localUsers: [] as SeededLocalUser[], - services: seedServices(servicesCount), +const deleteAlertsForSeededEntities = async ({ + space, + users, + localUsers, + hosts, + services, +}: { + space: string; + users: SeededUser[]; + localUsers: SeededLocalUser[]; + hosts: SeededHost[]; + services: SeededService[]; +}) => { + const client = getEsClient(); + const alertIndex = getAlertIndex(space); + const shouldQueries: Array> = []; + if (users.length > 0) { + shouldQueries.push({ terms: { 'user.email': users.map((u) => u.userEmail) } }); + } + if (localUsers.length > 0) { + shouldQueries.push({ terms: { 'user.name': localUsers.map((u) => u.userName) } }); + } + if (hosts.length > 0) { + shouldQueries.push({ terms: { 'host.id': hosts.map((h) => h.hostId) } }); + } + if (services.length > 0) { + shouldQueries.push({ terms: { 'service.name': services.map((s) => s.serviceName) } }); + } + if (shouldQueries.length === 0) { + log.info('No seeded entities available for alert cleanup.'); + return; + } + log.info(`Deleting alerts tied to seeded entities from "${alertIndex}"...`); + try { + const response = await client.deleteByQuery({ + index: alertIndex, + refresh: true, + ignore_unavailable: true, + conflicts: 'proceed', + query: { + bool: { + should: shouldQueries, + minimum_should_match: 1, + }, + }, + }); + log.info(`Deleted ${response.deleted ?? 0} alerts for seeded entities.`); + } catch (error) { + const statusCode = (error as { meta?: { statusCode?: number } }).meta?.statusCode; + if (statusCode === 404) { + log.info(`Alert index "${alertIndex}" not found; nothing to delete.`); + return; + } + throw error; + } +}; + +const clearEntityModifiers = async ({ + entityIds, + space, + batchSize, +}: { + entityIds: string[]; + space: string; + batchSize: number; +}) => { + const assignments = new Map(); + for (const entityId of entityIds) { + const entityType = toModifierEntityType(entityId); + if (!entityType) continue; + assignments.set(entityId, { watchlists: [] }); + } + + const entries = [...assignments.entries()]; + for (let i = 0; i < entries.length; i += batchSize) { + const batch = entries.slice(i, i + batchSize); + const entities: Array<{ type: ModifierEntityType; doc: Record }> = []; + for (const [entityId, assignment] of batch) { + const entityType = toModifierEntityType(entityId); + if (!entityType) continue; + entities.push({ + type: entityType, + doc: { + entity: { + id: entityId, + attributes: { watchlists: assignment.watchlists ?? [] }, + }, + asset: { criticality: null }, + }, + }); + } + if (entities.length > 0) { + await forceBulkUpdateEntitiesViaCrud({ entities, space }); + } + } + log.info('Requested clearing of entity modifiers (watchlists + criticality).'); +}; + +const runRiskMaintainerOnce = async ({ + space, + runTimedStage, + stage, +}: { + space: string; + runTimedStage: (stage: string, fn: () => Promise) => Promise; + stage: string; +}) => + runTimedStage(stage, async () => { + await initEntityMaintainers(space); + return waitForMaintainerRun(space, 'risk-score'); + }); + +const countSeededAlertsByEntityKind = async ({ + space, + users, + localUsers, + hosts, + services, +}: { + space: string; + users: SeededUser[]; + localUsers: SeededLocalUser[]; + hosts: SeededHost[]; + services: SeededService[]; +}): Promise<{ + idpUserAlerts: number; + localUserAlerts: number; + hostAlerts: number; + serviceAlerts: number; +}> => { + const client = getEsClient(); + const alertIndex = getAlertIndex(space); + const safeCount = async (query: Record): Promise => { + try { + const response = await client.count({ + index: alertIndex, + ignore_unavailable: true, + query, + }); + return response.count; + } catch { + return 0; + } + }; + + const [idpUserAlerts, localUserAlerts, hostAlerts, serviceAlerts] = await Promise.all([ + users.length > 0 ? safeCount({ terms: { 'user.email': users.map((u) => u.userEmail) } }) : 0, + localUsers.length > 0 + ? safeCount({ terms: { 'user.name': localUsers.map((u) => u.userName) } }) + : 0, + hosts.length > 0 ? safeCount({ terms: { 'host.id': hosts.map((h) => h.hostId) } }) : 0, + services.length > 0 + ? safeCount({ terms: { 'service.name': services.map((s) => s.serviceName) } }) + : 0, + ]); + + return { idpUserAlerts, localUserAlerts, hostAlerts, serviceAlerts }; +}; + +type FollowOnAction = + | 'reset_to_zero' + | 'post_more_alerts' + | 'remove_modifiers' + | 'reapply_modifiers' + | 'add_more_entities' + | 'tweak_single_entity' + | 'view_single_risk_doc' + | 'export_risk_docs' + | 'refresh_table' + | 'run_maintainer_and_refresh' + | 'exit'; + +type TrackedEntitySelection = + | { kind: 'idp_user'; user: SeededUser; euid: string } + | { kind: 'local_user'; user: SeededLocalUser; euid: string } + | { kind: 'host'; host: SeededHost; euid: string } + | { kind: 'service'; service: SeededService; euid: string }; + +const formatMenuKey = (key: string): string => + process.stdout.isTTY ? `${ANSI.bold}${key}${ANSI.reset}` : key; + +const formatFollowOnOption = (key: string, description: string): string => + ` [${formatMenuKey(key)}] ${description}`; + +const resolveTrackedEntitySelection = ({ + entityId, + users, + localUsers, + hosts, + services, +}: { + entityId: string; + users: SeededUser[]; + localUsers: SeededLocalUser[]; + hosts: SeededHost[]; + services: SeededService[]; +}): TrackedEntitySelection | null => { + const idpUser = users.find((user) => toUserEuid(user) === entityId); + if (idpUser) return { kind: 'idp_user', user: idpUser, euid: entityId }; + + const localUser = localUsers.find( + (user) => `user:${user.userName}@${user.hostId}@local` === entityId, + ); + if (localUser) return { kind: 'local_user', user: localUser, euid: entityId }; + + const host = hosts.find((h) => toHostEuid(h) === entityId); + if (host) return { kind: 'host', host, euid: entityId }; + + const service = services.find((s) => toServiceEuid(s) === entityId); + if (service) return { kind: 'service', service, euid: entityId }; + + return null; +}; + +const getSelectionAlertQuery = (selection: TrackedEntitySelection): Record => { + const legacyQuery = (() => { + switch (selection.kind) { + case 'idp_user': + return { term: { 'user.email': selection.user.userEmail } }; + case 'local_user': + return { + bool: { + must: [ + { term: { 'user.name': selection.user.userName } }, + { term: { 'host.id': selection.user.hostId } }, + ], + }, + }; + case 'host': + return { term: { 'host.id': selection.host.hostId } }; + case 'service': + return { term: { 'service.name': selection.service.serviceName } }; + } + })(); + + // Prefer explicit per-alert target marker; keep legacy matcher for older generated alerts. + return { + bool: { + should: [{ term: { [ALERT_TARGET_ENTITY_ID_FIELD]: selection.euid } }, legacyQuery], + minimum_should_match: 1, + }, + }; +}; + +const countAlertsForSelection = async ({ + space, + selection, +}: { + space: string; + selection: TrackedEntitySelection; +}): Promise => { + const client = getEsClient(); + const alertIndex = getAlertIndex(space); + try { + const response = await client.count({ + index: alertIndex, + ignore_unavailable: true, + query: getSelectionAlertQuery(selection), + }); + return response.count; + } catch { + return 0; + } +}; + +const deleteAlertsForSelection = async ({ + space, + selection, +}: { + space: string; + selection: TrackedEntitySelection; +}) => { + const client = getEsClient(); + const alertIndex = getAlertIndex(space); + try { + const response = await client.deleteByQuery({ + index: alertIndex, + refresh: true, + ignore_unavailable: true, + conflicts: 'proceed', + query: getSelectionAlertQuery(selection), + }); + log.info(`Deleted ${response.deleted ?? 0} alerts for entity "${selection.euid}".`); + } catch (error) { + const statusCode = (error as { meta?: { statusCode?: number } }).meta?.statusCode; + if (statusCode === 404) { + log.info(`Alert index "${alertIndex}" not found; nothing to delete.`); + return; + } + throw error; + } +}; + +const printSingleEntityState = async ({ + space, + selection, +}: { + space: string; + selection: TrackedEntitySelection; +}) => { + const snapshot = await collectRiskSnapshot({ space, entityIds: [selection.euid] }); + const row = snapshot.rows[0]; + const alertCount = await countAlertsForSelection({ space, selection }); + if (!row) { + log.warn(`No current risk/entity state found for "${selection.euid}".`); + return; + } + // eslint-disable-next-line no-console + console.log(colorize(`🎯 Single entity state: ${selection.euid}`, 'cyan')); + // eslint-disable-next-line no-console + console.log( + ` score=${row.score}, level=${row.level}, criticality=${row.criticality}, watchlists=${row.watchlistsCount}, alerts=${alertCount}`, + ); +}; + +const fetchSingleEntityModifierState = async ({ + space, + entityId, +}: { + space: string; + entityId: string; +}): Promise<{ criticality: string; watchlists: string[] }> => { + const client = getEsClient(); + const entityIndex = `.entities.v2.latest.security_${space}`; + try { + const response = await client.search({ + index: entityIndex, + size: 1, + query: { + term: { + 'entity.id': entityId, + }, + }, + _source: ['asset.criticality', 'entity.attributes.watchlists'], + }); + const source = response.hits.hits[0]?._source as + | { asset?: { criticality?: string }; entity?: { attributes?: { watchlists?: unknown } } } + | undefined; + return { + criticality: source?.asset?.criticality ?? '-', + watchlists: normalizeWatchlists(source?.entity?.attributes?.watchlists), + }; + } catch { + return { criticality: '-', watchlists: [] }; + } +}; + +const fetchRiskDocsForEntityIds = async ({ + space, + entityIds, + maxDocsPerEntity, +}: { + space: string; + entityIds: string[]; + maxDocsPerEntity: number; +}): Promise> => { + const uniqueEntityIds = [...new Set(entityIds)]; + const grouped = new Map(); + if (uniqueEntityIds.length === 0) { + return grouped; + } + + const client = getEsClient(); + const riskIndex = `risk-score.risk-score-${space}`; + const response = await client.search({ + index: riskIndex, + size: Math.max(100, uniqueEntityIds.length * Math.max(1, maxDocsPerEntity) * 4), + sort: [{ '@timestamp': { order: 'desc' } }], + query: { + bool: { + should: [ + { terms: { 'host.name': uniqueEntityIds } }, + { terms: { 'user.name': uniqueEntityIds } }, + { terms: { 'service.name': uniqueEntityIds } }, + ], + minimum_should_match: 1, + }, + }, + }); + + for (const hit of response.hits.hits) { + const source = (hit._source ?? {}) as { + '@timestamp'?: string; + host?: { name?: string; risk?: Record }; + user?: { name?: string; risk?: Record }; + service?: { name?: string; risk?: Record }; + }; + const entityId = source.host?.name ?? source.user?.name ?? source.service?.name; + if (!entityId || !uniqueEntityIds.includes(entityId)) { + continue; + } + const risk = source.host?.risk ?? source.user?.risk ?? source.service?.risk ?? {}; + const entries = grouped.get(entityId) ?? []; + if (entries.length >= maxDocsPerEntity) { + continue; + } + entries.push({ + entityId, + timestamp: source['@timestamp'] ?? '-', + score: + typeof risk.calculated_score_norm === 'number' + ? (risk.calculated_score_norm as number) + : null, + level: typeof risk.calculated_level === 'string' ? (risk.calculated_level as string) : '-', + scoreType: typeof risk.score_type === 'string' ? (risk.score_type as string) : '-', + calculationRunId: + typeof risk.calculation_run_id === 'string' ? (risk.calculation_run_id as string) : '-', + source: source as unknown as Record, + }); + grouped.set(entityId, entries); + } + return grouped; +}; + +const promptFollowOnAction = async (): Promise => { + const optionsText = [ + `${ANSI.bold}Choose a follow-on action:${ANSI.reset}`, + formatFollowOnOption('r', 'reset to zero (wipe seeded alerts, re-run maintainer)'), + formatFollowOnOption('p', 'post more alerts (same seeded entities)'), + formatFollowOnOption('m', 'remove modifiers (clear watchlists + criticality)'), + formatFollowOnOption('a', 're-apply modifiers (new watchlists + criticality)'), + formatFollowOnOption('e', 'expand entities (add more users/hosts/local-users/services)'), + formatFollowOnOption('t', 'tweak single entity (criticality/watchlists/reset/add alerts)'), + formatFollowOnOption('v', 'view single risk-score doc(s)'), + formatFollowOnOption('x', 'export risk-score docs to file'), + formatFollowOnOption('f', 'refresh table (no data changes)'), + formatFollowOnOption('u', 'run maintainer and refresh table'), + formatFollowOnOption('q', 'exit'), + ].join('\n'); + + while (true) { + const answer = ( + await input({ + message: optionsText, + default: 'q', + }) + ) + .trim() + .toLowerCase(); + + if (answer === 'r') { + log.info('Selected [r] reset to zero.'); + return 'reset_to_zero'; + } + if (answer === 'p') { + log.info('Selected [p] post more alerts.'); + return 'post_more_alerts'; + } + if (answer === 'm') { + log.info('Selected [m] remove modifiers.'); + return 'remove_modifiers'; + } + if (answer === 'a') { + log.info('Selected [a] re-apply modifiers.'); + return 'reapply_modifiers'; + } + if (answer === 'e') { + log.info('Selected [e] expand entities.'); + return 'add_more_entities'; + } + if (answer === 't') { + log.info('Selected [t] tweak single entity.'); + return 'tweak_single_entity'; + } + if (answer === 'v') { + log.info('Selected [v] view single risk-score doc(s).'); + return 'view_single_risk_doc'; + } + if (answer === 'x') { + log.info('Selected [x] export risk-score docs to file.'); + return 'export_risk_docs'; + } + if (answer === 'f') { + log.info('Selected [f] refresh table (no data changes).'); + return 'refresh_table'; + } + if (answer === 'u') { + log.info('Selected [u] run maintainer and refresh table.'); + return 'run_maintainer_and_refresh'; + } + if (answer === 'q') { + log.info('Selected [q] exit.'); + return 'exit'; + } + + log.warn(`Invalid option "${answer}". Please enter one of: r, p, m, a, e, t, v, x, f, u, q.`); + } +}; + +const runFollowOnActionLoop = async ({ + space, + entityIds, + users, + localUsers, + hosts, + services, + watchlistIds, + entityKinds, + eventIndex, + offsetHours, + enableCriticality, + enableWatchlists, + alertsPerEntity, + modifierBulkBatchSize, + runTimedStage, +}: { + space: string; + entityIds: string[]; + users: SeededUser[]; + localUsers: SeededLocalUser[]; + hosts: SeededHost[]; + services: SeededService[]; + watchlistIds: string[]; + entityKinds: EntityKind[]; + eventIndex: string; + offsetHours: number; + enableCriticality: boolean; + enableWatchlists: boolean; + alertsPerEntity: number; + modifierBulkBatchSize: number; + runTimedStage: (stage: string, fn: () => Promise) => Promise; +}) => { + let trackedUsers = [...users]; + let trackedLocalUsers = [...localUsers]; + let trackedHosts = [...hosts]; + let trackedServices = [...services]; + let trackedWatchlistIds = [...watchlistIds]; + let trackedEntityIds = [...new Set(entityIds)]; + let lastChangedEntityIds: string[] = []; + + while (true) { + log.info( + `Current entity pool: idp_users=${trackedUsers.length}, local_users=${trackedLocalUsers.length}, hosts=${trackedHosts.length}, services=${trackedServices.length}, total=${trackedEntityIds.length}`, + ); + const action = await promptFollowOnAction(); + + if (action === 'exit') { + log.info('Exiting follow-on action loop.'); + return; + } + + const before = await collectRiskSnapshot({ space, entityIds: trackedEntityIds }); + log.info(`Captured baseline snapshot for action "${action}".`); + + if (action === 'reset_to_zero') { + await runTimedStage('follow_on_reset_delete_alerts', async () => + deleteAlertsForSeededEntities({ + space, + users: trackedUsers, + localUsers: trackedLocalUsers, + hosts: trackedHosts, + services: trackedServices, + }), + ); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_reset_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } else if (action === 'post_more_alerts') { + const extraAlertsRaw = await input({ + message: 'Additional alerts per entity', + default: String(alertsPerEntity), + }); + const extraAlerts = Math.max(1, parseOptionInt(extraAlertsRaw, alertsPerEntity)); + await runTimedStage('follow_on_post_alerts', async () => + indexAlertsForSeededEntities({ + users: trackedUsers, + localUsers: trackedLocalUsers, + hosts: trackedHosts, + services: trackedServices, + alertsPerEntity: extraAlerts, + space, + }), + ); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_post_alerts_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } else if (action === 'remove_modifiers') { + await runTimedStage('follow_on_remove_modifiers', async () => + clearEntityModifiers({ + entityIds: trackedEntityIds, + space, + batchSize: modifierBulkBatchSize, + }), + ); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_remove_modifiers_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } else if (action === 'reapply_modifiers') { + await runTimedStage('follow_on_reapply_modifiers', async () => { + const watchlists = await createWatchlistsForRun(space); + trackedWatchlistIds = watchlists.map((w) => w.id); + const reassignments = buildEntityModifierAssignments({ + entityIds: trackedEntityIds, + watchlistIds: trackedWatchlistIds, + applyCriticality: true, + }); + await applyEntityModifiers({ + assignments: reassignments, + totalEntities: trackedEntityIds.length, + space, + batchSize: modifierBulkBatchSize, + }); + }); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_reapply_modifiers_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } else if (action === 'add_more_entities') { + const [addUsersRaw, addLocalUsersRaw, addHostsRaw, addServicesRaw, addAlertsRaw] = + await Promise.all([ + input({ message: 'Add IdP users', default: '0' }), + input({ message: 'Add local users', default: '0' }), + input({ message: 'Add hosts', default: '0' }), + input({ message: 'Add services', default: '0' }), + input({ message: 'Alerts per NEW entity', default: String(alertsPerEntity) }), + ]); + + const addUsers = Math.max(0, parseOptionInt(addUsersRaw, 0)); + const addLocalUsers = Math.max(0, parseOptionInt(addLocalUsersRaw, 0)); + const addHosts = Math.max(0, parseOptionInt(addHostsRaw, 0)); + const addServices = Math.max(0, parseOptionInt(addServicesRaw, 0)); + const addAlertsPerEntity = Math.max(1, parseOptionInt(addAlertsRaw, alertsPerEntity)); + + if (addUsers + addLocalUsers + addHosts + addServices === 0) { + log.info('No additional entities requested. Skipping expansion action.'); + } else { + const newUsers = seedUsers(addUsers, trackedUsers.length); + const newHosts = seedHosts(addHosts, trackedHosts.length); + const hostPool = [...trackedHosts, ...newHosts]; + const newLocalUsers = seedLocalUsers( + addLocalUsers, + hostPool.length > 0 ? hostPool : seedHosts(1), + trackedLocalUsers.length, + ); + const newServices = seedServices(addServices, trackedServices.length); + + const newEntityIds = getAllEntityIds({ + users: newUsers, + localUsers: newLocalUsers, + hosts: newHosts, + services: newServices, + }); + const expectedNewEntityIds = newEntityIds.filter((id) => !trackedEntityIds.includes(id)); + + const addedKinds: EntityKind[] = []; + if (addUsers > 0) addedKinds.push('idp_user'); + if (addLocalUsers > 0) addedKinds.push('local_user'); + if (addHosts > 0) addedKinds.push('host'); + if (addServices > 0) addedKinds.push('service'); + const extractionKinds = addedKinds.length > 0 ? addedKinds : entityKinds; + + await runTimedStage('follow_on_expand_source_ingest', async () => { + const sourceIngestAction = await ensureEventTarget(eventIndex); + const userEvents = buildUserEvents(newUsers, offsetHours); + const hostEvents = buildHostEvents(newHosts, offsetHours); + const localUserEvents = buildLocalUserEvents(newLocalUsers, offsetHours); + const serviceEvents = buildServiceEvents(newServices, offsetHours); + const docs = [...userEvents, ...hostEvents, ...localUserEvents, ...serviceEvents]; + log.info( + `Ingesting ${docs.length} expansion source events into "${eventIndex}" (bulk action=${sourceIngestAction})...`, + ); + await bulkIngest({ + index: eventIndex, + documents: docs, + action: sourceIngestAction, + }); + }); + + await runTimedStage('follow_on_expand_extract_entities', async () => + forceExtractExpectedEntities({ + space, + entityKinds: extractionKinds, + expectedEntityIds: expectedNewEntityIds, + offsetHours, + }), + ); + + if ((enableCriticality || enableWatchlists) && expectedNewEntityIds.length > 0) { + await runTimedStage('follow_on_expand_apply_modifiers', async () => { + if (enableWatchlists && trackedWatchlistIds.length === 0) { + const created = await createWatchlistsForRun(space); + trackedWatchlistIds = created.map((w) => w.id); + } + const modifierEntityIds = expectedNewEntityIds.filter( + (entityId) => toModifierEntityType(entityId) !== null, + ); + const assignments = buildEntityModifierAssignments({ + entityIds: modifierEntityIds, + watchlistIds: enableWatchlists ? trackedWatchlistIds : [], + applyCriticality: enableCriticality, + }); + await applyEntityModifiers({ + assignments, + totalEntities: trackedEntityIds.length + expectedNewEntityIds.length, + space, + batchSize: modifierBulkBatchSize, + }); + }); + } + + await runTimedStage('follow_on_expand_alerts', async () => + indexAlertsForSeededEntities({ + users: newUsers, + localUsers: newLocalUsers, + hosts: newHosts, + services: newServices, + alertsPerEntity: addAlertsPerEntity, + space, + }), + ); + + trackedUsers = [...trackedUsers, ...newUsers]; + trackedHosts = [...trackedHosts, ...newHosts]; + trackedLocalUsers = [...trackedLocalUsers, ...newLocalUsers]; + trackedServices = [...trackedServices, ...newServices]; + trackedEntityIds = [...new Set([...trackedEntityIds, ...newEntityIds])]; + + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_expand_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } + } else if (action === 'tweak_single_entity') { + const entityIdInput = await input({ + message: 'Entity ID to tweak (exact match)', + default: trackedEntityIds[0] ?? '', + }); + const selectedEntityId = entityIdInput.trim(); + const selection = resolveTrackedEntitySelection({ + entityId: selectedEntityId, + users: trackedUsers, + localUsers: trackedLocalUsers, + hosts: trackedHosts, + services: trackedServices, + }); + if (!selection) { + log.warn(`Entity "${selectedEntityId}" is not in the tracked entity pool.`); + } else { + await printSingleEntityState({ space, selection }); + const modifierState = await fetchSingleEntityModifierState({ + space, + entityId: selection.euid, + }); + + const tweakActionRaw = await input({ + message: + 'Single-entity action: [c] criticality, [w] watchlists, [z] reset alerts->zero, [l] add alerts', + default: 'c', + }); + const tweakAction = tweakActionRaw.trim().toLowerCase(); + + if (tweakAction === 'c') { + const currentCriticality = modifierState.criticality; + const selectedCriticality = await select({ + message: `Select criticality (current: ${currentCriticality})`, + choices: [ + { name: `Keep current (${currentCriticality})`, value: '__keep__' }, + { name: 'None (clear criticality)', value: '__none__' }, + ...CRITICALITY_LEVELS.map((level) => ({ + name: `${level}${level === currentCriticality ? ' (current)' : ''}`, + value: level, + })), + ], + default: '__keep__', + }); + + if (selectedCriticality === '__keep__') { + log.info('Criticality unchanged.'); + } else { + const entityType = toModifierEntityType(selection.euid); + if (!entityType) { + log.warn(`Entity type for "${selection.euid}" does not support modifier updates.`); + } else { + const criticality: CriticalityLevel | null = + selectedCriticality === '__none__' + ? null + : (selectedCriticality as CriticalityLevel); + await runTimedStage('follow_on_tweak_criticality', async () => { + await forceBulkUpdateEntitiesViaCrud({ + entities: [ + { + type: entityType, + doc: { + entity: { id: selection.euid }, + asset: { criticality }, + }, + }, + ], + space, + }); + }); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_tweak_criticality_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } + } + } else if (tweakAction === 'w') { + if (trackedWatchlistIds.length === 0) { + const created = await createWatchlistsForRun(space); + trackedWatchlistIds = created.map((w) => w.id); + log.info(`No existing watchlists; created ${trackedWatchlistIds.length} for tweaking.`); + } + const currentWatchlists = modifierState.watchlists; + const availableWatchlistIds = [ + ...new Set([...trackedWatchlistIds, ...currentWatchlists]), + ]; + const selectedWatchlists = await checkbox({ + message: `Select watchlists (current: ${currentWatchlists.length ? currentWatchlists.join(', ') : 'none'})`, + choices: availableWatchlistIds.map((id) => ({ + name: `${id}${currentWatchlists.includes(id) ? ' (current)' : ''}`, + value: id, + checked: currentWatchlists.includes(id), + })), + }); + + const nextWatchlists = selectedWatchlists; + const entityType = toModifierEntityType(selection.euid); + if (!entityType) { + log.warn(`Entity type for "${selection.euid}" does not support modifier updates.`); + } else { + await runTimedStage('follow_on_tweak_watchlists', async () => { + await forceBulkUpdateEntitiesViaCrud({ + entities: [ + { + type: entityType, + doc: { + entity: { + id: selection.euid, + attributes: { watchlists: nextWatchlists }, + }, + }, + }, + ], + space, + }); + }); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_tweak_watchlists_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } + } else if (tweakAction === 'z') { + await runTimedStage('follow_on_tweak_single_reset_delete_alerts', async () => + deleteAlertsForSelection({ space, selection }), + ); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_tweak_single_reset_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } else if (tweakAction === 'l') { + const extraAlertsRaw = await input({ + message: 'Additional alerts for this entity', + default: '5', + }); + const extraAlerts = Math.max(1, parseOptionInt(extraAlertsRaw, 5)); + await runTimedStage('follow_on_tweak_single_add_alerts', async () => { + await indexAlertsForSeededEntities({ + users: selection.kind === 'idp_user' ? [selection.user] : [], + localUsers: selection.kind === 'local_user' ? [selection.user] : [], + hosts: selection.kind === 'host' ? [selection.host] : [], + services: selection.kind === 'service' ? [selection.service] : [], + alertsPerEntity: extraAlerts, + space, + }); + }); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_tweak_single_add_alerts_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } else { + log.warn(`Invalid single-entity action "${tweakAction}". No changes applied.`); + } + } + } else if (action === 'view_single_risk_doc') { + const entityIdInput = await input({ + message: 'Entity ID to inspect', + default: trackedEntityIds[0] ?? '', + }); + const selectedEntityId = entityIdInput.trim(); + if (!selectedEntityId) { + log.warn('No entity ID provided.'); + } else { + const docCountRaw = await input({ + message: 'Latest docs to show', + default: '1', + }); + const docCount = Math.max(1, parseOptionInt(docCountRaw, 1)); + const docsByEntity = await fetchRiskDocsForEntityIds({ + space, + entityIds: [selectedEntityId], + maxDocsPerEntity: docCount, + }); + const docs = docsByEntity.get(selectedEntityId) ?? []; + if (docs.length === 0) { + log.warn(`No risk score docs found for "${selectedEntityId}".`); + } else { + // eslint-disable-next-line no-console + console.log(colorize(`🔍 Risk docs for ${selectedEntityId}`, 'cyan')); + docs.forEach((doc, idx) => { + // eslint-disable-next-line no-console + console.log( + ` [${idx + 1}] ts=${doc.timestamp} score=${doc.score ?? '-'} level=${doc.level} score_type=${doc.scoreType} run_id=${doc.calculationRunId}`, + ); + }); + const showFullRaw = await input({ + message: 'Show full source JSON? [y/N]', + default: 'n', + }); + if (showFullRaw.trim().toLowerCase() === 'y') { + for (const doc of docs) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(doc.source, null, 2)); + } + } + } + } + } else if (action === 'export_risk_docs') { + const scopeRaw = await input({ + message: 'Export scope: [t] tracked entities, [c] changed entities', + default: 't', + }); + const scope = scopeRaw.trim().toLowerCase(); + const targetEntityIds = + scope === 'c' && lastChangedEntityIds.length > 0 ? lastChangedEntityIds : trackedEntityIds; + if (scope === 'c' && lastChangedEntityIds.length === 0) { + log.warn('No changed entities available yet; falling back to tracked entities.'); + } + const docsPerEntityRaw = await input({ + message: 'Max docs per entity', + default: '1', + }); + const docsPerEntity = Math.max(1, parseOptionInt(docsPerEntityRaw, 1)); + const formatRaw = await input({ + message: 'Format: [n] ndjson, [j] json', + default: 'n', + }); + const format = formatRaw.trim().toLowerCase() === 'j' ? 'json' : 'ndjson'; + const outDirRaw = await input({ + message: 'Output directory', + default: 'tmp/risk-score-v2/exports', + }); + const outDir = outDirRaw.trim() || 'tmp/risk-score-v2/exports'; + + const docsByEntity = await fetchRiskDocsForEntityIds({ + space, + entityIds: targetEntityIds, + maxDocsPerEntity: docsPerEntity, + }); + const records = [...docsByEntity.entries()].flatMap(([entityId, docs]) => + docs.map((doc, idx) => ({ + entity_id: entityId, + doc_index: idx + 1, + timestamp: doc.timestamp, + score: doc.score, + level: doc.level, + score_type: doc.scoreType, + calculation_run_id: doc.calculationRunId, + source: doc.source, + })), + ); + + await fs.mkdir(outDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filePath = path.resolve( + outDir, + `risk-docs-${space}-${timestamp}.${format === 'json' ? 'json' : 'ndjson'}`, + ); + const payload = + format === 'json' + ? `${JSON.stringify(records, null, 2)}\n` + : `${records.map((record) => JSON.stringify(record)).join('\n')}\n`; + await fs.writeFile(filePath, payload, 'utf8'); + log.info( + `Exported ${records.length} risk doc(s) for ${docsByEntity.size} entity(ies) to ${filePath}`, + ); + } else if (action === 'run_maintainer_and_refresh') { + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_run_maintainer_and_refresh', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } else if (action === 'refresh_table') { + log.info('Refreshing summary table without mutating alerts, modifiers, or entities.'); + } + + const after = await collectRiskSnapshot({ space, entityIds: trackedEntityIds }); + if (action === 'reset_to_zero') { + const alertCounts = await countSeededAlertsByEntityKind({ + space, + users: trackedUsers, + localUsers: trackedLocalUsers, + hosts: trackedHosts, + services: trackedServices, + }); + log.info( + `Post-reset seeded alert counts: idp_user=${alertCounts.idpUserAlerts}, local_user=${alertCounts.localUserAlerts}, host=${alertCounts.hostAlerts}, service=${alertCounts.serviceAlerts}`, + ); + + const lingeringServiceScores = after.rows.filter((row) => { + if (!row.id.startsWith('service:')) return false; + const score = toNumericScore(row.score); + return score !== null && score > 0; + }); + if (lingeringServiceScores.length > 0) { + log.warn( + `Reset diagnostic: ${lingeringServiceScores.length} service entities remain non-zero after alert cleanup + maintainer run. This suggests service score recalculation may not be zeroing stale docs in the current maintainer behavior.`, + ); + } + } + lastChangedEntityIds = printBeforeAfterComparison({ actionTitle: action, before, after }); + await printRiskRows(after.rows, after.riskDocsMatched); + printSnapshotResult(after); + } +}; + +export const riskScoreV2Command = async (options: RiskScoreV2Options) => { + const overallStartMs = Date.now(); + const stageTimings: Array<{ stage: string; ms: number }> = []; + const runTimedStage = async (stage: string, fn: () => Promise): Promise => { + const startMs = Date.now(); + const result = await fn(); + const elapsedMs = Date.now() - startMs; + stageTimings.push({ stage, ms: elapsedMs }); + log.info(`Stage complete: ${stage} (${formatDurationMs(elapsedMs)})`); + return result; + }; + + const space = await ensureSpace(options.space ?? 'default'); + const config = getConfig(); + const perf = Boolean(options.perf); + const seedSource = parseSeedSource(options.seedSource); + const orgSize = parseOrgSize(options.orgSize); + const productivitySuite = parseProductivitySuite(options.orgProductivitySuite); + const entityKinds = parseEntityKinds(options.entityKinds); + const usersCount = entityKinds.includes('idp_user') + ? perf + ? 1000 + : parseOptionInt(options.users, 10) + : 0; + const hostsCount = entityKinds.includes('host') + ? perf + ? 1000 + : parseOptionInt(options.hosts, 10) + : 0; + const localUsersCount = entityKinds.includes('local_user') + ? perf + ? 1000 + : parseOptionInt(options.localUsers, 10) + : 0; + const servicesCount = entityKinds.includes('service') + ? perf + ? 1000 + : parseOptionInt(options.services, 10) + : 0; + const alertsPerEntity = perf ? 50 : parseOptionInt(options.alertsPerEntity, 5); + const offsetHours = parseOptionInt(options.offsetHours, 1); + const eventIndex = options.eventIndex || config.eventIndex || 'logs-testlogs-default'; + const modifierBulkBatchSize = perf ? 500 : 200; + const followOnEnabled = options.followOn ?? process.stdout.isTTY; + + log.info( + `Starting risk-score-v2 in space "${space}" with seedSource=${seedSource}, kinds=${entityKinds.join(',')}, idp_users=${usersCount}, local_users=${localUsersCount}, hosts=${hostsCount}, services=${servicesCount}, alertsPerEntity=${alertsPerEntity}, eventIndex=${eventIndex}`, + ); + + if (options.setup !== false) { + await runTimedStage('setup', async () => { + await ensureSecurityDefaultDataView(space); + await enableEntityStoreV2(space); + await installEntityStoreV2(space); + }); + } + + const baselineEntityCount = await getEntityStoreDocCount(space); + const baselineRiskScoreCount = await getRiskScoreDocCount(space); + log.info( + `Baselines in space "${space}": entities=${baselineEntityCount}, risk_scores=${baselineRiskScoreCount}`, + ); + + const seeded = + seedSource === 'org' + ? seedFromOrgData({ + usersCount, + hostsCount, + localUsersCount, + servicesCount, + orgSize, + productivitySuite, + }) + : { + users: seedUsers(usersCount), + hosts: seedHosts(hostsCount), + localUsers: [] as SeededLocalUser[], + services: seedServices(servicesCount), }; const users = seeded.users; const hosts = seeded.hosts; @@ -1118,12 +2543,7 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { ? seeded.localUsers : seedLocalUsers(localUsersCount, hosts.length > 0 ? hosts : seedHosts(1)); const services = seeded.services; - const allEntityIds = [ - ...users.map(toUserEuid), - ...localUsers.map((user) => `user:${user.userName}@${user.hostId}@local`), - ...hosts.map(toHostEuid), - ...services.map(toServiceEuid), - ]; + const allEntityIds = getAllEntityIds({ users, localUsers, hosts, services }); const uniqueEntityIds = [...new Set(allEntityIds)]; const baselinePresentEntityIds = await getPresentEntityIds(space, uniqueEntityIds); const expectedNewEntityIds = uniqueEntityIds.filter((id) => !baselinePresentEntityIds.has(id)); @@ -1148,24 +2568,13 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { log.info('Source event ingest complete.'); }); - const fromDateISO = new Date(Date.now() - (offsetHours + 4) * 60 * 60 * 1000).toISOString(); - const toDateISO = new Date(Date.now() + 5 * 60 * 1000).toISOString(); - const extractionTypes = new Set<'user' | 'host' | 'service'>(); - if (entityKinds.includes('idp_user') || entityKinds.includes('local_user')) - extractionTypes.add('user'); - if (entityKinds.includes('host')) extractionTypes.add('host'); - if (entityKinds.includes('service')) extractionTypes.add('service'); await runTimedStage('extract_entities', async () => { - log.info( - `Forcing log extraction for [${[...extractionTypes].join(', ')}] from ${fromDateISO} to ${toDateISO}...`, - ); - await Promise.all( - [...extractionTypes].map(async (extractionType) => { - log.info(`Requesting force log extraction for "${extractionType}"...`); - await forceLogExtraction(extractionType, { fromDateISO, toDateISO, space }); - }), - ); - await waitForExpectedEntityIds({ space, expectedEntityIds: expectedNewEntityIds }); + await forceExtractExpectedEntities({ + space, + entityKinds, + expectedEntityIds: expectedNewEntityIds, + offsetHours, + }); }); let watchlistIds: string[] = []; @@ -1196,38 +2605,22 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { } if (options.alerts !== false) { - await runTimedStage('index_alerts', async () => { - log.info('Generating and indexing alerts for seeded entities...'); - const totalAlerts = - (users.length + localUsers.length + hosts.length + services.length) * alertsPerEntity; - const maxOperationsPerChunk = 5000 * 2; - const totalOperations = totalAlerts * 2; - const totalChunks = - totalOperations > 0 ? Math.ceil(totalOperations / maxOperationsPerChunk) : 0; - log.info( - `Alert bulk indexing: total_operations=${totalOperations}, chunk_size=${maxOperationsPerChunk}, chunks=${totalChunks}`, - ); - let chunkIndex = 0; - for (const chunkOps of buildAlertOpChunks({ - idpUsers: users, + await runTimedStage('index_alerts', async () => + indexAlertsForSeededEntities({ + users, localUsers, hosts, services, alertsPerEntity, space, - maxOperationsPerChunk, - })) { - chunkIndex += 1; - await bulkUpsert({ documents: chunkOps }); - log.info(`Alert bulk indexing progress: chunk ${chunkIndex}/${totalChunks}`); - } - log.info('Alert indexing stage complete.'); - }); + }), + ); } - const maintainerOutcome = await runTimedStage('run_maintainer', async () => { - await initEntityMaintainers(space); - return waitForMaintainerRun(space, 'risk-score'); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'run_maintainer', }); log.info( `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, @@ -1235,7 +2628,7 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { log.info( 'Maintainer run requested once. Collecting risk summary directly (without strict risk-score count gating).', ); - const summary = await runTimedStage('report_summary', async () => + await runTimedStage('report_summary', async () => reportRiskSummary({ space, baselineRiskScoreCount, @@ -1244,22 +2637,30 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { entityIds: allEntityIds, }), ); - if (summary.totalEntities > 0 && summary.missingRiskDocs === 0) { - // eslint-disable-next-line no-console - console.log( - colorize( - `✅ PASS: Risk docs present for all ${summary.totalEntities} seeded entities.`, - 'green', - ), + if (followOnEnabled && process.stdout.isTTY) { + await runFollowOnActionLoop({ + space, + entityIds: allEntityIds, + users, + localUsers, + hosts, + services, + watchlistIds, + entityKinds, + eventIndex, + offsetHours, + enableCriticality: options.criticality !== false, + enableWatchlists: options.watchlists !== false, + alertsPerEntity, + modifierBulkBatchSize, + runTimedStage, + }); + } else if (followOnEnabled && !process.stdout.isTTY) { + log.info( + 'Follow-on actions requested, but output is non-interactive (non-TTY). Skipping menu.', ); } else { - // eslint-disable-next-line no-console - console.log( - colorize( - `⚠️ WARN: Missing risk docs for ${summary.missingRiskDocs}/${summary.totalEntities} seeded entities.`, - 'yellow', - ), - ); + log.info('Follow-on menu disabled (--no-follow-on).'); } if (stageTimings.length > 0) { log.info( From e5c3cdc33da9b86154d99a303d753533e638e321 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 13:13:43 +0100 Subject: [PATCH 10/15] Expand risk-score-v2 for phase 2 relationships and resolution. Default-enable relationship graph behavior and add route-backed resolution and ownership actions, richer graph summaries, and expanded score tables so phase 2 scenarios are visible across initial runs and follow-on workflows. Made-with: Cursor --- src/commands/entity_store/README.md | 23 +- src/commands/entity_store/index.ts | 25 + src/commands/entity_store/risk_score_v2.ts | 899 ++++++++++++++++++++- src/constants.ts | 6 + src/utils/kibana_api.ts | 80 ++ 5 files changed, 1010 insertions(+), 23 deletions(-) diff --git a/src/commands/entity_store/README.md b/src/commands/entity_store/README.md index d4799ec0..7349981d 100644 --- a/src/commands/entity_store/README.md +++ b/src/commands/entity_store/README.md @@ -78,6 +78,15 @@ yarn start risk-score-v2 [options] - `--perf`: high-volume preset - `--no-setup`, `--no-criticality`, `--no-watchlists`, `--no-alerts` - `--follow-on` / `--no-follow-on`: enable or skip interactive post-run action menu +- phase2 relationships are enabled by default +- `--no-phase2`: disable relationship + entity-resolution flows throughout the command +- `--no-resolution`: disable resolution linking when `--phase2` is enabled +- propagation ownership links are enabled by default when phase2 is on +- `--no-propagation`: disable ownership relationship writes when `--phase2` is enabled +- `--resolution-group-rate `: default `0.2` +- `--avg-aliases-per-target `: default `2` +- `--ownership-edge-rate `: default `0.3` +- `--table-page-size `: rows per page in summary tables ### Follow-on actions @@ -89,5 +98,17 @@ After the initial summary (TTY mode), you can choose: - re-apply modifiers (new watchlists and criticality, rerun maintainer) - refresh table (no data mutations; re-read latest risk/entity docs) - run maintainer and refresh table (no data mutations beyond maintainer recalculation) +- graph summary (prints resolution groups, ownership edges, sampled resolution group sizes) +- link aliases / unlink entities in resolution groups +- mutate ownership links, clear all relationships, reapply default relationship topology -Each action prints a compact before/after comparison table with score and modifier deltas. +Each action prints a compact before/after comparison table with score, level, modifier, and relationship deltas. + +### Phase 2 sensible defaults + +When phase2 is enabled (default) and no topology overrides are provided: + +- resolution targets are generated with `resolution-group-rate=0.2` +- aliases are assigned with `avg-aliases-per-target=2` +- ownership links use `ownership-edge-rate=0.3` (only with `--propagation`) +- summary table page size defaults to `30` rows diff --git a/src/commands/entity_store/index.ts b/src/commands/entity_store/index.ts index e701f455..3c80f802 100644 --- a/src/commands/entity_store/index.ts +++ b/src/commands/entity_store/index.ts @@ -239,6 +239,31 @@ export const entityStoreCommands: CommandModule = { .option('--no-alerts', 'skip alert generation') .option('--follow-on', 'enable interactive follow-on actions after initial summary') .option('--no-follow-on', 'disable interactive follow-on actions') + .option('--no-phase2', 'disable phase 2 relationship/resolution flows') + .option( + '--no-resolution', + 'disable resolution relationship generation when --phase2 is enabled', + ) + .option( + '--no-propagation', + 'disable ownership/propagation relationship generation when --phase2 is enabled', + ) + .option( + '--resolution-group-rate ', + 'ratio of entities used as resolution targets when --phase2 is enabled (default 0.2)', + ) + .option( + '--avg-aliases-per-target ', + 'average aliases per resolution target when --phase2 is enabled (default 2)', + ) + .option( + '--ownership-edge-rate ', + 'ratio of host/service entities with ownership links when --phase2 + --propagation are enabled (default 0.3)', + ) + .option( + '--table-page-size ', + 'rows per summary table page (default 20, or 30 with --phase2)', + ) .action( wrapAction(async (options) => { await riskScoreV2Command(options); diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index fee42b4f..a43c5569 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -11,10 +11,13 @@ import { enableEntityStoreV2, forceLogExtraction, forceBulkUpdateEntitiesViaCrud, + getResolutionGroup, getEntityMaintainers, initEntityMaintainers, installEntityStoreV2, + linkResolutionEntities, runEntityMaintainer, + unlinkResolutionEntities, } from '../../utils/kibana_api.ts'; import { log } from '../../utils/logger.ts'; import { sleep } from '../../utils/sleep.ts'; @@ -45,6 +48,13 @@ type RiskScoreV2Options = { orgSize?: string; orgProductivitySuite?: string; followOn?: boolean; + phase2?: boolean; + resolution?: boolean; + propagation?: boolean; + resolutionGroupRate?: string; + avgAliasesPerTarget?: string; + ownershipEdgeRate?: string; + tablePageSize?: string; }; type SeededUser = { userName: string; userId: string; userEmail: string }; @@ -57,8 +67,13 @@ type RiskSummaryRow = { id: string; score: string; level: string; + scoreType: string; criticality: string; watchlistsCount: number; + resolutionTarget: string; + resolutionAliases: number; + ownershipLinks: number; + relatedEntities: number; }; type RiskSnapshot = { rows: RiskSummaryRow[]; @@ -67,6 +82,118 @@ type RiskSnapshot = { riskDocsMatched: number; watchlistModifierDocs: number; }; +type ResolutionGroupAssignment = { + targetId: string; + aliasIds: string[]; +}; +type OwnershipEdge = { + sourceId: string; + targetId: string; +}; +type RelationshipGraphState = { + resolutionGroups: ResolutionGroupAssignment[]; + ownershipEdges: OwnershipEdge[]; +}; + +const summarizeList = (items: string[], max: number = 6): string => { + if (items.length === 0) return '-'; + if (items.length <= max) return items.join(', '); + return `${items.slice(0, max).join(', ')}, ... (+${items.length - max} more)`; +}; + +const printGraphSummaryViews = ({ + graph, + maxRows = 20, +}: { + graph: RelationshipGraphState; + maxRows?: number; +}) => { + // eslint-disable-next-line no-console + console.log(colorize('🕸️ Relationships only', 'cyan')); + if (graph.resolutionGroups.length === 0) { + // eslint-disable-next-line no-console + console.log(' resolution links: none'); + } else { + // eslint-disable-next-line no-console + console.log(` resolution groups: ${graph.resolutionGroups.length}`); + for (const [index, group] of graph.resolutionGroups.slice(0, maxRows).entries()) { + // eslint-disable-next-line no-console + console.log(` [${index + 1}] ${group.targetId} <- ${summarizeList(group.aliasIds, 4)}`); + } + if (graph.resolutionGroups.length > maxRows) { + // eslint-disable-next-line no-console + console.log( + ` ... ${graph.resolutionGroups.length - maxRows} additional resolution groups hidden`, + ); + } + } + + if (graph.ownershipEdges.length === 0) { + // eslint-disable-next-line no-console + console.log(' ownership edges: none'); + } else { + // eslint-disable-next-line no-console + console.log(` ownership edges: ${graph.ownershipEdges.length}`); + for (const [index, edge] of graph.ownershipEdges.slice(0, maxRows).entries()) { + // eslint-disable-next-line no-console + console.log(` [${index + 1}] ${edge.sourceId} -> ${edge.targetId}`); + } + if (graph.ownershipEdges.length > maxRows) { + // eslint-disable-next-line no-console + console.log( + ` ... ${graph.ownershipEdges.length - maxRows} additional ownership edges hidden`, + ); + } + } + + // eslint-disable-next-line no-console + console.log(colorize('🧮 Scoring view (resolution + ownership)', 'cyan')); + if (graph.resolutionGroups.length === 0 && graph.ownershipEdges.length === 0) { + // eslint-disable-next-line no-console + console.log(' no relationship data available'); + return; + } + + const resolutionByTarget = new Map(); + for (const group of graph.resolutionGroups) { + resolutionByTarget.set(group.targetId, group.aliasIds); + } + const groupTargets = [...resolutionByTarget.keys()]; + + if (groupTargets.length === 0) { + // eslint-disable-next-line no-console + console.log(' no resolution groups; scoring uses direct ownership edges only'); + const uniqueOwnershipTargets = [...new Set(graph.ownershipEdges.map((edge) => edge.targetId))]; + for (const targetId of uniqueOwnershipTargets.slice(0, maxRows)) { + const contributors = graph.ownershipEdges + .filter((edge) => edge.targetId === targetId) + .map((edge) => edge.sourceId); + // eslint-disable-next-line no-console + console.log(` ${targetId} <= owns(${summarizeList([...new Set(contributors)], 4)})`); + } + return; + } + + for (const [index, targetId] of groupTargets.slice(0, maxRows).entries()) { + const aliases = resolutionByTarget.get(targetId) ?? []; + const members = new Set([targetId, ...aliases]); + const ownershipContributors = [ + ...new Set( + graph.ownershipEdges + .filter((edge) => members.has(edge.targetId)) + .map((edge) => edge.sourceId), + ), + ]; + // eslint-disable-next-line no-console + console.log( + ` [${index + 1}] ${targetId} <= resolution(${aliases.length} aliases: ${summarizeList(aliases, 3)}) + owns(${ownershipContributors.length}: ${summarizeList(ownershipContributors, 3)})`, + ); + } + if (groupTargets.length > maxRows) { + // eslint-disable-next-line no-console + console.log(` ... ${groupTargets.length - maxRows} additional scoring groups hidden`); + } +}; type RiskDocSummary = { entityId: string; timestamp: string; @@ -375,6 +502,169 @@ const getAllEntityIds = ({ ...services.map(toServiceEuid), ]; +const groupByEntityType = (entityIds: string[]): Record => { + const grouped: Record = { user: [], host: [], service: [] }; + for (const entityId of entityIds) { + const type = toModifierEntityType(entityId); + if (type) { + grouped[type].push(entityId); + } + } + return grouped; +}; + +const chunk = (items: T[], size: number): T[][] => { + if (size <= 0) return [items]; + const chunks: T[][] = []; + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + return chunks; +}; + +const buildRelationshipGraph = ({ + entityIds, + enableResolution, + enablePropagation, + resolutionGroupRate, + avgAliasesPerTarget, + ownershipEdgeRate, +}: { + entityIds: string[]; + enableResolution: boolean; + enablePropagation: boolean; + resolutionGroupRate: number; + avgAliasesPerTarget: number; + ownershipEdgeRate: number; +}): RelationshipGraphState => { + const grouped = groupByEntityType(entityIds); + const resolutionGroups: ResolutionGroupAssignment[] = []; + if (enableResolution) { + for (const ids of Object.values(grouped)) { + if (ids.length < 2) continue; + const shuffled = faker.helpers.shuffle(ids); + const targetCount = Math.max(1, Math.floor(shuffled.length * resolutionGroupRate)); + const targets = shuffled.slice(0, Math.min(targetCount, shuffled.length)); + const aliasesPool = shuffled.slice(targets.length); + let aliasCursor = 0; + for (const targetId of targets) { + if (aliasCursor >= aliasesPool.length) break; + const aliasesPerTarget = Math.max(1, Math.floor(avgAliasesPerTarget)); + const aliasIds = aliasesPool.slice(aliasCursor, aliasCursor + aliasesPerTarget); + aliasCursor += aliasIds.length; + if (aliasIds.length > 0) { + resolutionGroups.push({ targetId, aliasIds }); + } + } + } + } + + const ownershipEdges: OwnershipEdge[] = []; + if (enablePropagation) { + const candidateSources = [...grouped.host, ...grouped.service]; + if (candidateSources.length > 0 && grouped.user.length > 0) { + const targetUsers = faker.helpers.shuffle(grouped.user); + for (const sourceId of candidateSources) { + if (faker.number.float({ min: 0, max: 1, fractionDigits: 4 }) > ownershipEdgeRate) continue; + const targetId = targetUsers[faker.number.int({ min: 0, max: targetUsers.length - 1 })]; + ownershipEdges.push({ sourceId, targetId }); + } + } + } + + return { resolutionGroups, ownershipEdges }; +}; + +const applyRelationshipGraph = async ({ + graph, + space, +}: { + graph: RelationshipGraphState; + space: string; +}) => { + const maxResolutionBatchSize = 1000; + for (const group of graph.resolutionGroups) { + for (const aliases of chunk(group.aliasIds, maxResolutionBatchSize)) { + if (aliases.length === 0) continue; + const response = await linkResolutionEntities({ + targetId: group.targetId, + entityIds: aliases, + space, + }); + log.info( + `Resolution link: target=${group.targetId}, linked=${response.linked.length}, skipped=${response.skipped.length}`, + ); + } + } + + if (graph.ownershipEdges.length === 0) { + return; + } + + const bySource = new Map(); + for (const edge of graph.ownershipEdges) { + bySource.set(edge.sourceId, [...(bySource.get(edge.sourceId) ?? []), edge.targetId]); + } + + const entities: Array<{ type: ModifierEntityType; doc: Record }> = []; + for (const [sourceId, targets] of bySource.entries()) { + const type = toModifierEntityType(sourceId); + if (!type) continue; + entities.push({ + type, + doc: { + entity: { + id: sourceId, + relationships: { + owns: [...new Set(targets)], + }, + }, + }, + }); + } + + for (const entityBatch of chunk(entities, 200)) { + await forceBulkUpdateEntitiesViaCrud({ entities: entityBatch, space }); + } +}; + +const clearRelationshipGraph = async ({ + entityIds, + space, +}: { + entityIds: string[]; + space: string; +}) => { + for (const ids of chunk(entityIds, 1000)) { + if (ids.length === 0) continue; + const response = await unlinkResolutionEntities({ entityIds: ids, space }); + log.info( + `Resolution unlink: unlinked=${response.unlinked.length}, skipped=${response.skipped.length}`, + ); + } + + const entities: Array<{ type: ModifierEntityType; doc: Record }> = []; + for (const id of entityIds) { + const type = toModifierEntityType(id); + if (!type) continue; + entities.push({ + type, + doc: { + entity: { + id, + relationships: { + owns: [], + }, + }, + }, + }); + } + + for (const entityBatch of chunk(entities, 200)) { + await forceBulkUpdateEntitiesViaCrud({ entities: entityBatch, space }); + } +}; + const forceExtractExpectedEntities = async ({ space, entityKinds, @@ -846,21 +1136,41 @@ const collectRiskSnapshot = async ({ const entityById = new Map< string, - { entityType: string; criticality: string; watchlists: string[] } + { + entityType: string; + criticality: string; + watchlists: string[]; + resolutionTarget: string; + ownershipLinks: number; + } >(); + const aliasCountByTarget = new Map(); for (const hit of entityResponse.hits.hits) { const source = hit._source as | { - entity?: { id?: string; type?: string; attributes?: { watchlists?: string[] } }; + entity?: { + id?: string; + type?: string; + attributes?: { watchlists?: string[] }; + relationships?: { resolution?: { resolved_to?: unknown }; owns?: unknown }; + }; asset?: { criticality?: string }; } | undefined; const id = source?.entity?.id; if (!id) continue; + const resolvedTo = + normalizeWatchlists(source?.entity?.relationships?.resolution?.resolved_to)[0] ?? '-'; + const owns = normalizeWatchlists(source?.entity?.relationships?.owns); + if (resolvedTo !== '-') { + aliasCountByTarget.set(resolvedTo, (aliasCountByTarget.get(resolvedTo) ?? 0) + 1); + } entityById.set(id, { entityType: source.entity?.type ?? 'unknown', criticality: source.asset?.criticality ?? '-', watchlists: normalizeWatchlists(source?.entity?.attributes?.watchlists), + resolutionTarget: resolvedTo, + ownershipLinks: owns.length, }); } @@ -890,7 +1200,13 @@ const collectRiskSnapshot = async ({ 'service.name', 'service.risk.calculated_score_norm', 'service.risk.calculated_level', + 'service.risk.score_type', + 'service.risk.related_entities', 'service.risk.modifiers', + 'host.risk.score_type', + 'host.risk.related_entities', + 'user.risk.score_type', + 'user.risk.related_entities', ], }); @@ -903,21 +1219,39 @@ const collectRiskSnapshot = async ({ return modifiers.some((m) => m.type === 'watchlist'); }).length; - const riskById = new Map(); + const riskById = new Map< + string, + { score: string; level: string; scoreType: string; relatedEntities: number } + >(); for (const hit of riskResponse.hits.hits) { const source = hit._source as | { host?: { name?: string; - risk?: { calculated_score_norm?: number; calculated_level?: string }; + risk?: { + calculated_score_norm?: number; + calculated_level?: string; + score_type?: string; + related_entities?: unknown[]; + }; }; user?: { name?: string; - risk?: { calculated_score_norm?: number; calculated_level?: string }; + risk?: { + calculated_score_norm?: number; + calculated_level?: string; + score_type?: string; + related_entities?: unknown[]; + }; }; service?: { name?: string; - risk?: { calculated_score_norm?: number; calculated_level?: string }; + risk?: { + calculated_score_norm?: number; + calculated_level?: string; + score_type?: string; + related_entities?: unknown[]; + }; }; } | undefined; @@ -930,6 +1264,8 @@ const collectRiskSnapshot = async ({ ? risk.calculated_score_norm.toFixed(2) : '-', level: risk.calculated_level ?? '-', + scoreType: typeof risk.score_type === 'string' ? risk.score_type : '-', + relatedEntities: Array.isArray(risk.related_entities) ? risk.related_entities.length : 0, }); } @@ -938,8 +1274,13 @@ const collectRiskSnapshot = async ({ id, score: riskById.get(id)?.score ?? '-', level: riskById.get(id)?.level ?? '-', + scoreType: riskById.get(id)?.scoreType ?? '-', criticality: entityById.get(id)?.criticality ?? '-', watchlistsCount: entityById.get(id)?.watchlists.length ?? 0, + resolutionTarget: entityById.get(id)?.resolutionTarget ?? '-', + resolutionAliases: aliasCountByTarget.get(id) ?? 0, + ownershipLinks: entityById.get(id)?.ownershipLinks ?? 0, + relatedEntities: riskById.get(id)?.relatedEntities ?? 0, })), totalRiskDocs, totalEntityDocs, @@ -948,21 +1289,38 @@ const collectRiskSnapshot = async ({ }; }; -const printRiskRows = async (rows: RiskSummaryRow[], riskDocsMatched: number): Promise => { +const printRiskRows = async ({ + rows, + riskDocsMatched, + pageSize = 20, +}: { + rows: RiskSummaryRow[]; + riskDocsMatched: number; + pageSize?: number; +}): Promise => { const idWidth = 66; const scoreWidth = 7; + const scoreTypeWidth = 10; const levelWidth = 8; const critWidth = 14; const watchWidth = 5; - const pageSize = 20; + const relTargetWidth = 22; + const aliasWidth = 5; + const ownsWidth = 4; + const relatedWidth = 4; const header = [ formatCell('Entity ID', idWidth), formatCell('Score', scoreWidth), + formatCell('Type', scoreTypeWidth), formatCell('Lvl', levelWidth), formatCell('Criticality', critWidth), formatCell('WL', watchWidth), + formatCell('Res.Target', relTargetWidth), + formatCell('Ali', aliasWidth), + formatCell('Own', ownsWidth), + formatCell('Rel', relatedWidth), ].join(' | '); - const separator = `${'-'.repeat(idWidth)}-+-${'-'.repeat(scoreWidth)}-+-${'-'.repeat(levelWidth)}-+-${'-'.repeat(critWidth)}-+-${'-'.repeat(watchWidth)}`; + const separator = `${'-'.repeat(idWidth)}-+-${'-'.repeat(scoreWidth)}-+-${'-'.repeat(scoreTypeWidth)}-+-${'-'.repeat(levelWidth)}-+-${'-'.repeat(critWidth)}-+-${'-'.repeat(watchWidth)}-+-${'-'.repeat(relTargetWidth)}-+-${'-'.repeat(aliasWidth)}-+-${'-'.repeat(ownsWidth)}-+-${'-'.repeat(relatedWidth)}`; // eslint-disable-next-line no-console console.log( @@ -983,9 +1341,14 @@ const printRiskRows = async (rows: RiskSummaryRow[], riskDocsMatched: number): P [ formatCell(row.id, idWidth), formatCell(row.score, scoreWidth), + formatCell(row.scoreType, scoreTypeWidth), colorizeRiskLevel(levelCell, row.level), formatCell(row.criticality, critWidth), formatCell(String(row.watchlistsCount), watchWidth), + formatCell(row.resolutionTarget, relTargetWidth), + formatCell(String(row.resolutionAliases), aliasWidth), + formatCell(String(row.ownershipLinks), ownsWidth), + formatCell(String(row.relatedEntities), relatedWidth), ].join(' | '), ); } @@ -1007,9 +1370,14 @@ const printRiskRows = async (rows: RiskSummaryRow[], riskDocsMatched: number): P [ formatCell(row.id, idWidth), formatCell(row.score, scoreWidth), + formatCell(row.scoreType, scoreTypeWidth), colorizeRiskLevel(levelCell, row.level), formatCell(row.criticality, critWidth), formatCell(String(row.watchlistsCount), watchWidth), + formatCell(row.resolutionTarget, relTargetWidth), + formatCell(String(row.resolutionAliases), aliasWidth), + formatCell(String(row.ownershipLinks), ownsWidth), + formatCell(String(row.relatedEntities), relatedWidth), ].join(' | '), ); } @@ -1093,12 +1461,14 @@ const reportRiskSummary = async ({ baselineEntityCount, expectedRiskDelta, entityIds, + pageSize, }: { space: string; baselineRiskScoreCount: number; baselineEntityCount: number; expectedRiskDelta: number; entityIds: string[]; + pageSize: number; }): Promise<{ missingRiskDocs: number; totalEntities: number; snapshot: RiskSnapshot }> => { const snapshot = await collectRiskSnapshot({ space, entityIds }); const riskDelta = Math.max(0, snapshot.totalRiskDocs - baselineRiskScoreCount); @@ -1117,7 +1487,7 @@ const reportRiskSummary = async ({ } log.info(`Docs with watchlist modifiers: ${snapshot.watchlistModifierDocs}`); - await printRiskRows(snapshot.rows, snapshot.riskDocsMatched); + await printRiskRows({ rows: snapshot.rows, riskDocsMatched: snapshot.riskDocsMatched, pageSize }); const result = printSnapshotResult(snapshot); return { ...result, snapshot }; }; @@ -1139,15 +1509,25 @@ const printBeforeAfterComparison = ({ id, score: '-', level: '-', + scoreType: '-', criticality: '-', watchlistsCount: 0, + resolutionTarget: '-', + resolutionAliases: 0, + ownershipLinks: 0, + relatedEntities: 0, }; const afterRow = afterById.get(id) ?? { id, score: '-', level: '-', + scoreType: '-', criticality: '-', watchlistsCount: 0, + resolutionTarget: '-', + resolutionAliases: 0, + ownershipLinks: 0, + relatedEntities: 0, }; const beforeScore = toNumericScore(beforeRow.score); const afterScore = toNumericScore(afterRow.score); @@ -1163,11 +1543,26 @@ const printBeforeAfterComparison = ({ afterCriticality: afterRow.criticality, beforeWatchlistsCount: beforeRow.watchlistsCount, afterWatchlistsCount: afterRow.watchlistsCount, + beforeScoreType: beforeRow.scoreType, + afterScoreType: afterRow.scoreType, + beforeResolutionTarget: beforeRow.resolutionTarget, + afterResolutionTarget: afterRow.resolutionTarget, + beforeResolutionAliases: beforeRow.resolutionAliases, + afterResolutionAliases: afterRow.resolutionAliases, + beforeOwnershipLinks: beforeRow.ownershipLinks, + afterOwnershipLinks: afterRow.ownershipLinks, + beforeRelatedEntities: beforeRow.relatedEntities, + afterRelatedEntities: afterRow.relatedEntities, scoreTransition: `${beforeRow.score}->${afterRow.score}`, delta, levelTransition: `${beforeRow.level}->${afterRow.level}`, + scoreTypeTransition: `${beforeRow.scoreType}->${afterRow.scoreType}`, + resolutionTransition: `${beforeRow.resolutionTarget}->${afterRow.resolutionTarget}`, criticalityTransition: `${beforeRow.criticality}->${afterRow.criticality}`, watchlistTransition: `${beforeRow.watchlistsCount}->${afterRow.watchlistsCount}`, + aliasTransition: `${beforeRow.resolutionAliases}->${afterRow.resolutionAliases}`, + ownershipTransition: `${beforeRow.ownershipLinks}->${afterRow.ownershipLinks}`, + relatedTransition: `${beforeRow.relatedEntities}->${afterRow.relatedEntities}`, }; }); const changedRows = deltaRows.filter((row) => { @@ -1175,25 +1570,40 @@ const printBeforeAfterComparison = ({ row.beforeScore !== row.afterScore || row.beforeLevel !== row.afterLevel || row.beforeCriticality !== row.afterCriticality || - row.beforeWatchlistsCount !== row.afterWatchlistsCount + row.beforeWatchlistsCount !== row.afterWatchlistsCount || + row.beforeScoreType !== row.afterScoreType || + row.beforeResolutionTarget !== row.afterResolutionTarget || + row.beforeResolutionAliases !== row.afterResolutionAliases || + row.beforeOwnershipLinks !== row.afterOwnershipLinks || + row.beforeRelatedEntities !== row.afterRelatedEntities ); }); - const idWidth = 52; + const idWidth = 40; const scoreWidth = 17; + const typeWidth = 12; const deltaWidth = 7; - const levelWidth = 17; - const critWidth = 27; + const levelWidth = 15; + const relWidth = 17; + const critWidth = 21; const wlWidth = 9; + const aliasWidth = 8; + const ownWidth = 7; + const relEntWidth = 7; const header = [ formatCell('Entity ID', idWidth), formatCell('Score b->a', scoreWidth), + formatCell('Type b->a', typeWidth), formatCell('Delta', deltaWidth), formatCell('Lvl b->a', levelWidth), + formatCell('Res b->a', relWidth), formatCell('Crit b->a', critWidth), formatCell('WL b->a', wlWidth), + formatCell('Ali b->a', aliasWidth), + formatCell('Own b->a', ownWidth), + formatCell('Rel b->a', relEntWidth), ].join(' | '); - const separator = `${'-'.repeat(idWidth)}-+-${'-'.repeat(scoreWidth)}-+-${'-'.repeat(deltaWidth)}-+-${'-'.repeat(levelWidth)}-+-${'-'.repeat(critWidth)}-+-${'-'.repeat(wlWidth)}`; + const separator = `${'-'.repeat(idWidth)}-+-${'-'.repeat(scoreWidth)}-+-${'-'.repeat(typeWidth)}-+-${'-'.repeat(deltaWidth)}-+-${'-'.repeat(levelWidth)}-+-${'-'.repeat(relWidth)}-+-${'-'.repeat(critWidth)}-+-${'-'.repeat(wlWidth)}-+-${'-'.repeat(aliasWidth)}-+-${'-'.repeat(ownWidth)}-+-${'-'.repeat(relEntWidth)}`; // eslint-disable-next-line no-console console.log(colorize(`🔄 Before/After (${actionTitle})`, 'cyan')); if (changedRows.length === 0) { @@ -1219,10 +1629,15 @@ const printBeforeAfterComparison = ({ [ formatCell(row.id, idWidth), formatCell(row.scoreTransition, scoreWidth), + formatCell(row.scoreTypeTransition, typeWidth), colorizeDelta(deltaCell, row.delta), levelCell, + formatCell(row.resolutionTransition, relWidth), formatCell(row.criticalityTransition, critWidth), formatCell(row.watchlistTransition, wlWidth), + formatCell(row.aliasTransition, aliasWidth), + formatCell(row.ownershipTransition, ownWidth), + formatCell(row.relatedTransition, relEntWidth), ].join(' | '), ); } @@ -1569,6 +1984,12 @@ type FollowOnAction = | 'export_risk_docs' | 'refresh_table' | 'run_maintainer_and_refresh' + | 'graph_summary' + | 'link_aliases' + | 'unlink_entities' + | 'ownership_mutate' + | 'clear_relationships' + | 'reapply_relationships' | 'exit'; type TrackedEntitySelection = @@ -1702,6 +2123,15 @@ const printSingleEntityState = async ({ const snapshot = await collectRiskSnapshot({ space, entityIds: [selection.euid] }); const row = snapshot.rows[0]; const alertCount = await countAlertsForSelection({ space, selection }); + let resolutionGroupSize = 0; + let resolutionAliases = 0; + try { + const resolutionGroup = await getResolutionGroup({ entityId: selection.euid, space }); + resolutionGroupSize = resolutionGroup.group_size; + resolutionAliases = resolutionGroup.aliases.length; + } catch { + // Some entities may not participate in resolution; keep defaults. + } if (!row) { log.warn(`No current risk/entity state found for "${selection.euid}".`); return; @@ -1710,7 +2140,7 @@ const printSingleEntityState = async ({ console.log(colorize(`🎯 Single entity state: ${selection.euid}`, 'cyan')); // eslint-disable-next-line no-console console.log( - ` score=${row.score}, level=${row.level}, criticality=${row.criticality}, watchlists=${row.watchlistsCount}, alerts=${alertCount}`, + ` score=${row.score}, score_type=${row.scoreType}, level=${row.level}, criticality=${row.criticality}, watchlists=${row.watchlistsCount}, alerts=${alertCount}, resolved_to=${row.resolutionTarget}, aliases=${row.resolutionAliases}, owns=${row.ownershipLinks}, related=${row.relatedEntities}, resolution_group_size=${resolutionGroupSize}, resolution_group_aliases=${resolutionAliases}`, ); }; @@ -1813,7 +2243,15 @@ const fetchRiskDocsForEntityIds = async ({ return grouped; }; -const promptFollowOnAction = async (): Promise => { +const promptFollowOnAction = async ({ + phase2Enabled, + resolutionEnabled, + propagationEnabled, +}: { + phase2Enabled: boolean; + resolutionEnabled: boolean; + propagationEnabled: boolean; +}): Promise => { const optionsText = [ `${ANSI.bold}Choose a follow-on action:${ANSI.reset}`, formatFollowOnOption('r', 'reset to zero (wipe seeded alerts, re-run maintainer)'), @@ -1826,6 +2264,22 @@ const promptFollowOnAction = async (): Promise => { formatFollowOnOption('x', 'export risk-score docs to file'), formatFollowOnOption('f', 'refresh table (no data changes)'), formatFollowOnOption('u', 'run maintainer and refresh table'), + ...(phase2Enabled + ? [ + formatFollowOnOption('g', 'graph summary (resolution groups + ownership edges)'), + ...(resolutionEnabled + ? [ + formatFollowOnOption('l', 'link aliases to a resolution target'), + formatFollowOnOption('k', 'unlink entities from resolution groups'), + ] + : []), + ...(propagationEnabled + ? [formatFollowOnOption('o', 'mutate ownership relationships')] + : []), + formatFollowOnOption('c', 'clear all relationships'), + formatFollowOnOption('d', 'reapply default relationships'), + ] + : []), formatFollowOnOption('q', 'exit'), ].join('\n'); @@ -1879,12 +2333,38 @@ const promptFollowOnAction = async (): Promise => { log.info('Selected [u] run maintainer and refresh table.'); return 'run_maintainer_and_refresh'; } + if (answer === 'g') { + log.info('Selected [g] graph summary.'); + return 'graph_summary'; + } + if (answer === 'l') { + log.info('Selected [l] link aliases.'); + return 'link_aliases'; + } + if (answer === 'k') { + log.info('Selected [k] unlink entities.'); + return 'unlink_entities'; + } + if (answer === 'o') { + log.info('Selected [o] ownership mutate.'); + return 'ownership_mutate'; + } + if (answer === 'c') { + log.info('Selected [c] clear relationships.'); + return 'clear_relationships'; + } + if (answer === 'd') { + log.info('Selected [d] reapply relationships.'); + return 'reapply_relationships'; + } if (answer === 'q') { log.info('Selected [q] exit.'); return 'exit'; } - log.warn(`Invalid option "${answer}". Please enter one of: r, p, m, a, e, t, v, x, f, u, q.`); + log.warn( + `Invalid option "${answer}". Please enter one of: r, p, m, a, e, t, v, x, f, u, g, l, k, o, c, d, q.`, + ); } }; @@ -1903,6 +2383,14 @@ const runFollowOnActionLoop = async ({ enableWatchlists, alertsPerEntity, modifierBulkBatchSize, + pageSize, + phase2Enabled, + resolutionEnabled, + propagationEnabled, + resolutionGroupRate, + avgAliasesPerTarget, + ownershipEdgeRate, + relationshipGraph, runTimedStage, }: { space: string; @@ -1919,6 +2407,14 @@ const runFollowOnActionLoop = async ({ enableWatchlists: boolean; alertsPerEntity: number; modifierBulkBatchSize: number; + pageSize: number; + phase2Enabled: boolean; + resolutionEnabled: boolean; + propagationEnabled: boolean; + resolutionGroupRate: number; + avgAliasesPerTarget: number; + ownershipEdgeRate: number; + relationshipGraph: RelationshipGraphState; runTimedStage: (stage: string, fn: () => Promise) => Promise; }) => { let trackedUsers = [...users]; @@ -1928,12 +2424,20 @@ const runFollowOnActionLoop = async ({ let trackedWatchlistIds = [...watchlistIds]; let trackedEntityIds = [...new Set(entityIds)]; let lastChangedEntityIds: string[] = []; + let trackedRelationshipGraph: RelationshipGraphState = { + resolutionGroups: [...relationshipGraph.resolutionGroups], + ownershipEdges: [...relationshipGraph.ownershipEdges], + }; while (true) { log.info( `Current entity pool: idp_users=${trackedUsers.length}, local_users=${trackedLocalUsers.length}, hosts=${trackedHosts.length}, services=${trackedServices.length}, total=${trackedEntityIds.length}`, ); - const action = await promptFollowOnAction(); + const action = await promptFollowOnAction({ + phase2Enabled, + resolutionEnabled, + propagationEnabled, + }); if (action === 'exit') { log.info('Exiting follow-on action loop.'); @@ -2118,6 +2622,25 @@ const runFollowOnActionLoop = async ({ }); } + if (phase2Enabled && expectedNewEntityIds.length > 0) { + await runTimedStage('follow_on_expand_apply_relationships', async () => { + const expandedGraph = buildRelationshipGraph({ + entityIds: [...trackedEntityIds, ...expectedNewEntityIds], + enableResolution: resolutionEnabled, + enablePropagation: propagationEnabled, + resolutionGroupRate, + avgAliasesPerTarget, + ownershipEdgeRate, + }); + await clearRelationshipGraph({ + entityIds: [...trackedEntityIds, ...expectedNewEntityIds], + space, + }); + await applyRelationshipGraph({ graph: expandedGraph, space }); + trackedRelationshipGraph = expandedGraph; + }); + } + await runTimedStage('follow_on_expand_alerts', async () => indexAlertsForSeededEntities({ users: newUsers, @@ -2168,7 +2691,7 @@ const runFollowOnActionLoop = async ({ const tweakActionRaw = await input({ message: - 'Single-entity action: [c] criticality, [w] watchlists, [z] reset alerts->zero, [l] add alerts', + 'Single-entity action: [c] criticality, [w] watchlists, [z] reset alerts->zero, [l] add alerts, [y] set resolution target, [h] set ownership target', default: 'c', }); const tweakAction = tweakActionRaw.trim().toLowerCase(); @@ -2308,6 +2831,69 @@ const runFollowOnActionLoop = async ({ log.info( `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, ); + } else if (tweakAction === 'y') { + const targetId = ( + await input({ + message: 'Resolution target entity ID (blank to clear resolution link)', + default: '', + }) + ).trim(); + await runTimedStage('follow_on_tweak_single_resolution', async () => { + await unlinkResolutionEntities({ entityIds: [selection.euid], space }); + if (targetId && targetId !== selection.euid) { + await linkResolutionEntities({ + targetId, + entityIds: [selection.euid], + space, + }); + } + }); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_tweak_single_resolution_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } else if (tweakAction === 'h') { + const targetId = ( + await input({ + message: 'Ownership target entity ID (blank to clear ownership links)', + default: '', + }) + ).trim(); + const entityType = toModifierEntityType(selection.euid); + if (!entityType) { + log.warn(`Entity type for "${selection.euid}" does not support relationship updates.`); + } else { + await runTimedStage('follow_on_tweak_single_ownership', async () => { + await forceBulkUpdateEntitiesViaCrud({ + entities: [ + { + type: entityType, + doc: { + entity: { + id: selection.euid, + relationships: { + owns: targetId ? [targetId] : [], + }, + }, + }, + }, + ], + space, + }); + }); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_tweak_single_ownership_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } } else { log.warn(`Invalid single-entity action "${tweakAction}". No changes applied.`); } @@ -2376,6 +2962,11 @@ const runFollowOnActionLoop = async ({ default: 'n', }); const format = formatRaw.trim().toLowerCase() === 'j' ? 'json' : 'ndjson'; + const includeRelRaw = await input({ + message: 'Include relationship context from entity docs? [y/N]', + default: 'n', + }); + const includeRelationshipContext = includeRelRaw.trim().toLowerCase() === 'y'; const outDirRaw = await input({ message: 'Output directory', default: 'tmp/risk-score-v2/exports', @@ -2399,6 +2990,22 @@ const runFollowOnActionLoop = async ({ source: doc.source, })), ); + if (includeRelationshipContext && records.length > 0) { + const snapshot = await collectRiskSnapshot({ space, entityIds: targetEntityIds }); + const rowById = new Map(snapshot.rows.map((row) => [row.id, row])); + for (const record of records) { + const row = rowById.get(record.entity_id); + if (!row) continue; + Object.assign(record, { + relationship_context: { + resolution_target: row.resolutionTarget, + resolution_aliases: row.resolutionAliases, + ownership_links: row.ownershipLinks, + related_entities: row.relatedEntities, + }, + }); + } + } await fs.mkdir(outDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); @@ -2425,6 +3032,210 @@ const runFollowOnActionLoop = async ({ ); } else if (action === 'refresh_table') { log.info('Refreshing summary table without mutating alerts, modifiers, or entities.'); + } else if (action === 'graph_summary') { + if (!phase2Enabled) { + log.warn('Phase2 graph actions are disabled. Re-run with --phase2.'); + } else { + const sampledIds = trackedEntityIds.slice(0, 3); + const sampledGroups = await Promise.all( + sampledIds.map(async (entityId) => { + try { + const group = await getResolutionGroup({ entityId, space }); + return `${entityId}:${group.group_size}`; + } catch { + return `${entityId}:n/a`; + } + }), + ); + log.info( + `Graph summary: resolution_groups=${trackedRelationshipGraph.resolutionGroups.length}, ownership_edges=${trackedRelationshipGraph.ownershipEdges.length}, sample_group_sizes=[${sampledGroups.join(', ')}]`, + ); + printGraphSummaryViews({ graph: trackedRelationshipGraph, maxRows: pageSize }); + } + } else if (action === 'link_aliases') { + if (!phase2Enabled || !resolutionEnabled) { + log.warn('Resolution linking is disabled. Re-run with --phase2 --resolution.'); + } else if (trackedEntityIds.length < 2) { + log.warn('Not enough tracked entities to create resolution links.'); + } else { + const targetId = ( + await input({ + message: 'Resolution target entity ID', + default: trackedEntityIds[0] ?? '', + }) + ).trim(); + const aliasCsv = await input({ + message: 'Alias entity IDs (comma-separated)', + default: trackedEntityIds.slice(1, 3).join(','), + }); + const aliasIds = [ + ...new Set( + aliasCsv + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + ), + ].filter((id) => id !== targetId); + if (!targetId || aliasIds.length === 0) { + log.warn('Target and at least one alias are required.'); + } else { + await runTimedStage('follow_on_link_aliases', async () => { + for (const ids of chunk(aliasIds, 1000)) { + const response = await linkResolutionEntities({ targetId, entityIds: ids, space }); + log.info( + `Resolution link: target=${targetId}, linked=${response.linked.length}, skipped=${response.skipped.length}`, + ); + } + }); + trackedRelationshipGraph.resolutionGroups.push({ targetId, aliasIds }); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_link_aliases_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } + } + } else if (action === 'unlink_entities') { + if (!phase2Enabled || !resolutionEnabled) { + log.warn('Resolution unlink is disabled. Re-run with --phase2 --resolution.'); + } else { + const entityIdsRaw = await input({ + message: 'Entity IDs to unlink (comma-separated)', + default: trackedEntityIds.slice(0, 2).join(','), + }); + const entityIdsToUnlink = [ + ...new Set( + entityIdsRaw + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + ), + ]; + if (entityIdsToUnlink.length === 0) { + log.warn('No entity IDs provided for unlink.'); + } else { + await runTimedStage('follow_on_unlink_entities', async () => { + for (const ids of chunk(entityIdsToUnlink, 1000)) { + const response = await unlinkResolutionEntities({ entityIds: ids, space }); + log.info( + `Resolution unlink: unlinked=${response.unlinked.length}, skipped=${response.skipped.length}`, + ); + } + }); + trackedRelationshipGraph.resolutionGroups = trackedRelationshipGraph.resolutionGroups.map( + (group) => ({ + targetId: group.targetId, + aliasIds: group.aliasIds.filter((aliasId) => !entityIdsToUnlink.includes(aliasId)), + }), + ); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_unlink_entities_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } + } + } else if (action === 'ownership_mutate') { + if (!phase2Enabled || !propagationEnabled) { + log.warn('Ownership mutation is disabled. Re-run with --phase2 --propagation.'); + } else { + const sourceId = ( + await input({ + message: 'Ownership source entity ID (host/service recommended)', + default: trackedHosts[0] ? toHostEuid(trackedHosts[0]) : (trackedEntityIds[0] ?? ''), + }) + ).trim(); + const targetId = ( + await input({ + message: 'Ownership target entity ID', + default: trackedUsers[0] ? toUserEuid(trackedUsers[0]) : (trackedEntityIds[0] ?? ''), + }) + ).trim(); + const sourceType = toModifierEntityType(sourceId); + if (!sourceId || !targetId || !sourceType) { + log.warn('Valid source and target IDs are required for ownership mutation.'); + } else { + await runTimedStage('follow_on_ownership_mutate', async () => { + await forceBulkUpdateEntitiesViaCrud({ + entities: [ + { + type: sourceType, + doc: { + entity: { + id: sourceId, + relationships: { + owns: [targetId], + }, + }, + }, + }, + ], + space, + }); + }); + trackedRelationshipGraph.ownershipEdges = trackedRelationshipGraph.ownershipEdges.filter( + (edge) => edge.sourceId !== sourceId, + ); + trackedRelationshipGraph.ownershipEdges.push({ sourceId, targetId }); + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_ownership_mutate_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } + } + } else if (action === 'clear_relationships') { + if (!phase2Enabled) { + log.warn('Relationship operations are disabled. Re-run with --phase2.'); + } else { + await runTimedStage('follow_on_clear_relationships', async () => + clearRelationshipGraph({ entityIds: trackedEntityIds, space }), + ); + trackedRelationshipGraph = { resolutionGroups: [], ownershipEdges: [] }; + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_clear_relationships_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } + } else if (action === 'reapply_relationships') { + if (!phase2Enabled) { + log.warn('Relationship operations are disabled. Re-run with --phase2.'); + } else { + const rebuiltGraph = buildRelationshipGraph({ + entityIds: trackedEntityIds, + enableResolution: resolutionEnabled, + enablePropagation: propagationEnabled, + resolutionGroupRate, + avgAliasesPerTarget, + ownershipEdgeRate, + }); + await runTimedStage('follow_on_reapply_relationships', async () => { + await clearRelationshipGraph({ entityIds: trackedEntityIds, space }); + await applyRelationshipGraph({ graph: rebuiltGraph, space }); + }); + trackedRelationshipGraph = rebuiltGraph; + const maintainerOutcome = await runRiskMaintainerOnce({ + space, + runTimedStage, + stage: 'follow_on_reapply_relationships_run_maintainer', + }); + log.info( + `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, + ); + } } const after = await collectRiskSnapshot({ space, entityIds: trackedEntityIds }); @@ -2452,7 +3263,7 @@ const runFollowOnActionLoop = async ({ } } lastChangedEntityIds = printBeforeAfterComparison({ actionTitle: action, before, after }); - await printRiskRows(after.rows, after.riskDocsMatched); + await printRiskRows({ rows: after.rows, riskDocsMatched: after.riskDocsMatched, pageSize }); printSnapshotResult(after); } }; @@ -2500,10 +3311,23 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { const offsetHours = parseOptionInt(options.offsetHours, 1); const eventIndex = options.eventIndex || config.eventIndex || 'logs-testlogs-default'; const modifierBulkBatchSize = perf ? 500 : 200; + const phase2Enabled = options.phase2 !== false; + const resolutionEnabled = phase2Enabled && options.resolution !== false; + const propagationEnabled = phase2Enabled && options.propagation !== false; + const resolutionGroupRate = Math.min( + 0.9, + Math.max(0.01, Number.parseFloat(options.resolutionGroupRate ?? '0.2')), + ); + const avgAliasesPerTarget = Math.max(1, parseOptionInt(options.avgAliasesPerTarget, 2)); + const ownershipEdgeRate = Math.min( + 1, + Math.max(0, Number.parseFloat(options.ownershipEdgeRate ?? '0.3')), + ); + const pageSize = Math.max(10, parseOptionInt(options.tablePageSize, phase2Enabled ? 30 : 20)); const followOnEnabled = options.followOn ?? process.stdout.isTTY; log.info( - `Starting risk-score-v2 in space "${space}" with seedSource=${seedSource}, kinds=${entityKinds.join(',')}, idp_users=${usersCount}, local_users=${localUsersCount}, hosts=${hostsCount}, services=${servicesCount}, alertsPerEntity=${alertsPerEntity}, eventIndex=${eventIndex}`, + `Starting risk-score-v2 in space "${space}" with seedSource=${seedSource}, kinds=${entityKinds.join(',')}, idp_users=${usersCount}, local_users=${localUsersCount}, hosts=${hostsCount}, services=${servicesCount}, alertsPerEntity=${alertsPerEntity}, eventIndex=${eventIndex}, phase2=${phase2Enabled}, resolution=${resolutionEnabled}, propagation=${propagationEnabled}`, ); if (options.setup !== false) { @@ -2577,6 +3401,28 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { }); }); + let relationshipGraph: RelationshipGraphState = { resolutionGroups: [], ownershipEdges: [] }; + if (phase2Enabled) { + relationshipGraph = buildRelationshipGraph({ + entityIds: uniqueEntityIds, + enableResolution: resolutionEnabled, + enablePropagation: propagationEnabled, + resolutionGroupRate, + avgAliasesPerTarget, + ownershipEdgeRate, + }); + await runTimedStage('apply_relationships', async () => { + if ( + relationshipGraph.resolutionGroups.length === 0 && + relationshipGraph.ownershipEdges.length === 0 + ) { + log.info('Phase2 relationships enabled but no relationship rows generated; continuing.'); + return; + } + await applyRelationshipGraph({ graph: relationshipGraph, space }); + }); + } + let watchlistIds: string[] = []; if (options.watchlists !== false) { log.info('Creating watchlists...'); @@ -2635,6 +3481,7 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { baselineEntityCount, expectedRiskDelta: Math.max(1, expectedNewEntityIds.length), entityIds: allEntityIds, + pageSize, }), ); if (followOnEnabled && process.stdout.isTTY) { @@ -2653,6 +3500,14 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { enableWatchlists: options.watchlists !== false, alertsPerEntity, modifierBulkBatchSize, + pageSize, + phase2Enabled, + resolutionEnabled, + propagationEnabled, + resolutionGroupRate, + avgAliasesPerTarget, + ownershipEdgeRate, + relationshipGraph, runTimedStage, }); } else if (followOnEnabled && !process.stdout.isTTY) { diff --git a/src/constants.ts b/src/constants.ts index c37ba40a..2ed1c3da 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -111,6 +111,12 @@ export const ENTITY_STORE_V2_INSTALL_URL = '/internal/security/entity_store/inst export const ENTITY_STORE_V2_FORCE_LOG_EXTRACTION_URL = (entityType: 'user' | 'host' | 'service') => `/internal/security/entity_store/${entityType}/force_log_extraction`; export const ENTITY_STORE_V2_CRUD_BULK_URL = '/internal/security/entity_store/entities/bulk'; +export const ENTITY_STORE_V2_RESOLUTION_LINK_URL = + '/internal/security/entity_store/resolution/link'; +export const ENTITY_STORE_V2_RESOLUTION_UNLINK_URL = + '/internal/security/entity_store/resolution/unlink'; +export const ENTITY_STORE_V2_RESOLUTION_GROUP_URL = + '/internal/security/entity_store/resolution/group'; export const ENTITY_MAINTAINERS_INIT_URL = '/internal/security/entity_store/entity_maintainers/init'; export const ENTITY_MAINTAINERS_URL = '/internal/security/entity_store/entity_maintainers'; diff --git a/src/utils/kibana_api.ts b/src/utils/kibana_api.ts index a0966a0e..97d6f42e 100644 --- a/src/utils/kibana_api.ts +++ b/src/utils/kibana_api.ts @@ -26,6 +26,9 @@ import { ENTITY_STORE_ENTITIES_URL, ENTITY_STORE_V2_INSTALL_URL, ENTITY_STORE_V2_FORCE_LOG_EXTRACTION_URL, + ENTITY_STORE_V2_RESOLUTION_GROUP_URL, + ENTITY_STORE_V2_RESOLUTION_LINK_URL, + ENTITY_STORE_V2_RESOLUTION_UNLINK_URL, ENTITY_MAINTAINERS_INIT_URL, ENTITY_MAINTAINERS_URL, ENTITY_MAINTAINERS_RUN_URL, @@ -782,6 +785,83 @@ export const runEntityMaintainer = async (maintainerId: string, space: string = ); }; +export interface ResolutionLinkResponse { + linked: string[]; + skipped: string[]; + target_id: string; +} + +export interface ResolutionUnlinkResponse { + unlinked: string[]; + skipped: string[]; +} + +export interface ResolutionGroupResponse { + target: Record; + aliases: Array>; + group_size: number; +} + +export const linkResolutionEntities = async ({ + targetId, + entityIds, + space = 'default', +}: { + targetId: string; + entityIds: string[]; + space?: string; +}) => { + const spacePath = getEntityStoreV2SpacePath(space); + const path = `${spacePath}${ENTITY_STORE_V2_RESOLUTION_LINK_URL}?apiVersion=2`; + return kibanaFetch( + path, + { + method: 'POST', + body: JSON.stringify({ + target_id: targetId, + entity_ids: entityIds, + }), + }, + { apiVersion: '2' }, + ); +}; + +export const unlinkResolutionEntities = async ({ + entityIds, + space = 'default', +}: { + entityIds: string[]; + space?: string; +}) => { + const spacePath = getEntityStoreV2SpacePath(space); + const path = `${spacePath}${ENTITY_STORE_V2_RESOLUTION_UNLINK_URL}?apiVersion=2`; + return kibanaFetch( + path, + { + method: 'POST', + body: JSON.stringify({ + entity_ids: entityIds, + }), + }, + { apiVersion: '2' }, + ); +}; + +export const getResolutionGroup = async ({ + entityId, + space = 'default', +}: { + entityId: string; + space?: string; +}) => { + const spacePath = getEntityStoreV2SpacePath(space); + const query = new URLSearchParams(); + query.set('apiVersion', '2'); + query.set('entity_id', entityId); + const path = `${spacePath}${ENTITY_STORE_V2_RESOLUTION_GROUP_URL}?${query.toString()}`; + return kibanaFetch(path, { method: 'GET' }, { apiVersion: '2' }); +}; + export const createWatchlist = async ({ name, riskModifier, From 8ad5600599c32f05f3a2e4c311c65e1af1cded88 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 13:17:08 +0100 Subject: [PATCH 11/15] Improve graph summary readability for relationship output. Colorize relationship arrows and labels separately from entity IDs so resolution and ownership structure is easier to scan in dense terminal output. Made-with: Cursor --- src/commands/entity_store/risk_score_v2.ts | 27 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index a43c5569..ba8caef1 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -108,6 +108,18 @@ const printGraphSummaryViews = ({ graph: RelationshipGraphState; maxRows?: number; }) => { + const token = (text: string, color: 'green' | 'yellow' | 'cyan') => { + if (!process.stdout.isTTY) return text; + const code = color === 'green' ? '\x1b[32m' : color === 'yellow' ? '\x1b[33m' : '\x1b[36m'; + return `${code}${text}\x1b[0m`; + }; + const resolutionLabel = token('resolution', 'yellow'); + const ownsLabel = token('owns', 'green'); + const leftArrow = token('<-', 'yellow'); + const rightArrow = token('->', 'green'); + const contributesArrow = token('<=', 'cyan'); + const plusToken = token('+', 'cyan'); + // eslint-disable-next-line no-console console.log(colorize('🕸️ Relationships only', 'cyan')); if (graph.resolutionGroups.length === 0) { @@ -118,7 +130,9 @@ const printGraphSummaryViews = ({ console.log(` resolution groups: ${graph.resolutionGroups.length}`); for (const [index, group] of graph.resolutionGroups.slice(0, maxRows).entries()) { // eslint-disable-next-line no-console - console.log(` [${index + 1}] ${group.targetId} <- ${summarizeList(group.aliasIds, 4)}`); + console.log( + ` [${index + 1}] ${group.targetId} ${leftArrow} ${summarizeList(group.aliasIds, 4)}`, + ); } if (graph.resolutionGroups.length > maxRows) { // eslint-disable-next-line no-console @@ -136,7 +150,7 @@ const printGraphSummaryViews = ({ console.log(` ownership edges: ${graph.ownershipEdges.length}`); for (const [index, edge] of graph.ownershipEdges.slice(0, maxRows).entries()) { // eslint-disable-next-line no-console - console.log(` [${index + 1}] ${edge.sourceId} -> ${edge.targetId}`); + console.log(` [${index + 1}] ${edge.sourceId} ${rightArrow} ${edge.targetId}`); } if (graph.ownershipEdges.length > maxRows) { // eslint-disable-next-line no-console @@ -169,7 +183,12 @@ const printGraphSummaryViews = ({ .filter((edge) => edge.targetId === targetId) .map((edge) => edge.sourceId); // eslint-disable-next-line no-console - console.log(` ${targetId} <= owns(${summarizeList([...new Set(contributors)], 4)})`); + console.log( + ` ${targetId} ${contributesArrow} ${ownsLabel}(${summarizeList( + [...new Set(contributors)], + 4, + )})`, + ); } return; } @@ -186,7 +205,7 @@ const printGraphSummaryViews = ({ ]; // eslint-disable-next-line no-console console.log( - ` [${index + 1}] ${targetId} <= resolution(${aliases.length} aliases: ${summarizeList(aliases, 3)}) + owns(${ownershipContributors.length}: ${summarizeList(ownershipContributors, 3)})`, + ` [${index + 1}] ${targetId} ${contributesArrow} ${resolutionLabel}(${aliases.length} aliases: ${summarizeList(aliases, 3)}) ${plusToken} ${ownsLabel}(${ownershipContributors.length}: ${summarizeList(ownershipContributors, 3)})`, ); } if (groupTargets.length > maxRows) { From 60cd9f8ced7c2cfb9699c24833c89a07b3ac3ccc Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 20:16:08 +0100 Subject: [PATCH 12/15] Refine risk-score-v2 diagnostics and cleanup workflow. Keep the reliability fixes that improved run stability while gating verbose resolution debugging behind an explicit option, and add a dangerous clean mode that fully resets phase-2 state for deterministic reruns. Made-with: Cursor --- src/commands/entity_store/README.md | 4 + src/commands/entity_store/index.ts | 10 + src/commands/entity_store/risk_score_v2.ts | 1104 +++++++++++++++++++- src/utils/logger.ts | 3 +- 4 files changed, 1097 insertions(+), 24 deletions(-) diff --git a/src/commands/entity_store/README.md b/src/commands/entity_store/README.md index 7349981d..fc27fb65 100644 --- a/src/commands/entity_store/README.md +++ b/src/commands/entity_store/README.md @@ -87,6 +87,8 @@ yarn start risk-score-v2 [options] - `--avg-aliases-per-target `: default `2` - `--ownership-edge-rate `: default `0.3` - `--table-page-size `: rows per page in summary tables +- `--dangerous-clean`: clear alerts, entity docs, risk-score docs, and risk lookup docs in target space before run +- `--debug-resolution`: enable verbose resolution diagnostics (relationship sync/read traces) ### Follow-on actions @@ -99,10 +101,12 @@ After the initial summary (TTY mode), you can choose: - refresh table (no data mutations; re-read latest risk/entity docs) - run maintainer and refresh table (no data mutations beyond maintainer recalculation) - graph summary (prints resolution groups, ownership edges, sampled resolution group sizes) +- explain resolution score for a single target (prints synthetic resolution key + contributors) - link aliases / unlink entities in resolution groups - mutate ownership links, clear all relationships, reapply default relationship topology Each action prints a compact before/after comparison table with score, level, modifier, and relationship deltas. +The command also prints a dedicated **resolution scorecard** (with synthetic `resolution_key`) so parent-anchored resolution scores are visible and referenceable. ### Phase 2 sensible defaults diff --git a/src/commands/entity_store/index.ts b/src/commands/entity_store/index.ts index 3c80f802..2e947138 100644 --- a/src/commands/entity_store/index.ts +++ b/src/commands/entity_store/index.ts @@ -264,6 +264,16 @@ export const entityStoreCommands: CommandModule = { '--table-page-size ', 'rows per summary table page (default 20, or 30 with --phase2)', ) + .option( + '--dangerous-clean', + 'DANGEROUS: clear alerts, entity docs, and risk score docs in the selected space before running', + false, + ) + .option( + '--debug-resolution', + 'enable verbose resolution diagnostics (relationship sync + debug read traces)', + false, + ) .action( wrapAction(async (options) => { await riskScoreV2Command(options); diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index ba8caef1..277ad3ee 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -55,6 +55,8 @@ type RiskScoreV2Options = { avgAliasesPerTarget?: string; ownershipEdgeRate?: string; tablePageSize?: string; + dangerousClean?: boolean; + debugResolution?: boolean; }; type SeededUser = { userName: string; userId: string; userEmail: string }; @@ -77,11 +79,21 @@ type RiskSummaryRow = { }; type RiskSnapshot = { rows: RiskSummaryRow[]; + resolutionRows: ResolutionScoreRow[]; totalRiskDocs: number; totalEntityDocs: number; riskDocsMatched: number; watchlistModifierDocs: number; }; +type ResolutionScoreRow = { + resolutionKey: string; + targetEntityId: string; + score: string; + level: string; + relatedEntities: number; + calculationRunId: string; + timestamp: string; +}; type ResolutionGroupAssignment = { targetId: string; aliasIds: string[]; @@ -95,6 +107,28 @@ type RelationshipGraphState = { ownershipEdges: OwnershipEdge[]; }; +const buildResolutionKey = ({ + targetEntityId, + calculationRunId, +}: { + targetEntityId: string; + calculationRunId: string; +}): string => `${targetEntityId}#${calculationRunId || 'no-run-id'}`; + +const getRelationshipGraphStats = (graph: RelationshipGraphState) => { + const resolutionTargetCount = graph.resolutionGroups.length; + const resolutionAliasCount = graph.resolutionGroups.reduce( + (count, group) => count + group.aliasIds.length, + 0, + ); + return { + resolutionTargetCount, + resolutionAliasCount, + resolutionEdgeCount: resolutionAliasCount, + ownershipEdgeCount: graph.ownershipEdges.length, + }; +}; + const summarizeList = (items: string[], max: number = 6): string => { if (items.length === 0) return '-'; if (items.length <= max) return items.join(', '); @@ -610,6 +644,11 @@ const applyRelationshipGraph = async ({ entityIds: aliases, space, }); + if (response.linked.length < aliases.length || response.skipped.length > 0) { + log.warn( + `Resolution link validation: requested=${aliases.length}, linked=${response.linked.length}, skipped=${response.skipped.length} for target=${group.targetId}`, + ); + } log.info( `Resolution link: target=${group.targetId}, linked=${response.linked.length}, skipped=${response.skipped.length}`, ); @@ -657,6 +696,11 @@ const clearRelationshipGraph = async ({ for (const ids of chunk(entityIds, 1000)) { if (ids.length === 0) continue; const response = await unlinkResolutionEntities({ entityIds: ids, space }); + if (response.unlinked.length < ids.length || response.skipped.length > 0) { + log.warn( + `Resolution unlink validation: requested=${ids.length}, unlinked=${response.unlinked.length}, skipped=${response.skipped.length}`, + ); + } log.info( `Resolution unlink: unlinked=${response.unlinked.length}, skipped=${response.skipped.length}`, ); @@ -684,6 +728,214 @@ const clearRelationshipGraph = async ({ } }; +type RelationshipStateSnapshot = { + fetched: number; + expectedTargetCount: number; + expectedAliasCount: number; + aliasesWithResolvedTo: number; + expectedAliasMatches: number; + ownershipSources: number; + ownershipLinks: number; + expectedOwnershipEdges: number; + mismatches: string[]; + unexpectedAliases: string[]; +}; + +const collectEntityRelationshipState = async ({ + space, + entityIds, + graph, +}: { + space: string; + entityIds: string[]; + graph: RelationshipGraphState; +}): Promise => { + const uniqueEntityIds = [...new Set(entityIds)]; + if (uniqueEntityIds.length === 0) { + return { + fetched: 0, + expectedTargetCount: graph.resolutionGroups.length, + expectedAliasCount: 0, + aliasesWithResolvedTo: 0, + expectedAliasMatches: 0, + ownershipSources: 0, + ownershipLinks: 0, + expectedOwnershipEdges: graph.ownershipEdges.length, + mismatches: [], + unexpectedAliases: [], + }; + } + + const client = getEsClient(); + const entityIndex = `.entities.v2.latest.security_${space}`; + const entityResponse = await client.search({ + index: entityIndex, + size: uniqueEntityIds.length, + query: { + terms: { + 'entity.id': uniqueEntityIds, + }, + }, + _source: [ + 'entity.id', + 'entity.relationships.resolution.resolved_to', + 'entity.relationships.owns', + ], + }); + + const expectedAliasToTarget = new Map(); + for (const group of graph.resolutionGroups) { + for (const aliasId of group.aliasIds) { + expectedAliasToTarget.set(aliasId, group.targetId); + } + } + + let aliasesWithResolvedTo = 0; + let ownershipSources = 0; + let ownershipLinks = 0; + let expectedAliasMatches = 0; + const mismatches: string[] = []; + const unexpectedAliases: string[] = []; + + for (const hit of entityResponse.hits.hits) { + const source = hit._source as + | { + entity?: { + id?: string; + relationships?: { resolution?: { resolved_to?: unknown }; owns?: unknown }; + }; + } + | undefined; + const id = source?.entity?.id; + if (!id) continue; + + const resolvedTo = + normalizeWatchlists( + getFromNestedOrDotted( + source as Record | undefined, + 'entity.relationships.resolution.resolved_to', + ), + )[0] ?? '-'; + const owns = normalizeWatchlists( + getFromNestedOrDotted( + source as Record | undefined, + 'entity.relationships.owns', + ), + ); + + if (resolvedTo !== '-') { + aliasesWithResolvedTo += 1; + } + if (owns.length > 0) { + ownershipSources += 1; + ownershipLinks += owns.length; + } + + const expectedTarget = expectedAliasToTarget.get(id); + if (expectedTarget) { + if (resolvedTo === expectedTarget) { + expectedAliasMatches += 1; + } else { + mismatches.push(`${id} -> ${resolvedTo} (expected ${expectedTarget})`); + } + } else if (resolvedTo !== '-') { + unexpectedAliases.push(`${id} -> ${resolvedTo}`); + } + } + + return { + fetched: entityResponse.hits.hits.length, + expectedTargetCount: graph.resolutionGroups.length, + expectedAliasCount: expectedAliasToTarget.size, + aliasesWithResolvedTo, + expectedAliasMatches, + ownershipSources, + ownershipLinks, + expectedOwnershipEdges: graph.ownershipEdges.length, + mismatches, + unexpectedAliases, + }; +}; + +const logEntityRelationshipState = async ({ + space, + entityIds, + graph, + context, +}: { + space: string; + entityIds: string[]; + graph: RelationshipGraphState; + context: string; +}): Promise => { + const uniqueEntityIds = [...new Set(entityIds)]; + const snapshot = await collectEntityRelationshipState({ + space, + entityIds: uniqueEntityIds, + graph, + }); + log.info( + `[rel-state:${context}] fetched=${snapshot.fetched}/${uniqueEntityIds.length}, expected_targets=${snapshot.expectedTargetCount}, expected_aliases=${snapshot.expectedAliasCount}, aliases_with_resolved_to=${snapshot.aliasesWithResolvedTo}, expected_alias_matches=${snapshot.expectedAliasMatches}, ownership_sources=${snapshot.ownershipSources}, ownership_links=${snapshot.ownershipLinks}, expected_ownership_edges=${snapshot.expectedOwnershipEdges}`, + ); + if (snapshot.mismatches.length > 0) { + log.warn( + `[rel-state:${context}] resolution mismatches (${snapshot.mismatches.length}): ${summarizeList(snapshot.mismatches, 6)}`, + ); + } + if (snapshot.unexpectedAliases.length > 0) { + log.warn( + `[rel-state:${context}] unexpected alias mappings (${snapshot.unexpectedAliases.length}): ${summarizeList(snapshot.unexpectedAliases, 6)}`, + ); + } + return snapshot; +}; + +const waitForEntityRelationshipState = async ({ + space, + entityIds, + graph, + context, + timeoutMs = 15_000, +}: { + space: string; + entityIds: string[]; + graph: RelationshipGraphState; + context: string; + timeoutMs?: number; +}): Promise => { + const expectedAliasCount = graph.resolutionGroups.reduce( + (count, group) => count + group.aliasIds.length, + 0, + ); + if (expectedAliasCount === 0) { + await logEntityRelationshipState({ space, entityIds, graph, context }); + return; + } + + const deadline = Date.now() + timeoutMs; + let lastMatches = -1; + let lastResolved = -1; + while (Date.now() < deadline) { + const snapshot = await collectEntityRelationshipState({ space, entityIds, graph }); + if ( + snapshot.expectedAliasMatches !== lastMatches || + snapshot.aliasesWithResolvedTo !== lastResolved + ) { + log.info( + `[rel-sync:${context}] alias_matches=${snapshot.expectedAliasMatches}/${expectedAliasCount}, aliases_with_resolved_to=${snapshot.aliasesWithResolvedTo}`, + ); + lastMatches = snapshot.expectedAliasMatches; + lastResolved = snapshot.aliasesWithResolvedTo; + } + if (snapshot.expectedAliasMatches >= expectedAliasCount && snapshot.mismatches.length === 0) { + break; + } + await sleep(1000); + } + + await logEntityRelationshipState({ space, entityIds, graph, context }); +}; + const forceExtractExpectedEntities = async ({ space, entityKinds, @@ -865,6 +1117,7 @@ const waitForMaintainerRun = async ( space: string, maintainerId: string = 'risk-score', ): Promise<{ runs: number; taskStatus: string; settled: boolean }> => { + const settleWaitMs = 8_000; let baselineRuns: number; try { const baseline = await getEntityMaintainers(space, [maintainerId]); @@ -888,7 +1141,7 @@ const waitForMaintainerRun = async ( log.info( `Maintainer "${maintainerId}" run observed (runs=${maintainer.runs}, taskStatus=${maintainer.taskStatus}).`, ); - const settleDeadline = Date.now() + 15_000; + const settleDeadline = Date.now() + settleWaitMs; while (Date.now() < settleDeadline) { const settleResponse = await getEntityMaintainers(space, [maintainerId]); const settleMaintainer = settleResponse.maintainers.find((m) => m.id === maintainerId); @@ -897,7 +1150,7 @@ const waitForMaintainerRun = async ( `Maintainer "${maintainerId}" appears settled (taskStatus=${settleMaintainer?.taskStatus ?? 'unknown'}).`, ); return { - runs: maintainer.runs, + runs: settleMaintainer?.runs ?? maintainer.runs, taskStatus: settleMaintainer?.taskStatus ?? maintainer.taskStatus, settled: true, }; @@ -905,7 +1158,7 @@ const waitForMaintainerRun = async ( await sleep(2000); } log.warn( - `Maintainer "${maintainerId}" still reports taskStatus=started after short settle wait; continuing with summary.`, + `Maintainer "${maintainerId}" still reports taskStatus=started after ${formatDurationMs(settleWaitMs)} settle wait.`, ); return { runs: maintainer.runs, taskStatus: maintainer.taskStatus, settled: false }; } @@ -923,6 +1176,36 @@ const waitForMaintainerRun = async ( throw new Error(`Timed out waiting for maintainer "${maintainerId}" run`); }; +const waitForResolutionDocs = async ({ + space, + entityIds, + minDocs = 1, + timeoutMs = 45000, +}: { + space: string; + entityIds: string[]; + minDocs?: number; + timeoutMs?: number; +}) => { + const deadline = Date.now() + timeoutMs; + let lastCount = -1; + while (Date.now() < deadline) { + const snapshot = await collectRiskSnapshot({ space, entityIds }); + const count = snapshot.resolutionRows.length; + if (count !== lastCount) { + log.info(`Resolution doc wait: found=${count}, target>=${minDocs}`); + lastCount = count; + } + if (count >= minDocs) { + return; + } + await sleep(2000); + } + log.warn( + `Timed out waiting for resolution docs (target>=${minDocs}) after ${formatDurationMs(timeoutMs)}; continuing with current snapshot.`, + ); +}; + const createWatchlistsForRun = async (space: string) => { const suffix = Date.now(); return Promise.all([ @@ -1113,6 +1396,30 @@ const normalizeWatchlists = (value: unknown): string[] => { return []; }; +const canUseInteractivePrompts = (): boolean => + Boolean(process.stdout.isTTY && process.stdin.isTTY); + +const isPromptIoError = (error: unknown): boolean => { + const message = + error instanceof Error ? error.message : typeof error === 'string' ? error : String(error); + return message.includes('setRawMode') || message.includes('EIO'); +}; + +const getFromNestedOrDotted = ( + source: Record | undefined, + path: string, +): unknown => { + if (!source) return undefined; + if (path in source) return source[path]; + const parts = path.split('.'); + let current: unknown = source; + for (const part of parts) { + if (!current || typeof current !== 'object') return undefined; + current = (current as Record)[part]; + } + return current; +}; + const toNumericScore = (value: string): number | null => { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : null; @@ -1134,6 +1441,7 @@ const collectRiskSnapshot = async ({ if (uniqueEntityIds.length === 0) { return { rows: [], + resolutionRows: [], totalRiskDocs, totalEntityDocs, riskDocsMatched: 0, @@ -1150,7 +1458,14 @@ const collectRiskSnapshot = async ({ 'entity.id': uniqueEntityIds, }, }, - _source: ['entity.id', 'entity.type', 'entity.attributes.watchlists', 'asset.criticality'], + _source: [ + 'entity.id', + 'entity.type', + 'entity.attributes.watchlists', + 'entity.relationships.resolution.resolved_to', + 'entity.relationships.owns', + 'asset.criticality', + ], }); const entityById = new Map< @@ -1179,15 +1494,30 @@ const collectRiskSnapshot = async ({ const id = source?.entity?.id; if (!id) continue; const resolvedTo = - normalizeWatchlists(source?.entity?.relationships?.resolution?.resolved_to)[0] ?? '-'; - const owns = normalizeWatchlists(source?.entity?.relationships?.owns); + normalizeWatchlists( + getFromNestedOrDotted( + source as Record | undefined, + 'entity.relationships.resolution.resolved_to', + ), + )[0] ?? '-'; + const owns = normalizeWatchlists( + getFromNestedOrDotted( + source as Record | undefined, + 'entity.relationships.owns', + ), + ); if (resolvedTo !== '-') { aliasCountByTarget.set(resolvedTo, (aliasCountByTarget.get(resolvedTo) ?? 0) + 1); } entityById.set(id, { entityType: source.entity?.type ?? 'unknown', criticality: source.asset?.criticality ?? '-', - watchlists: normalizeWatchlists(source?.entity?.attributes?.watchlists), + watchlists: normalizeWatchlists( + getFromNestedOrDotted( + source as Record | undefined, + 'entity.attributes.watchlists', + ), + ), resolutionTarget: resolvedTo, ownershipLinks: owns.length, }); @@ -1203,22 +1533,32 @@ const collectRiskSnapshot = async ({ { terms: { 'host.name': uniqueEntityIds } }, { terms: { 'user.name': uniqueEntityIds } }, { terms: { 'service.name': uniqueEntityIds } }, + { terms: { 'host.risk.id_value': uniqueEntityIds } }, + { terms: { 'user.risk.id_value': uniqueEntityIds } }, + { terms: { 'service.risk.id_value': uniqueEntityIds } }, ], minimum_should_match: 1, }, }, _source: [ + '@timestamp', 'host.name', 'host.risk.calculated_score_norm', 'host.risk.calculated_level', + 'host.risk.calculation_run_id', + 'host.risk.id_value', 'host.risk.modifiers', 'user.name', 'user.risk.calculated_score_norm', 'user.risk.calculated_level', + 'user.risk.calculation_run_id', + 'user.risk.id_value', 'user.risk.modifiers', 'service.name', 'service.risk.calculated_score_norm', 'service.risk.calculated_level', + 'service.risk.calculation_run_id', + 'service.risk.id_value', 'service.risk.score_type', 'service.risk.related_entities', 'service.risk.modifiers', @@ -1229,6 +1569,73 @@ const collectRiskSnapshot = async ({ ], }); + const resolutionResponse = await client.search({ + index: riskIndex, + size: Math.max(100, uniqueEntityIds.length * 8), + sort: [{ '@timestamp': { order: 'desc' } }], + query: { + bool: { + should: [ + { + term: { + 'host.risk.score_type': 'resolution', + }, + }, + { + term: { + 'user.risk.score_type': 'resolution', + }, + }, + { + term: { + 'service.risk.score_type': 'resolution', + }, + }, + ], + minimum_should_match: 1, + filter: [ + { + bool: { + should: [ + { terms: { 'host.name': uniqueEntityIds } }, + { terms: { 'user.name': uniqueEntityIds } }, + { terms: { 'service.name': uniqueEntityIds } }, + { terms: { 'host.risk.id_value': uniqueEntityIds } }, + { terms: { 'user.risk.id_value': uniqueEntityIds } }, + { terms: { 'service.risk.id_value': uniqueEntityIds } }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + _source: [ + '@timestamp', + 'host.name', + 'host.risk.id_value', + 'host.risk.calculated_score_norm', + 'host.risk.calculated_level', + 'host.risk.score_type', + 'host.risk.related_entities', + 'host.risk.calculation_run_id', + 'user.name', + 'user.risk.id_value', + 'user.risk.calculated_score_norm', + 'user.risk.calculated_level', + 'user.risk.score_type', + 'user.risk.related_entities', + 'user.risk.calculation_run_id', + 'service.name', + 'service.risk.id_value', + 'service.risk.calculated_score_norm', + 'service.risk.calculated_level', + 'service.risk.score_type', + 'service.risk.related_entities', + 'service.risk.calculation_run_id', + ], + }); + const riskDocs = riskResponse.hits.hits.map((hit) => hit._source as Record); const watchlistModifierDocs = riskDocs.filter((doc) => { const risk = ((doc.user as Record)?.risk ?? @@ -1242,48 +1649,64 @@ const collectRiskSnapshot = async ({ string, { score: string; level: string; scoreType: string; relatedEntities: number } >(); + const resolutionRowsByKey = new Map(); for (const hit of riskResponse.hits.hits) { const source = hit._source as | { + '@timestamp'?: string; host?: { name?: string; risk?: { + id_value?: string; calculated_score_norm?: number; calculated_level?: string; score_type?: string; related_entities?: unknown[]; + calculation_run_id?: string; }; }; user?: { name?: string; risk?: { + id_value?: string; calculated_score_norm?: number; calculated_level?: string; score_type?: string; related_entities?: unknown[]; + calculation_run_id?: string; }; }; service?: { name?: string; risk?: { + id_value?: string; calculated_score_norm?: number; calculated_level?: string; score_type?: string; related_entities?: unknown[]; + calculation_run_id?: string; }; }; } | undefined; - const id = source?.host?.name ?? source?.user?.name ?? source?.service?.name; + const id = + source?.host?.name ?? + source?.user?.name ?? + source?.service?.name ?? + source?.host?.risk?.id_value ?? + source?.user?.risk?.id_value ?? + source?.service?.risk?.id_value; const risk = source?.host?.risk ?? source?.user?.risk ?? source?.service?.risk; - if (!id || !risk || riskById.has(id)) continue; + if (!id || !risk) continue; + const scoreType = typeof risk.score_type === 'string' ? risk.score_type : '-'; + if (riskById.has(id)) continue; riskById.set(id, { score: typeof risk.calculated_score_norm === 'number' ? risk.calculated_score_norm.toFixed(2) : '-', level: risk.calculated_level ?? '-', - scoreType: typeof risk.score_type === 'string' ? risk.score_type : '-', + scoreType, relatedEntities: Array.isArray(risk.related_entities) ? risk.related_entities.length : 0, }); } @@ -1305,9 +1728,167 @@ const collectRiskSnapshot = async ({ totalEntityDocs, riskDocsMatched: riskById.size, watchlistModifierDocs, + resolutionRows: (() => { + for (const hit of resolutionResponse.hits.hits) { + const source = hit._source as + | { + '@timestamp'?: string; + host?: { + name?: string; + risk?: { + id_value?: string; + calculated_score_norm?: number; + calculated_level?: string; + score_type?: string; + related_entities?: unknown[]; + calculation_run_id?: string; + }; + }; + user?: { + name?: string; + risk?: { + id_value?: string; + calculated_score_norm?: number; + calculated_level?: string; + score_type?: string; + related_entities?: unknown[]; + calculation_run_id?: string; + }; + }; + service?: { + name?: string; + risk?: { + id_value?: string; + calculated_score_norm?: number; + calculated_level?: string; + score_type?: string; + related_entities?: unknown[]; + calculation_run_id?: string; + }; + }; + } + | undefined; + const risk = source?.host?.risk ?? source?.user?.risk ?? source?.service?.risk; + if (!risk || risk.score_type !== 'resolution') continue; + const id = + source?.host?.name ?? + source?.user?.name ?? + source?.service?.name ?? + source?.host?.risk?.id_value ?? + source?.user?.risk?.id_value ?? + source?.service?.risk?.id_value; + if (!id) continue; + const calculationRunId = + typeof risk.calculation_run_id === 'string' ? risk.calculation_run_id : '-'; + const resolutionKey = buildResolutionKey({ targetEntityId: id, calculationRunId }); + if (resolutionRowsByKey.has(resolutionKey)) continue; + resolutionRowsByKey.set(resolutionKey, { + resolutionKey, + targetEntityId: id, + score: + typeof risk.calculated_score_norm === 'number' + ? risk.calculated_score_norm.toFixed(2) + : '-', + level: risk.calculated_level ?? '-', + relatedEntities: Array.isArray(risk.related_entities) ? risk.related_entities.length : 0, + calculationRunId, + timestamp: typeof source?.['@timestamp'] === 'string' ? source['@timestamp'] : '-', + }); + } + return [...resolutionRowsByKey.values()]; + })(), }; }; +const logResolutionReadDiagnostics = async ({ + space, + entityIds, + context, +}: { + space: string; + entityIds: string[]; + context: string; +}): Promise => { + const uniqueEntityIds = [...new Set(entityIds)]; + if (uniqueEntityIds.length === 0) return; + + const client = getEsClient(); + const riskIndex = `risk-score.risk-score-${space}`; + const response = await client.search({ + index: riskIndex, + size: 8, + sort: [{ '@timestamp': { order: 'desc' } }], + query: { + bool: { + should: [ + { term: { 'host.risk.score_type': 'resolution' } }, + { term: { 'user.risk.score_type': 'resolution' } }, + { term: { 'service.risk.score_type': 'resolution' } }, + ], + minimum_should_match: 1, + filter: [ + { + bool: { + should: [ + { terms: { 'host.name': uniqueEntityIds } }, + { terms: { 'user.name': uniqueEntityIds } }, + { terms: { 'service.name': uniqueEntityIds } }, + { terms: { 'host.risk.id_value': uniqueEntityIds } }, + { terms: { 'user.risk.id_value': uniqueEntityIds } }, + { terms: { 'service.risk.id_value': uniqueEntityIds } }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + _source: [ + '@timestamp', + 'host.name', + 'host.risk.id_value', + 'host.risk.score_type', + 'user.name', + 'user.risk.id_value', + 'user.risk.score_type', + 'service.name', + 'service.risk.id_value', + 'service.risk.score_type', + ], + }); + + const hits = response.hits.hits; + log.warn( + `[debug:${context}] resolution-read diagnostics: matched_hits=${hits.length}, total=${response.hits.total && typeof response.hits.total === 'object' ? response.hits.total.value : 'n/a'}`, + ); + for (const [index, hit] of hits.entries()) { + const source = hit._source as + | { + '@timestamp'?: string; + host?: { name?: string; risk?: { id_value?: string; score_type?: string } }; + user?: { name?: string; risk?: { id_value?: string; score_type?: string } }; + service?: { name?: string; risk?: { id_value?: string; score_type?: string } }; + } + | undefined; + const id = + source?.host?.name ?? + source?.user?.name ?? + source?.service?.name ?? + source?.host?.risk?.id_value ?? + source?.user?.risk?.id_value ?? + source?.service?.risk?.id_value ?? + '-'; + const scoreType = + source?.host?.risk?.score_type ?? + source?.user?.risk?.score_type ?? + source?.service?.risk?.score_type ?? + '-'; + log.warn( + `[debug:${context}] hit_${index + 1}: id=${id}, score_type=${scoreType}, ts=${source?.['@timestamp'] ?? '-'}`, + ); + } +}; + const printRiskRows = async ({ rows, riskDocsMatched, @@ -1351,7 +1932,7 @@ const printRiskRows = async ({ console.log(line); }; - if (!process.stdout.isTTY || rows.length <= pageSize) { + if (!canUseInteractivePrompts() || rows.length <= pageSize) { printLine(header); printLine(separator); for (const row of rows) { @@ -1414,14 +1995,20 @@ const printRiskRows = async ({ : canPrev ? '[p] previous, [q] continue' : '[q] continue'; - const nav = ( - await input({ + let navRaw: string; + try { + navRaw = await input({ message: `Table navigation: ${navHint}`, default: 'q', - }) - ) - .trim() - .toLowerCase(); + }); + } catch (error) { + if (isPromptIoError(error)) { + log.warn(`Interactive table navigation unavailable (${error}). Continuing.`); + break; + } + throw error; + } + const nav = navRaw.trim().toLowerCase(); if (nav === 'n' && canNext) { page += 1; @@ -1438,6 +2025,163 @@ const printRiskRows = async ({ } }; +const printResolutionRows = async ({ + rows, + pageSize = 20, +}: { + rows: ResolutionScoreRow[]; + pageSize?: number; +}): Promise => { + if (rows.length === 0) { + log.info('Resolution scorecard: no resolution docs found for tracked entities.'); + return; + } + + const keyWidth = 52; + const idxWidth = 4; + const targetWidth = 48; + const scoreWidth = 7; + const levelWidth = 9; + const relWidth = 6; + const runWidth = 18; + const tsWidth = 24; + + const header = [ + formatCell('#', idxWidth), + formatCell('Resolution Key', keyWidth), + formatCell('Target Entity', targetWidth), + formatCell('Score', scoreWidth), + formatCell('Level', levelWidth), + formatCell('Rel', relWidth), + formatCell('Run ID', runWidth), + formatCell('Timestamp', tsWidth), + ].join(' | '); + const separator = `${'-'.repeat(idxWidth)}-+-${'-'.repeat(keyWidth)}-+-${'-'.repeat(targetWidth)}-+-${'-'.repeat(scoreWidth)}-+-${'-'.repeat(levelWidth)}-+-${'-'.repeat(relWidth)}-+-${'-'.repeat(runWidth)}-+-${'-'.repeat(tsWidth)}`; + + // eslint-disable-next-line no-console + console.log(colorize(`🧩 Resolution scorecard (${rows.length} rows)`, 'cyan')); + const printLine = (line: string) => { + // eslint-disable-next-line no-console + console.log(line); + }; + + if (!canUseInteractivePrompts() || rows.length <= pageSize) { + printLine(header); + printLine(separator); + for (const [index, row] of rows.entries()) { + const levelCell = formatCell(row.level, levelWidth); + printLine( + [ + formatCell(String(index + 1), idxWidth), + formatCell(row.resolutionKey, keyWidth), + formatCell(row.targetEntityId, targetWidth), + formatCell(row.score, scoreWidth), + colorizeRiskLevel(levelCell, row.level), + formatCell(String(row.relatedEntities), relWidth), + formatCell(row.calculationRunId, runWidth), + formatCell(row.timestamp, tsWidth), + ].join(' | '), + ); + } + log.info('Tip: use [j] with a row number, full target ID, or ID prefix.'); + return; + } + + let page = 0; + const totalPages = Math.ceil(rows.length / pageSize); + while (true) { + const start = page * pageSize; + const end = Math.min(start + pageSize, rows.length); + const pageRows = rows.slice(start, end); + + printLine(header); + printLine(separator); + for (const [offset, row] of pageRows.entries()) { + const index = start + offset; + const levelCell = formatCell(row.level, levelWidth); + printLine( + [ + formatCell(String(index + 1), idxWidth), + formatCell(row.resolutionKey, keyWidth), + formatCell(row.targetEntityId, targetWidth), + formatCell(row.score, scoreWidth), + colorizeRiskLevel(levelCell, row.level), + formatCell(String(row.relatedEntities), relWidth), + formatCell(row.calculationRunId, runWidth), + formatCell(row.timestamp, tsWidth), + ].join(' | '), + ); + } + log.info('Tip: use [j] with a row number, full target ID, or ID prefix.'); + + const canNext = page < totalPages - 1; + const canPrev = page > 0; + const navHint = + canPrev && canNext + ? '[n] next, [p] previous, [q] continue' + : canNext + ? '[n] next, [q] continue' + : canPrev + ? '[p] previous, [q] continue' + : '[q] continue'; + let navRaw: string; + try { + navRaw = await input({ + message: `Resolution table navigation: ${navHint}`, + default: 'q', + }); + } catch (error) { + if (isPromptIoError(error)) { + log.warn(`Interactive resolution table navigation unavailable (${error}). Continuing.`); + break; + } + throw error; + } + const nav = navRaw.trim().toLowerCase(); + if (nav === 'n' && canNext) { + page += 1; + continue; + } + if (nav === 'p' && canPrev) { + page -= 1; + continue; + } + if (nav === 'q' || nav === '') { + break; + } + } +}; + +const resolveResolutionTargetFromInput = ({ + inputValue, + rows, +}: { + inputValue: string; + rows: ResolutionScoreRow[]; +}): string | null => { + const value = inputValue.trim(); + if (!value) return null; + + const numeric = Number.parseInt(value, 10); + if (Number.isFinite(numeric) && String(numeric) === value) { + const row = rows[numeric - 1]; + return row?.targetEntityId ?? null; + } + + const exactByTarget = rows.find((row) => row.targetEntityId === value); + if (exactByTarget) return exactByTarget.targetEntityId; + + const exactByKey = rows.find((row) => row.resolutionKey === value); + if (exactByKey) return exactByKey.targetEntityId; + + const prefixMatches = rows.filter( + (row) => row.targetEntityId.startsWith(value) || row.resolutionKey.startsWith(value), + ); + if (prefixMatches.length === 1) return prefixMatches[0].targetEntityId; + + return null; +}; + const printSnapshotResult = ( snapshot: RiskSnapshot, ): { missingRiskDocs: number; totalEntities: number } => { @@ -1481,6 +2225,8 @@ const reportRiskSummary = async ({ expectedRiskDelta, entityIds, pageSize, + expectedResolutionTargets = 0, + debugResolution = false, }: { space: string; baselineRiskScoreCount: number; @@ -1488,6 +2234,8 @@ const reportRiskSummary = async ({ expectedRiskDelta: number; entityIds: string[]; pageSize: number; + expectedResolutionTargets?: number; + debugResolution?: boolean; }): Promise<{ missingRiskDocs: number; totalEntities: number; snapshot: RiskSnapshot }> => { const snapshot = await collectRiskSnapshot({ space, entityIds }); const riskDelta = Math.max(0, snapshot.totalRiskDocs - baselineRiskScoreCount); @@ -1506,7 +2254,20 @@ const reportRiskSummary = async ({ } log.info(`Docs with watchlist modifiers: ${snapshot.watchlistModifierDocs}`); + if (expectedResolutionTargets > 0 && snapshot.resolutionRows.length === 0) { + log.warn( + `Resolution warning: expected resolution targets=${expectedResolutionTargets} but found no resolution docs in summary.`, + ); + if (debugResolution) { + await logResolutionReadDiagnostics({ + space, + entityIds, + context: 'initial_summary', + }); + } + } await printRiskRows({ rows: snapshot.rows, riskDocsMatched: snapshot.riskDocsMatched, pageSize }); + await printResolutionRows({ rows: snapshot.resolutionRows, pageSize }); const result = printSnapshotResult(snapshot); return { ...result, snapshot }; }; @@ -1690,6 +2451,16 @@ const getRiskScoreDocCount = async (space: string): Promise => { } }; +const refreshRiskScoreIndex = async (space: string): Promise => { + const client = getEsClient(); + const index = `risk-score.risk-score-${space}`; + try { + await client.indices.refresh({ index, ignore_unavailable: true }); + } catch { + // Ignore refresh issues in test harness; caller will still attempt reads. + } +}; + const getEntityStoreDocCount = async (space: string): Promise => { const client = getEsClient(); const index = `.entities.v2.latest.security_${space}`; @@ -1701,6 +2472,47 @@ const getEntityStoreDocCount = async (space: string): Promise => { } }; +const deleteAllDocsFromIndex = async (index: string, label: string): Promise => { + const client = getEsClient(); + try { + const response = await client.deleteByQuery({ + index, + refresh: true, + ignore_unavailable: true, + conflicts: 'proceed', + query: { match_all: {} }, + }); + const deleted = response.deleted ?? 0; + log.info(`Dangerous clean: deleted ${deleted} ${label} docs from "${index}".`); + return deleted; + } catch (error) { + const statusCode = (error as { meta?: { statusCode?: number } }).meta?.statusCode; + if (statusCode === 404) { + log.info(`Dangerous clean: index "${index}" not found for ${label}; nothing to delete.`); + return 0; + } + throw error; + } +}; + +const dangerousCleanSpaceData = async (space: string): Promise => { + const alertIndex = getAlertIndex(space); + const entityLatestIndex = `.entities.v2.latest.security_${space}`; + const entityHistoryIndex = `.entities.v2.history.security_${space}`; + const riskIndex = `risk-score.risk-score-${space}`; + const riskLookupIndex = `.entity_analytics.risk_score.lookup-${space}`; + + log.warn( + `Dangerous clean enabled for space "${space}". Clearing alerts, entity docs, and risk score docs before test run.`, + ); + + await deleteAllDocsFromIndex(alertIndex, 'alert'); + await deleteAllDocsFromIndex(entityLatestIndex, 'entity-latest'); + await deleteAllDocsFromIndex(entityHistoryIndex, 'entity-history'); + await deleteAllDocsFromIndex(riskIndex, 'risk-score'); + await deleteAllDocsFromIndex(riskLookupIndex, 'risk-score-lookup'); +}; + const getPresentEntityIds = async (space: string, entityIds: string[]): Promise> => { if (entityIds.length === 0) { return new Set(); @@ -1942,7 +2754,9 @@ const runRiskMaintainerOnce = async ({ }) => runTimedStage(stage, async () => { await initEntityMaintainers(space); - return waitForMaintainerRun(space, 'risk-score'); + const outcome = await waitForMaintainerRun(space, 'risk-score'); + await refreshRiskScoreIndex(space); + return outcome; }); const countSeededAlertsByEntityKind = async ({ @@ -2000,6 +2814,7 @@ type FollowOnAction = | 'add_more_entities' | 'tweak_single_entity' | 'view_single_risk_doc' + | 'explain_resolution' | 'export_risk_docs' | 'refresh_table' | 'run_maintainer_and_refresh' @@ -2280,6 +3095,7 @@ const promptFollowOnAction = async ({ formatFollowOnOption('e', 'expand entities (add more users/hosts/local-users/services)'), formatFollowOnOption('t', 'tweak single entity (criticality/watchlists/reset/add alerts)'), formatFollowOnOption('v', 'view single risk-score doc(s)'), + formatFollowOnOption('j', 'explain resolution score for one target'), formatFollowOnOption('x', 'export risk-score docs to file'), formatFollowOnOption('f', 'refresh table (no data changes)'), formatFollowOnOption('u', 'run maintainer and refresh table'), @@ -2340,6 +3156,10 @@ const promptFollowOnAction = async ({ log.info('Selected [v] view single risk-score doc(s).'); return 'view_single_risk_doc'; } + if (answer === 'j') { + log.info('Selected [j] explain resolution score.'); + return 'explain_resolution'; + } if (answer === 'x') { log.info('Selected [x] export risk-score docs to file.'); return 'export_risk_docs'; @@ -2382,7 +3202,7 @@ const promptFollowOnAction = async ({ } log.warn( - `Invalid option "${answer}". Please enter one of: r, p, m, a, e, t, v, x, f, u, g, l, k, o, c, d, q.`, + `Invalid option "${answer}". Please enter one of: r, p, m, a, e, t, v, j, x, f, u, g, l, k, o, c, d, q.`, ); } }; @@ -2410,6 +3230,7 @@ const runFollowOnActionLoop = async ({ avgAliasesPerTarget, ownershipEdgeRate, relationshipGraph, + debugResolution, runTimedStage, }: { space: string; @@ -2434,6 +3255,7 @@ const runFollowOnActionLoop = async ({ avgAliasesPerTarget: number; ownershipEdgeRate: number; relationshipGraph: RelationshipGraphState; + debugResolution: boolean; runTimedStage: (stage: string, fn: () => Promise) => Promise; }) => { let trackedUsers = [...users]; @@ -2657,6 +3479,14 @@ const runFollowOnActionLoop = async ({ }); await applyRelationshipGraph({ graph: expandedGraph, space }); trackedRelationshipGraph = expandedGraph; + if (debugResolution) { + await waitForEntityRelationshipState({ + space, + entityIds: [...trackedEntityIds, ...expectedNewEntityIds], + graph: trackedRelationshipGraph, + context: 'follow_on_expand_apply', + }); + } }); } @@ -2867,6 +3697,33 @@ const runFollowOnActionLoop = async ({ }); } }); + trackedRelationshipGraph.resolutionGroups = trackedRelationshipGraph.resolutionGroups + .map((group) => ({ + targetId: group.targetId, + aliasIds: group.aliasIds.filter((aliasId) => aliasId !== selection.euid), + })) + .filter((group) => group.aliasIds.length > 0); + if (targetId && targetId !== selection.euid) { + const existingGroup = trackedRelationshipGraph.resolutionGroups.find( + (group) => group.targetId === targetId, + ); + if (existingGroup) { + existingGroup.aliasIds = [...new Set([...existingGroup.aliasIds, selection.euid])]; + } else { + trackedRelationshipGraph.resolutionGroups.push({ + targetId, + aliasIds: [selection.euid], + }); + } + } + if (debugResolution) { + await waitForEntityRelationshipState({ + space, + entityIds: trackedEntityIds, + graph: trackedRelationshipGraph, + context: 'follow_on_tweak_resolution', + }); + } const maintainerOutcome = await runRiskMaintainerOnce({ space, runTimedStage, @@ -2904,6 +3761,24 @@ const runFollowOnActionLoop = async ({ space, }); }); + trackedRelationshipGraph.ownershipEdges = + trackedRelationshipGraph.ownershipEdges.filter( + (edge) => edge.sourceId !== selection.euid, + ); + if (targetId) { + trackedRelationshipGraph.ownershipEdges.push({ + sourceId: selection.euid, + targetId, + }); + } + if (debugResolution) { + await waitForEntityRelationshipState({ + space, + entityIds: trackedEntityIds, + graph: trackedRelationshipGraph, + context: 'follow_on_tweak_ownership', + }); + } const maintainerOutcome = await runRiskMaintainerOnce({ space, runTimedStage, @@ -2960,6 +3835,73 @@ const runFollowOnActionLoop = async ({ } } } + } else if (action === 'explain_resolution') { + const resolutionRowsForSelection = before.resolutionRows; + const targetIdInput = await input({ + message: 'Resolution target (row #, full ID, or prefix)', + default: + resolutionRowsForSelection.length > 0 + ? resolutionRowsForSelection[0].targetEntityId + : (trackedEntityIds[0] ?? ''), + }); + const targetId = + resolveResolutionTargetFromInput({ + inputValue: targetIdInput, + rows: resolutionRowsForSelection, + }) ?? targetIdInput.trim(); + if (!targetId) { + log.warn('No target entity ID provided.'); + } else { + const docsByEntity = await fetchRiskDocsForEntityIds({ + space, + entityIds: [targetId], + maxDocsPerEntity: 20, + }); + const resolutionDocs = (docsByEntity.get(targetId) ?? []).filter( + (doc) => doc.scoreType === 'resolution', + ); + if (resolutionDocs.length === 0) { + log.warn(`No resolution score docs found for "${targetId}".`); + if (resolutionRowsForSelection.length > 0) { + log.info( + `Try one of the visible row numbers (1-${resolutionRowsForSelection.length}) from the resolution scorecard.`, + ); + } + } else { + const latest = resolutionDocs[0]; + const riskSource = ((latest.source.user as Record)?.risk ?? + (latest.source.host as Record)?.risk ?? + (latest.source.service as Record)?.risk ?? + {}) as Record; + const relatedEntities = Array.isArray(riskSource.related_entities) + ? (riskSource.related_entities as unknown[]) + : []; + const relatedIds = relatedEntities + .map((item) => { + if (typeof item === 'string') return item; + if (item && typeof item === 'object') { + const rec = item as Record; + if (typeof rec.id === 'string') return rec.id; + if (typeof rec.entity_id === 'string') return rec.entity_id; + if (typeof rec.name === 'string') return rec.name; + } + return null; + }) + .filter((item): item is string => item !== null); + const resolutionKey = buildResolutionKey({ + targetEntityId: targetId, + calculationRunId: latest.calculationRunId, + }); + // eslint-disable-next-line no-console + console.log(colorize(`🧠 Resolution explain for ${targetId}`, 'cyan')); + // eslint-disable-next-line no-console + console.log( + ` key=${resolutionKey} score=${latest.score ?? '-'} level=${latest.level} run_id=${latest.calculationRunId} related_count=${relatedEntities.length}`, + ); + // eslint-disable-next-line no-console + console.log(` related_entities: ${summarizeList(relatedIds, 12)}`); + } + } } else if (action === 'export_risk_docs') { const scopeRaw = await input({ message: 'Export scope: [t] tracked entities, [c] changed entities', @@ -3005,6 +3947,13 @@ const runFollowOnActionLoop = async ({ score: doc.score, level: doc.level, score_type: doc.scoreType, + resolution_key: + doc.scoreType === 'resolution' + ? buildResolutionKey({ + targetEntityId: entityId, + calculationRunId: doc.calculationRunId, + }) + : null, calculation_run_id: doc.calculationRunId, source: doc.source, })), @@ -3021,6 +3970,13 @@ const runFollowOnActionLoop = async ({ resolution_aliases: row.resolutionAliases, ownership_links: row.ownershipLinks, related_entities: row.relatedEntities, + resolution_key: + record.score_type === 'resolution' + ? buildResolutionKey({ + targetEntityId: record.entity_id, + calculationRunId: record.calculation_run_id, + }) + : null, }, }); } @@ -3107,6 +4063,14 @@ const runFollowOnActionLoop = async ({ } }); trackedRelationshipGraph.resolutionGroups.push({ targetId, aliasIds }); + if (debugResolution) { + await waitForEntityRelationshipState({ + space, + entityIds: trackedEntityIds, + graph: trackedRelationshipGraph, + context: 'follow_on_link_aliases', + }); + } const maintainerOutcome = await runRiskMaintainerOnce({ space, runTimedStage, @@ -3150,6 +4114,14 @@ const runFollowOnActionLoop = async ({ aliasIds: group.aliasIds.filter((aliasId) => !entityIdsToUnlink.includes(aliasId)), }), ); + if (debugResolution) { + await waitForEntityRelationshipState({ + space, + entityIds: trackedEntityIds, + graph: trackedRelationshipGraph, + context: 'follow_on_unlink_entities', + }); + } const maintainerOutcome = await runRiskMaintainerOnce({ space, runTimedStage, @@ -3202,6 +4174,14 @@ const runFollowOnActionLoop = async ({ (edge) => edge.sourceId !== sourceId, ); trackedRelationshipGraph.ownershipEdges.push({ sourceId, targetId }); + if (debugResolution) { + await waitForEntityRelationshipState({ + space, + entityIds: trackedEntityIds, + graph: trackedRelationshipGraph, + context: 'follow_on_ownership_mutate', + }); + } const maintainerOutcome = await runRiskMaintainerOnce({ space, runTimedStage, @@ -3220,6 +4200,14 @@ const runFollowOnActionLoop = async ({ clearRelationshipGraph({ entityIds: trackedEntityIds, space }), ); trackedRelationshipGraph = { resolutionGroups: [], ownershipEdges: [] }; + if (debugResolution) { + await waitForEntityRelationshipState({ + space, + entityIds: trackedEntityIds, + graph: trackedRelationshipGraph, + context: 'follow_on_clear_relationships', + }); + } const maintainerOutcome = await runRiskMaintainerOnce({ space, runTimedStage, @@ -3246,6 +4234,14 @@ const runFollowOnActionLoop = async ({ await applyRelationshipGraph({ graph: rebuiltGraph, space }); }); trackedRelationshipGraph = rebuiltGraph; + if (debugResolution) { + await waitForEntityRelationshipState({ + space, + entityIds: trackedEntityIds, + graph: trackedRelationshipGraph, + context: 'follow_on_reapply_relationships', + }); + } const maintainerOutcome = await runRiskMaintainerOnce({ space, runTimedStage, @@ -3258,6 +4254,28 @@ const runFollowOnActionLoop = async ({ } const after = await collectRiskSnapshot({ space, entityIds: trackedEntityIds }); + const trackedGraphStats = getRelationshipGraphStats(trackedRelationshipGraph); + if (phase2Enabled && trackedGraphStats.resolutionEdgeCount === 0) { + log.warn( + 'Resolution scoring will be empty because current graph has zero resolution edges; use [d] reapply relationships.', + ); + } + if ( + phase2Enabled && + trackedGraphStats.resolutionTargetCount > 0 && + after.resolutionRows.length === 0 + ) { + log.warn( + `Resolution warning: graph has ${trackedGraphStats.resolutionTargetCount} resolution targets but summary found no resolution docs. If maintainer just ran, wait and press [u], or use [d] to reapply.`, + ); + if (debugResolution) { + await logResolutionReadDiagnostics({ + space, + entityIds: trackedEntityIds, + context: `follow_on_${action}`, + }); + } + } if (action === 'reset_to_zero') { const alertCounts = await countSeededAlertsByEntityKind({ space, @@ -3283,6 +4301,7 @@ const runFollowOnActionLoop = async ({ } lastChangedEntityIds = printBeforeAfterComparison({ actionTitle: action, before, after }); await printRiskRows({ rows: after.rows, riskDocsMatched: after.riskDocsMatched, pageSize }); + await printResolutionRows({ rows: after.resolutionRows, pageSize }); printSnapshotResult(after); } }; @@ -3343,10 +4362,12 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { Math.max(0, Number.parseFloat(options.ownershipEdgeRate ?? '0.3')), ); const pageSize = Math.max(10, parseOptionInt(options.tablePageSize, phase2Enabled ? 30 : 20)); - const followOnEnabled = options.followOn ?? process.stdout.isTTY; + const dangerousCleanEnabled = Boolean(options.dangerousClean); + const debugResolutionEnabled = Boolean(options.debugResolution); + const followOnEnabled = options.followOn ?? canUseInteractivePrompts(); log.info( - `Starting risk-score-v2 in space "${space}" with seedSource=${seedSource}, kinds=${entityKinds.join(',')}, idp_users=${usersCount}, local_users=${localUsersCount}, hosts=${hostsCount}, services=${servicesCount}, alertsPerEntity=${alertsPerEntity}, eventIndex=${eventIndex}, phase2=${phase2Enabled}, resolution=${resolutionEnabled}, propagation=${propagationEnabled}`, + `Starting risk-score-v2 in space "${space}" with seedSource=${seedSource}, kinds=${entityKinds.join(',')}, idp_users=${usersCount}, local_users=${localUsersCount}, hosts=${hostsCount}, services=${servicesCount}, alertsPerEntity=${alertsPerEntity}, eventIndex=${eventIndex}, phase2=${phase2Enabled}, resolution=${resolutionEnabled}, propagation=${propagationEnabled}, dangerous_clean=${dangerousCleanEnabled}`, ); if (options.setup !== false) { @@ -3357,6 +4378,12 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { }); } + if (dangerousCleanEnabled) { + await runTimedStage('dangerous_clean', async () => { + await dangerousCleanSpaceData(space); + }); + } + const baselineEntityCount = await getEntityStoreDocCount(space); const baselineRiskScoreCount = await getRiskScoreDocCount(space); log.info( @@ -3439,7 +4466,24 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { return; } await applyRelationshipGraph({ graph: relationshipGraph, space }); + if (debugResolutionEnabled) { + await waitForEntityRelationshipState({ + space, + entityIds: uniqueEntityIds, + graph: relationshipGraph, + context: 'initial_apply', + }); + } }); + const graphStats = getRelationshipGraphStats(relationshipGraph); + log.info( + `Pre-run graph summary: resolution_targets=${graphStats.resolutionTargetCount}, resolution_aliases=${graphStats.resolutionAliasCount}, resolution_edges=${graphStats.resolutionEdgeCount}, ownership_edges=${graphStats.ownershipEdgeCount}`, + ); + if (graphStats.resolutionEdgeCount === 0) { + log.warn( + 'Resolution scoring will be empty because resolution edge count is zero; use [d] reapply relationships.', + ); + } } let watchlistIds: string[] = []; @@ -3490,6 +4534,17 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { log.info( `Maintainer outcome: runs=${maintainerOutcome.runs}, taskStatus=${maintainerOutcome.taskStatus}, settled=${maintainerOutcome.settled ? 'yes' : 'no'}.`, ); + const graphStats = getRelationshipGraphStats(relationshipGraph); + if (phase2Enabled && graphStats.resolutionTargetCount > 0) { + await runTimedStage('wait_for_resolution_docs', async () => + waitForResolutionDocs({ + space, + entityIds: allEntityIds, + minDocs: 1, + timeoutMs: 60_000, + }), + ); + } log.info( 'Maintainer run requested once. Collecting risk summary directly (without strict risk-score count gating).', ); @@ -3501,9 +4556,11 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { expectedRiskDelta: Math.max(1, expectedNewEntityIds.length), entityIds: allEntityIds, pageSize, + expectedResolutionTargets: graphStats.resolutionTargetCount, + debugResolution: debugResolutionEnabled, }), ); - if (followOnEnabled && process.stdout.isTTY) { + if (followOnEnabled && canUseInteractivePrompts()) { await runFollowOnActionLoop({ space, entityIds: allEntityIds, @@ -3527,9 +4584,10 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { avgAliasesPerTarget, ownershipEdgeRate, relationshipGraph, + debugResolution: debugResolutionEnabled, runTimedStage, }); - } else if (followOnEnabled && !process.stdout.isTTY) { + } else if (followOnEnabled && !canUseInteractivePrompts()) { log.info( 'Follow-on actions requested, but output is non-interactive (non-TTY). Skipping menu.', ); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index ad64e6d9..6b2be5e5 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -32,7 +32,8 @@ function shouldLog(level: Exclude): boolean { function formatPrefix(level: Exclude): string { const { color, label } = LEVEL_STYLES[level]; - return `${color}[${label}]${COLORS.reset}`; + const timestamp = new Date().toISOString(); + return `${color}[${timestamp}][${label}]${COLORS.reset}`; } function formatArg(arg: unknown): string { From 1a5bef84eabbe5537cddbddca3a9e9acdc9a8ad8 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 1 Apr 2026 20:51:51 +0100 Subject: [PATCH 13/15] Enhance risk-score-v2 resolution visibility and graph readability. Surface resolution criticality/watchlist/alert metadata from risk documents and render a tree-style graph that makes base, resolved, and ownership score contributions easier to validate. Made-with: Cursor --- src/commands/entity_store/risk_score_v2.ts | 227 +++++++++++++++++++-- 1 file changed, 212 insertions(+), 15 deletions(-) diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index 277ad3ee..e07f133a 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -70,6 +70,7 @@ type RiskSummaryRow = { score: string; level: string; scoreType: string; + alertsCount: number; criticality: string; watchlistsCount: number; resolutionTarget: string; @@ -91,6 +92,9 @@ type ResolutionScoreRow = { score: string; level: string; relatedEntities: number; + resolvedAlertsCount: number; + resolvedCriticality: string; + resolvedWatchlistsCount: number; calculationRunId: string; timestamp: string; }; @@ -137,9 +141,31 @@ const summarizeList = (items: string[], max: number = 6): string => { const printGraphSummaryViews = ({ graph, + scoreByEntityId, + resolutionScoreByEntityId, maxRows = 20, }: { graph: RelationshipGraphState; + scoreByEntityId?: Map< + string, + { + score: string; + level: string; + alertsCount: number; + criticality: string; + watchlistsCount: number; + } + >; + resolutionScoreByEntityId?: Map< + string, + { + score: string; + level: string; + resolvedAlertsCount: number; + criticality: string; + watchlistsCount: number; + } + >; maxRows?: number; }) => { const token = (text: string, color: 'green' | 'yellow' | 'cyan') => { @@ -151,11 +177,64 @@ const printGraphSummaryViews = ({ const ownsLabel = token('owns', 'green'); const leftArrow = token('<-', 'yellow'); const rightArrow = token('->', 'green'); - const contributesArrow = token('<=', 'cyan'); + const upRightArrow = token('⤴', 'cyan'); const plusToken = token('+', 'cyan'); + const printTreeItems = ({ items, indent }: { items: string[]; indent: string }) => { + if (items.length === 0) { + // eslint-disable-next-line no-console + console.log(`${indent}└─ -`); + return; + } + for (const [index, item] of items.entries()) { + const branch = index === items.length - 1 ? '└─' : '├─'; + // eslint-disable-next-line no-console + console.log(`${indent}${branch} ${item}`); + } + }; + const shortenCriticality = (criticality: string): string => { + const normalized = criticality.trim().toLowerCase(); + if (!normalized || normalized === '-') return '-'; + if (normalized === 'extreme_impact') return 'extreme'; + if (normalized === 'high_impact') return 'high'; + if (normalized === 'medium_impact') return 'medium'; + if (normalized === 'low_impact') return 'low'; + return normalized; + }; + const formatBaseTag = (entityId: string): string => { + const base = scoreByEntityId?.get(entityId); + const hasBase = Boolean(base && base.score !== '-' && base.level !== '-'); + if (!hasBase) return ''; + return colorizeRiskLevel( + `[${base?.score}/${base?.level} alerts:${base?.alertsCount ?? 0} crit:${shortenCriticality(base?.criticality ?? '-')} wlists:${base?.watchlistsCount ?? 0}]`, + base?.level ?? '-', + ); + }; + const formatResolvedTag = (entityId: string): string => { + const resolved = resolutionScoreByEntityId?.get(entityId); + const hasResolved = Boolean(resolved && resolved.score !== '-' && resolved.level !== '-'); + if (!hasResolved) return ''; + return colorizeRiskLevel( + `[resolved:${resolved?.score}/${resolved?.level} alerts:${resolved?.resolvedAlertsCount ?? 0} crit:${shortenCriticality(resolved?.criticality ?? '-')} wlists:${resolved?.watchlistsCount ?? 0}]`, + resolved?.level ?? '-', + ); + }; + const formatEntityWithScore = (entityId: string): string => { + const baseTag = formatBaseTag(entityId); + const resolutionTag = formatResolvedTag(entityId); + if (!baseTag && !resolutionTag) { + return entityId; + } + return `${entityId} ${[baseTag, resolutionTag].filter(Boolean).join(' ')}`.trim(); + }; // eslint-disable-next-line no-console console.log(colorize('🕸️ Relationships only', 'cyan')); + // eslint-disable-next-line no-console + console.log( + ' legend: [score/level alerts: crit: wlists:] [resolved:score/level alerts: crit: wlists:]', + ); + // eslint-disable-next-line no-console + console.log(` flow: ${upRightArrow} contributors into target score`); if (graph.resolutionGroups.length === 0) { // eslint-disable-next-line no-console console.log(' resolution links: none'); @@ -163,10 +242,12 @@ const printGraphSummaryViews = ({ // eslint-disable-next-line no-console console.log(` resolution groups: ${graph.resolutionGroups.length}`); for (const [index, group] of graph.resolutionGroups.slice(0, maxRows).entries()) { + const formattedAliases = group.aliasIds.map((aliasId) => formatEntityWithScore(aliasId)); // eslint-disable-next-line no-console - console.log( - ` [${index + 1}] ${group.targetId} ${leftArrow} ${summarizeList(group.aliasIds, 4)}`, - ); + console.log(` [${index + 1}] ${formatEntityWithScore(group.targetId)}`); + // eslint-disable-next-line no-console + console.log(` └─ ${resolutionLabel} ${leftArrow} aliases`); + printTreeItems({ items: formattedAliases, indent: ' ' }); } if (graph.resolutionGroups.length > maxRows) { // eslint-disable-next-line no-console @@ -184,7 +265,9 @@ const printGraphSummaryViews = ({ console.log(` ownership edges: ${graph.ownershipEdges.length}`); for (const [index, edge] of graph.ownershipEdges.slice(0, maxRows).entries()) { // eslint-disable-next-line no-console - console.log(` [${index + 1}] ${edge.sourceId} ${rightArrow} ${edge.targetId}`); + console.log(` [${index + 1}] ${formatEntityWithScore(edge.sourceId)}`); + // eslint-disable-next-line no-console + console.log(` └─ ${ownsLabel} ${rightArrow} ${formatEntityWithScore(edge.targetId)}`); } if (graph.ownershipEdges.length > maxRows) { // eslint-disable-next-line no-console @@ -216,13 +299,14 @@ const printGraphSummaryViews = ({ const contributors = graph.ownershipEdges .filter((edge) => edge.targetId === targetId) .map((edge) => edge.sourceId); - // eslint-disable-next-line no-console - console.log( - ` ${targetId} ${contributesArrow} ${ownsLabel}(${summarizeList( - [...new Set(contributors)], - 4, - )})`, + const formattedContributors = [...new Set(contributors)].map((id) => + formatEntityWithScore(id), ); + // eslint-disable-next-line no-console + console.log(` ${formatEntityWithScore(targetId)}`); + // eslint-disable-next-line no-console + console.log(` └─ ${upRightArrow} ${ownsLabel}(${formattedContributors.length})`); + printTreeItems({ items: formattedContributors, indent: ' ' }); } return; } @@ -237,10 +321,28 @@ const printGraphSummaryViews = ({ .map((edge) => edge.sourceId), ), ]; + const formattedAliases = aliases.map((aliasId) => formatEntityWithScore(aliasId)); + const formattedOwnershipContributors = ownershipContributors.map((id) => + formatEntityWithScore(id), + ); + const resolutionTag = formatResolvedTag(targetId); + const baseTag = formatBaseTag(targetId); + // eslint-disable-next-line no-console + console.log( + ` [${index + 1}] ${resolutionLabel} score ${upRightArrow} ${targetId} ${resolutionTag || '[resolved:-]'}`, + ); + // eslint-disable-next-line no-console + console.log(` ├─ base target features`); + // eslint-disable-next-line no-console + console.log(` │ └─ ${targetId} ${baseTag || '[base:-]'}`); + // eslint-disable-next-line no-console + console.log(` ├─ ${upRightArrow} ${resolutionLabel} aliases (${aliases.length})`); + printTreeItems({ items: formattedAliases, indent: ' │ ' }); // eslint-disable-next-line no-console console.log( - ` [${index + 1}] ${targetId} ${contributesArrow} ${resolutionLabel}(${aliases.length} aliases: ${summarizeList(aliases, 3)}) ${plusToken} ${ownsLabel}(${ownershipContributors.length}: ${summarizeList(ownershipContributors, 3)})`, + ` └─ ${plusToken} ${ownsLabel} contributors (${ownershipContributors.length})`, ); + printTreeItems({ items: formattedOwnershipContributors, indent: ' ' }); } if (groupTargets.length > maxRows) { // eslint-disable-next-line no-console @@ -1546,26 +1648,32 @@ const collectRiskSnapshot = async ({ 'host.risk.calculated_score_norm', 'host.risk.calculated_level', 'host.risk.calculation_run_id', + 'host.risk.category_1_count', 'host.risk.id_value', 'host.risk.modifiers', 'user.name', 'user.risk.calculated_score_norm', 'user.risk.calculated_level', 'user.risk.calculation_run_id', + 'user.risk.category_1_count', 'user.risk.id_value', 'user.risk.modifiers', 'service.name', 'service.risk.calculated_score_norm', 'service.risk.calculated_level', 'service.risk.calculation_run_id', + 'service.risk.category_1_count', 'service.risk.id_value', 'service.risk.score_type', 'service.risk.related_entities', 'service.risk.modifiers', 'host.risk.score_type', 'host.risk.related_entities', + 'host.risk.category_1_count', 'user.risk.score_type', 'user.risk.related_entities', + 'user.risk.category_1_count', + 'service.risk.category_1_count', ], }); @@ -1619,6 +1727,8 @@ const collectRiskSnapshot = async ({ 'host.risk.score_type', 'host.risk.related_entities', 'host.risk.calculation_run_id', + 'host.risk.category_1_count', + 'host.risk.modifiers', 'user.name', 'user.risk.id_value', 'user.risk.calculated_score_norm', @@ -1626,6 +1736,8 @@ const collectRiskSnapshot = async ({ 'user.risk.score_type', 'user.risk.related_entities', 'user.risk.calculation_run_id', + 'user.risk.category_1_count', + 'user.risk.modifiers', 'service.name', 'service.risk.id_value', 'service.risk.calculated_score_norm', @@ -1633,6 +1745,8 @@ const collectRiskSnapshot = async ({ 'service.risk.score_type', 'service.risk.related_entities', 'service.risk.calculation_run_id', + 'service.risk.category_1_count', + 'service.risk.modifiers', ], }); @@ -1647,7 +1761,13 @@ const collectRiskSnapshot = async ({ const riskById = new Map< string, - { score: string; level: string; scoreType: string; relatedEntities: number } + { + score: string; + level: string; + scoreType: string; + relatedEntities: number; + alertsCount: number; + } >(); const resolutionRowsByKey = new Map(); for (const hit of riskResponse.hits.hits) { @@ -1663,6 +1783,7 @@ const collectRiskSnapshot = async ({ score_type?: string; related_entities?: unknown[]; calculation_run_id?: string; + category_1_count?: number; }; }; user?: { @@ -1674,6 +1795,7 @@ const collectRiskSnapshot = async ({ score_type?: string; related_entities?: unknown[]; calculation_run_id?: string; + category_1_count?: number; }; }; service?: { @@ -1685,6 +1807,7 @@ const collectRiskSnapshot = async ({ score_type?: string; related_entities?: unknown[]; calculation_run_id?: string; + category_1_count?: number; }; }; } @@ -1708,6 +1831,7 @@ const collectRiskSnapshot = async ({ level: risk.calculated_level ?? '-', scoreType, relatedEntities: Array.isArray(risk.related_entities) ? risk.related_entities.length : 0, + alertsCount: typeof risk.category_1_count === 'number' ? risk.category_1_count : 0, }); } @@ -1717,6 +1841,7 @@ const collectRiskSnapshot = async ({ score: riskById.get(id)?.score ?? '-', level: riskById.get(id)?.level ?? '-', scoreType: riskById.get(id)?.scoreType ?? '-', + alertsCount: riskById.get(id)?.alertsCount ?? 0, criticality: entityById.get(id)?.criticality ?? '-', watchlistsCount: entityById.get(id)?.watchlists.length ?? 0, resolutionTarget: entityById.get(id)?.resolutionTarget ?? '-', @@ -1742,6 +1867,8 @@ const collectRiskSnapshot = async ({ score_type?: string; related_entities?: unknown[]; calculation_run_id?: string; + category_1_count?: number; + modifiers?: unknown[]; }; }; user?: { @@ -1753,6 +1880,8 @@ const collectRiskSnapshot = async ({ score_type?: string; related_entities?: unknown[]; calculation_run_id?: string; + category_1_count?: number; + modifiers?: unknown[]; }; }; service?: { @@ -1764,6 +1893,8 @@ const collectRiskSnapshot = async ({ score_type?: string; related_entities?: unknown[]; calculation_run_id?: string; + category_1_count?: number; + modifiers?: unknown[]; }; }; } @@ -1782,6 +1913,22 @@ const collectRiskSnapshot = async ({ typeof risk.calculation_run_id === 'string' ? risk.calculation_run_id : '-'; const resolutionKey = buildResolutionKey({ targetEntityId: id, calculationRunId }); if (resolutionRowsByKey.has(resolutionKey)) continue; + const modifiers = Array.isArray(risk.modifiers) + ? (risk.modifiers as Array>) + : []; + const resolvedCriticality = (() => { + const criticalityModifier = modifiers.find( + (modifier) => modifier.type === 'asset_criticality', + ); + const metadata = + criticalityModifier && typeof criticalityModifier.metadata === 'object' + ? (criticalityModifier.metadata as Record) + : undefined; + return typeof metadata?.criticality_level === 'string' ? metadata.criticality_level : '-'; + })(); + const resolvedWatchlistsCount = modifiers.filter( + (modifier) => modifier.type === 'watchlist', + ).length; resolutionRowsByKey.set(resolutionKey, { resolutionKey, targetEntityId: id, @@ -1791,6 +1938,10 @@ const collectRiskSnapshot = async ({ : '-', level: risk.calculated_level ?? '-', relatedEntities: Array.isArray(risk.related_entities) ? risk.related_entities.length : 0, + resolvedAlertsCount: + typeof risk.category_1_count === 'number' ? risk.category_1_count : 0, + resolvedCriticality, + resolvedWatchlistsCount, calculationRunId, timestamp: typeof source?.['@timestamp'] === 'string' ? source['@timestamp'] : '-', }); @@ -2042,6 +2193,8 @@ const printResolutionRows = async ({ const targetWidth = 48; const scoreWidth = 7; const levelWidth = 9; + const critWidth = 14; + const watchlistsWidth = 4; const relWidth = 6; const runWidth = 18; const tsWidth = 24; @@ -2052,11 +2205,13 @@ const printResolutionRows = async ({ formatCell('Target Entity', targetWidth), formatCell('Score', scoreWidth), formatCell('Level', levelWidth), + formatCell('Crit', critWidth), + formatCell('WL', watchlistsWidth), formatCell('Rel', relWidth), formatCell('Run ID', runWidth), formatCell('Timestamp', tsWidth), ].join(' | '); - const separator = `${'-'.repeat(idxWidth)}-+-${'-'.repeat(keyWidth)}-+-${'-'.repeat(targetWidth)}-+-${'-'.repeat(scoreWidth)}-+-${'-'.repeat(levelWidth)}-+-${'-'.repeat(relWidth)}-+-${'-'.repeat(runWidth)}-+-${'-'.repeat(tsWidth)}`; + const separator = `${'-'.repeat(idxWidth)}-+-${'-'.repeat(keyWidth)}-+-${'-'.repeat(targetWidth)}-+-${'-'.repeat(scoreWidth)}-+-${'-'.repeat(levelWidth)}-+-${'-'.repeat(critWidth)}-+-${'-'.repeat(watchlistsWidth)}-+-${'-'.repeat(relWidth)}-+-${'-'.repeat(runWidth)}-+-${'-'.repeat(tsWidth)}`; // eslint-disable-next-line no-console console.log(colorize(`🧩 Resolution scorecard (${rows.length} rows)`, 'cyan')); @@ -2077,6 +2232,8 @@ const printResolutionRows = async ({ formatCell(row.targetEntityId, targetWidth), formatCell(row.score, scoreWidth), colorizeRiskLevel(levelCell, row.level), + formatCell(row.resolvedCriticality, critWidth), + formatCell(String(row.resolvedWatchlistsCount), watchlistsWidth), formatCell(String(row.relatedEntities), relWidth), formatCell(row.calculationRunId, runWidth), formatCell(row.timestamp, tsWidth), @@ -2106,6 +2263,8 @@ const printResolutionRows = async ({ formatCell(row.targetEntityId, targetWidth), formatCell(row.score, scoreWidth), colorizeRiskLevel(levelCell, row.level), + formatCell(row.resolvedCriticality, critWidth), + formatCell(String(row.resolvedWatchlistsCount), watchlistsWidth), formatCell(String(row.relatedEntities), relWidth), formatCell(row.calculationRunId, runWidth), formatCell(row.timestamp, tsWidth), @@ -4025,7 +4184,45 @@ const runFollowOnActionLoop = async ({ log.info( `Graph summary: resolution_groups=${trackedRelationshipGraph.resolutionGroups.length}, ownership_edges=${trackedRelationshipGraph.ownershipEdges.length}, sample_group_sizes=[${sampledGroups.join(', ')}]`, ); - printGraphSummaryViews({ graph: trackedRelationshipGraph, maxRows: pageSize }); + printGraphSummaryViews({ + graph: trackedRelationshipGraph, + scoreByEntityId: new Map( + before.rows.map((row) => [ + row.id, + { + score: row.score, + level: row.level, + alertsCount: row.alertsCount, + criticality: row.criticality, + watchlistsCount: row.watchlistsCount, + }, + ]), + ), + resolutionScoreByEntityId: (() => { + const latestByTarget = new Map< + string, + { + score: string; + level: string; + resolvedAlertsCount: number; + criticality: string; + watchlistsCount: number; + } + >(); + for (const row of before.resolutionRows) { + if (latestByTarget.has(row.targetEntityId)) continue; + latestByTarget.set(row.targetEntityId, { + score: row.score, + level: row.level, + resolvedAlertsCount: row.resolvedAlertsCount, + criticality: row.resolvedCriticality, + watchlistsCount: row.resolvedWatchlistsCount, + }); + } + return latestByTarget; + })(), + maxRows: pageSize, + }); } } else if (action === 'link_aliases') { if (!phase2Enabled || !resolutionEnabled) { From 2b1039c735de72617213eb63834ae73731f7c9ce Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Thu, 9 Apr 2026 09:45:44 +0100 Subject: [PATCH 14/15] Address Copilot PR review comments Made-with: Cursor --- src/commands/entity_store/risk_score_v2.ts | 117 +++++++++++++-------- src/utils/kibana_api.ts | 21 +++- 2 files changed, 88 insertions(+), 50 deletions(-) diff --git a/src/commands/entity_store/risk_score_v2.ts b/src/commands/entity_store/risk_score_v2.ts index e07f133a..dbf5d332 100644 --- a/src/commands/entity_store/risk_score_v2.ts +++ b/src/commands/entity_store/risk_score_v2.ts @@ -1627,7 +1627,7 @@ const collectRiskSnapshot = async ({ const riskResponse = await client.search({ index: riskIndex, - size: Math.max(100, uniqueEntityIds.length * 4), + size: Math.min(10000, Math.max(100, uniqueEntityIds.length * 4)), sort: [{ '@timestamp': { order: 'desc' } }], query: { bool: { @@ -1677,9 +1677,10 @@ const collectRiskSnapshot = async ({ ], }); + const maxResolutionSearchSize = 10_000; const resolutionResponse = await client.search({ index: riskIndex, - size: Math.max(100, uniqueEntityIds.length * 8), + size: Math.min(maxResolutionSearchSize, Math.max(100, uniqueEntityIds.length * 8)), sort: [{ '@timestamp': { order: 'desc' } }], query: { bool: { @@ -3179,59 +3180,83 @@ const fetchRiskDocsForEntityIds = async ({ maxDocsPerEntity: number; }): Promise> => { const uniqueEntityIds = [...new Set(entityIds)]; + const uniqueEntityIdSet = new Set(uniqueEntityIds); const grouped = new Map(); - if (uniqueEntityIds.length === 0) { + if (uniqueEntityIds.length === 0 || maxDocsPerEntity <= 0) { return grouped; } const client = getEsClient(); const riskIndex = `risk-score.risk-score-${space}`; - const response = await client.search({ - index: riskIndex, - size: Math.max(100, uniqueEntityIds.length * Math.max(1, maxDocsPerEntity) * 4), - sort: [{ '@timestamp': { order: 'desc' } }], - query: { - bool: { - should: [ - { terms: { 'host.name': uniqueEntityIds } }, - { terms: { 'user.name': uniqueEntityIds } }, - { terms: { 'service.name': uniqueEntityIds } }, - ], - minimum_should_match: 1, + + const desiredSize = Math.max(100, uniqueEntityIds.length * Math.max(1, maxDocsPerEntity) * 4); + const pageSize = Math.min(10000, desiredSize); + + const hasEnoughDocsForAllEntities = () => + uniqueEntityIds.every((entityId) => (grouped.get(entityId)?.length ?? 0) >= maxDocsPerEntity); + + let searchAfter: (string | number | boolean | null)[] | undefined; + + while (!hasEnoughDocsForAllEntities()) { + const response = await client.search({ + index: riskIndex, + size: pageSize, + sort: [{ '@timestamp': { order: 'desc' } }, { _id: { order: 'desc' } }], + search_after: searchAfter, + query: { + bool: { + should: [ + { terms: { 'host.name': uniqueEntityIds } }, + { terms: { 'user.name': uniqueEntityIds } }, + { terms: { 'service.name': uniqueEntityIds } }, + ], + minimum_should_match: 1, + }, }, - }, - }); + }); - for (const hit of response.hits.hits) { - const source = (hit._source ?? {}) as { - '@timestamp'?: string; - host?: { name?: string; risk?: Record }; - user?: { name?: string; risk?: Record }; - service?: { name?: string; risk?: Record }; - }; - const entityId = source.host?.name ?? source.user?.name ?? source.service?.name; - if (!entityId || !uniqueEntityIds.includes(entityId)) { - continue; + const hits = response.hits.hits; + if (hits.length === 0) { + break; } - const risk = source.host?.risk ?? source.user?.risk ?? source.service?.risk ?? {}; - const entries = grouped.get(entityId) ?? []; - if (entries.length >= maxDocsPerEntity) { - continue; + + for (const hit of hits) { + const source = (hit._source ?? {}) as { + '@timestamp'?: string; + host?: { name?: string; risk?: Record }; + user?: { name?: string; risk?: Record }; + service?: { name?: string; risk?: Record }; + }; + const entityId = source.host?.name ?? source.user?.name ?? source.service?.name; + if (!entityId || !uniqueEntityIdSet.has(entityId)) { + continue; + } + const risk = source.host?.risk ?? source.user?.risk ?? source.service?.risk ?? {}; + const entries = grouped.get(entityId) ?? []; + if (entries.length >= maxDocsPerEntity) { + continue; + } + entries.push({ + entityId, + timestamp: source['@timestamp'] ?? '-', + score: + typeof risk.calculated_score_norm === 'number' + ? (risk.calculated_score_norm as number) + : null, + level: typeof risk.calculated_level === 'string' ? (risk.calculated_level as string) : '-', + scoreType: typeof risk.score_type === 'string' ? (risk.score_type as string) : '-', + calculationRunId: + typeof risk.calculation_run_id === 'string' ? (risk.calculation_run_id as string) : '-', + source: source as unknown as Record, + }); + grouped.set(entityId, entries); } - entries.push({ - entityId, - timestamp: source['@timestamp'] ?? '-', - score: - typeof risk.calculated_score_norm === 'number' - ? (risk.calculated_score_norm as number) - : null, - level: typeof risk.calculated_level === 'string' ? (risk.calculated_level as string) : '-', - scoreType: typeof risk.score_type === 'string' ? (risk.score_type as string) : '-', - calculationRunId: - typeof risk.calculation_run_id === 'string' ? (risk.calculation_run_id as string) : '-', - source: source as unknown as Record, - }); - grouped.set(entityId, entries); + + const lastHit = hits[hits.length - 1]; + if (!lastHit.sort || lastHit.sort.length === 0) { + break; + } + searchAfter = lastHit.sort as (string | number | boolean | null)[]; } return grouped; }; @@ -4789,7 +4814,7 @@ export const riskScoreV2Command = async (options: RiskScoreV2Options) => { 'Follow-on actions requested, but output is non-interactive (non-TTY). Skipping menu.', ); } else { - log.info('Follow-on menu disabled (--no-follow-on).'); + log.info('Follow-on menu disabled.'); } if (stageTimings.length > 0) { log.info( diff --git a/src/utils/kibana_api.ts b/src/utils/kibana_api.ts index 97d6f42e..e7f42f54 100644 --- a/src/utils/kibana_api.ts +++ b/src/utils/kibana_api.ts @@ -54,6 +54,17 @@ const getDispatcher = () => { return undefined; }; +const redactUrl = (urlStr: string): string => { + try { + const parsed = new URL(urlStr); + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); + } catch { + return urlStr; + } +}; + const joinUrl = (...parts: string[]) => parts.map((p, i) => (i === 0 ? p.replace(/\/+$/, '') : p.replace(/^\/+/, ''))).join('/'); @@ -127,6 +138,7 @@ export const kibanaFetch = async ( headers.set('x-elastic-internal-origin', 'kibana'); headers.set('elastic-api-version', apiVersion); let result: Response; + const safeUrl = redactUrl(url); try { result = await fetch(url, { headers: headers, @@ -135,7 +147,7 @@ export const kibanaFetch = async ( } as RequestInit); } catch (error) { const details = formatCauseDetails(error); - const message = `Network request failed for ${method} ${url}. Details: ${details}. Check Kibana URL, credentials, and whether Kibana is running.`; + const message = `Network request failed for ${method} ${safeUrl}. Details: ${details}. Check Kibana URL, credentials, and whether Kibana is running.`; throw new Error(message, { cause: error }); } const rawResponse = await result.text(); @@ -148,13 +160,13 @@ export const kibanaFetch = async ( } if (!data || typeof data !== 'object') { throw new Error( - `Unexpected non-object response from ${method} ${url}. Raw response: ${rawResponse.slice(0, 500)}`, + `Unexpected non-object response from ${method} ${safeUrl}. Raw response: ${rawResponse.slice(0, 500)}`, ); } if (result.status >= 400 && !ignoreStatusesArray.includes(result.status)) { throwResponseError( - `Request failed for ${method} ${url}, status: ${result.status}`, + `Request failed for ${method} ${safeUrl}, status: ${result.status}`, result.status, data, ); @@ -1070,6 +1082,7 @@ export const uploadPrivmonCsv = async ( path: '/api/entity_analytics/monitoring/users/_csv', space, }); + const safeUploadUrl = redactUrl(uploadUrl); const response = await fetch(uploadUrl, { method: 'POST', headers: { @@ -1084,7 +1097,7 @@ export const uploadPrivmonCsv = async ( if (!response.ok) { const errorText = await response.text(); - throw new Error(`Failed to upload CSV: ${errorText}`); + throw new Error(`Failed to upload CSV to ${safeUploadUrl}: ${errorText}`); } return { success: true }; From dbadcb98a703885b71393e658a32c38bcd8cfd8f Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Thu, 9 Apr 2026 15:13:41 +0100 Subject: [PATCH 15/15] Update Entity Store V2 API routes to match new public/internal split Made-with: Cursor --- src/constants.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 2ed1c3da..f6540eff 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -100,23 +100,20 @@ export const ENTITY_ENGINE_URL = (engineType: string) => `${ENTITY_ENGINES_URL}/ export const INIT_ENTITY_ENGINE_URL = (engineType: string) => `${ENTITY_ENGINE_URL(engineType)}/init`; export const ENTITY_STORE_ENTITIES_URL = (entityType: 'user' | 'host' | 'service') => - `/api/entity_store/entities/${entityType}`; + `/api/security/entity_store/entities/${entityType}`; // Kibana Settings API endpoints export const KIBANA_SETTINGS_URL = '/api/kibana/settings'; export const KIBANA_SETTINGS_INTERNAL_URL = '/internal/kibana/settings'; -// Entity Store V2 (ESQL) internal API -export const ENTITY_STORE_V2_INSTALL_URL = '/internal/security/entity_store/install'; +// Entity Store V2 (ESQL) API +export const ENTITY_STORE_V2_INSTALL_URL = '/api/security/entity_store/install'; export const ENTITY_STORE_V2_FORCE_LOG_EXTRACTION_URL = (entityType: 'user' | 'host' | 'service') => `/internal/security/entity_store/${entityType}/force_log_extraction`; -export const ENTITY_STORE_V2_CRUD_BULK_URL = '/internal/security/entity_store/entities/bulk'; -export const ENTITY_STORE_V2_RESOLUTION_LINK_URL = - '/internal/security/entity_store/resolution/link'; -export const ENTITY_STORE_V2_RESOLUTION_UNLINK_URL = - '/internal/security/entity_store/resolution/unlink'; -export const ENTITY_STORE_V2_RESOLUTION_GROUP_URL = - '/internal/security/entity_store/resolution/group'; +export const ENTITY_STORE_V2_CRUD_BULK_URL = '/api/security/entity_store/entities/bulk'; +export const ENTITY_STORE_V2_RESOLUTION_LINK_URL = '/api/security/entity_store/resolution/link'; +export const ENTITY_STORE_V2_RESOLUTION_UNLINK_URL = '/api/security/entity_store/resolution/unlink'; +export const ENTITY_STORE_V2_RESOLUTION_GROUP_URL = '/api/security/entity_store/resolution/group'; export const ENTITY_MAINTAINERS_INIT_URL = '/internal/security/entity_store/entity_maintainers/init'; export const ENTITY_MAINTAINERS_URL = '/internal/security/entity_store/entity_maintainers';