From 3ca806e5665f0b769a391384678f5e88d3e2e748 Mon Sep 17 00:00:00 2001 From: Damien Goujard Date: Tue, 18 Aug 2026 15:33:51 +0200 Subject: [PATCH 1/6] [backend] Agent inactive audit logging --- docs/docs/deployment/configuration.md | 43 +++--- .../service/CalderaExecutorService.java | 1 + .../executors/model/AgentRegisterInput.java | 7 +- ...260818100000000__Add_status_to_agents.java | 25 ++++ .../scheduler/PlatformJobDefinitions.java | 9 ++ .../openaev/scheduler/PlatformTriggers.java | 17 +++ .../jobs/AgentInactivityMonitorJob.java | 92 ++++++++++++ .../io/openaev/service/EndpointService.java | 3 + .../autonomous/CapabilityResolverService.java | 7 +- .../src/main/resources/application.properties | 2 + .../service/ExecutionExecutorServiceTest.java | 3 + ...ntInactivityMonitorJobIntegrationTest.java | 122 ++++++++++++++++ .../jobs/AgentInactivityMonitorJobTest.java | 133 ++++++++++++++++++ .../jobs/InjectsExecutionJobTest.java | 2 + .../openaev/service/EndpointServiceTest.java | 28 ++-- .../openaev/utils/fixtures/AgentFixture.java | 2 + .../src/test/resources/application.properties | 1 + .../java/io/openaev/database/model/Agent.java | 10 +- .../openaev/database/model/AgentStatus.java | 6 + .../java/io/openaev/database/model/Asset.java | 6 +- .../raw/RawVulnerableEndpointIndexing.java | 3 +- .../database/repository/AgentRepository.java | 17 ++- .../VulnerableEndpointRepository.java | 4 +- .../VulnerableEndpointHandler.java | 21 +-- .../java/io/openaev/helper/AgentHelper.java | 32 ----- 25 files changed, 489 insertions(+), 107 deletions(-) create mode 100644 openaev-api/src/main/java/io/openaev/migration/V6_20260818100000000__Add_status_to_agents.java create mode 100644 openaev-api/src/main/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJob.java create mode 100644 openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobIntegrationTest.java create mode 100644 openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobTest.java create mode 100644 openaev-model/src/main/java/io/openaev/database/model/AgentStatus.java delete mode 100644 openaev-model/src/main/java/io/openaev/helper/AgentHelper.java diff --git a/docs/docs/deployment/configuration.md b/docs/docs/deployment/configuration.md index cd2f66d27e0..e481dd01ece 100644 --- a/docs/docs/deployment/configuration.md +++ b/docs/docs/deployment/configuration.md @@ -26,27 +26,28 @@ Here are the configuration keys, for both containers (environment variables) and #### Basic parameters -| Parameter | Environment variable | Default value | Description | -|:--------------------------------------------|:--------------------------------------------|:----------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| server.address | SERVER_ADDRESS | 0.0.0.0 | Listen address of the application | -| server.port | SERVER_PORT | 8080 | Listen port of the application | -| openaev.base-url | OPENAEV_BASE-URL | http://localhost:8080 | Base URL of the application, used for some email links and as the default agent URL (agent installer scripts and executor commands) unless `openaev.agent-url` is set. In production environments, ensure this URL can be resolved from endpoints where agents will be deployed. | -| server.servlet.session.timeout | SERVER_SERVLET_SESSION_TIMEOUT | 1440m | Rolling session timeout: every request extends the session by this duration. Sessions are persisted in PostgreSQL and survive platform restarts | -| openaev.session-idle-timeout | OPENAEV_SESSION-IDLE-TIMEOUT | 0 | Idle time before the UI locks the screen and asks the user to continue or log out (0 = disabled, e.g. 30m). Must be lower than the session timeout | -| openaev.session-cookie | OPENAEV_SESSION-COOKIE | `false` | When `true`, the session cookie dies when the browser closes (server-side timeout still applies). When `false`, users stay logged in across browser restarts: the cookie is re-issued on every request (sliding Max-Age), so it only expires after `openaev.cookie-duration` of inactivity | -| openaev.cookie-secure | OPENAEV_COOKIE-SECURE | `false` | Turn on if the access is done in HTTPS | -| openaev.cookie-duration | OPENAEV_COOKIE-DURATION | P1D | Cookie validity sliding window (default 1 day). Each request re-issues the cookie with this Max-Age, so active users are never logged out by cookie expiration | -| openaev.admin.email | OPENAEV_ADMIN_EMAIL | admin@openaev.io | Default login email of the admin user | -| openaev.admin.password | OPENAEV_ADMIN_PASSWORD | ChangeMe | Default password of the admin user | -| openaev.admin.token | OPENAEV_ADMIN_TOKEN | ChangeMe | Default token (must be a valid UUIDv4) | -| openaev.admin.encryption_key | OPENAEV_ADMIN_ENCRYPTION_KEY | ChangeMe | Encryption key used for encrypting sensitive data in database. Encryption key and salt are used to generate a 256bit encryption key for encrypting purpose. | -| openaev.admin.encryption_salt | OPENAEV_ADMIN_ENCRYPTION_SALT | ChangeMe | Encryption salt used for encrypting sensitive data in database. Must be at least 8 bytes long. Encryption key and salt are used to generate a 256bit encryption key for encrypting purpose | -| openaev.healthcheck.key | OPENAEV_HEALTHCHECK_KEY | ChangeMe | The key to use in the health check endpoint (/api/health) | -| inject.execution.threshold.minutes | INJECT_EXECUTION_THRESHOLD_MINUTES | 10 | Inject execution threshold in minutes. If this time is exceeded, the inject will be moved to the MAYBE_PREVENTED status. | -| openaev.run-mode | OPENAEV_RUN-MODE | normal | Startup run mode (`normal` or `safe`). In `safe`, Quartz background processing is disabled. See [Run modes](platform/run-modes.md). | -| openaev.starterpack.enabled | OPENAEV_STARTERPACK_ENABLED | true | StarterPack feature, providing default endpoint, asset group, scenarios and dashboards | -| openaev.url.access.token.expiry-margin-days | OPENAEV_URL_ACCESS_TOKEN_EXPIRY-MARGIN-DAYS | 7 | Number of days added after an exercise end date before URL access tokens expire | -| openaev.url.access.token.retention-days | OPENAEV_URL_ACCESS_TOKEN_RETENTION-DAYS | 30 | Number of days to retain expired or revoked URL access tokens before the purge job deletes them | +| Parameter | Environment variable | Default value | Description | +|:------------------------------------------------------|:------------------------------------------------------|:----------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| server.address | SERVER_ADDRESS | 0.0.0.0 | Listen address of the application | +| server.port | SERVER_PORT | 8080 | Listen port of the application | +| openaev.base-url | OPENAEV_BASE-URL | http://localhost:8080 | Base URL of the application, used for some email links and as the default agent URL (agent installer scripts and executor commands) unless `openaev.agent-url` is set. In production environments, ensure this URL can be resolved from endpoints where agents will be deployed. | +| server.servlet.session.timeout | SERVER_SERVLET_SESSION_TIMEOUT | 1440m | Rolling session timeout: every request extends the session by this duration. Sessions are persisted in PostgreSQL and survive platform restarts | +| openaev.session-idle-timeout | OPENAEV_SESSION-IDLE-TIMEOUT | 0 | Idle time before the UI locks the screen and asks the user to continue or log out (0 = disabled, e.g. 30m). Must be lower than the session timeout | +| openaev.session-cookie | OPENAEV_SESSION-COOKIE | `false` | When `true`, the session cookie dies when the browser closes (server-side timeout still applies). When `false`, users stay logged in across browser restarts: the cookie is re-issued on every request (sliding Max-Age), so it only expires after `openaev.cookie-duration` of inactivity | +| openaev.cookie-secure | OPENAEV_COOKIE-SECURE | `false` | Turn on if the access is done in HTTPS | +| openaev.cookie-duration | OPENAEV_COOKIE-DURATION | P1D | Cookie validity sliding window (default 1 day). Each request re-issues the cookie with this Max-Age, so active users are never logged out by cookie expiration | +| openaev.admin.email | OPENAEV_ADMIN_EMAIL | admin@openaev.io | Default login email of the admin user | +| openaev.admin.password | OPENAEV_ADMIN_PASSWORD | ChangeMe | Default password of the admin user | +| openaev.admin.token | OPENAEV_ADMIN_TOKEN | ChangeMe | Default token (must be a valid UUIDv4) | +| openaev.admin.encryption_key | OPENAEV_ADMIN_ENCRYPTION_KEY | ChangeMe | Encryption key used for encrypting sensitive data in database. Encryption key and salt are used to generate a 256bit encryption key for encrypting purpose. | +| openaev.admin.encryption_salt | OPENAEV_ADMIN_ENCRYPTION_SALT | ChangeMe | Encryption salt used for encrypting sensitive data in database. Must be at least 8 bytes long. Encryption key and salt are used to generate a 256bit encryption key for encrypting purpose | +| openaev.healthcheck.key | OPENAEV_HEALTHCHECK_KEY | ChangeMe | The key to use in the health check endpoint (/api/health) | +| inject.execution.threshold.minutes | INJECT_EXECUTION_THRESHOLD_MINUTES | 10 | Inject execution threshold in minutes. If this time is exceeded, the inject will be moved to the MAYBE_PREVENTED status. | +| openaev.cron.config.agent.inactivity.monitor.interval | OPENAEV_CRON_CONFIG_AGENT_INACTIVITY_MONITOR_INTERVAL | 5 | Polling interval in minutes for the agent inactivity monitor job. | +| openaev.run-mode | OPENAEV_RUN-MODE | normal | Startup run mode (`normal` or `safe`). In `safe`, Quartz background processing is disabled. See [Run modes](platform/run-modes.md). | +| openaev.starterpack.enabled | OPENAEV_STARTERPACK_ENABLED | true | StarterPack feature, providing default endpoint, asset group, scenarios and dashboards | +| openaev.url.access.token.expiry-margin-days | OPENAEV_URL_ACCESS_TOKEN_EXPIRY-MARGIN-DAYS | 7 | Number of days added after an exercise end date before URL access tokens expire | +| openaev.url.access.token.retention-days | OPENAEV_URL_ACCESS_TOKEN_RETENTION-DAYS | 30 | Number of days to retain expired or revoked URL access tokens before the purge job deletes them | #### Network and security diff --git a/openaev-api/src/main/java/io/openaev/executors/caldera/service/CalderaExecutorService.java b/openaev-api/src/main/java/io/openaev/executors/caldera/service/CalderaExecutorService.java index 915f13b523f..6d5dc6fa187 100644 --- a/openaev-api/src/main/java/io/openaev/executors/caldera/service/CalderaExecutorService.java +++ b/openaev-api/src/main/java/io/openaev/executors/caldera/service/CalderaExecutorService.java @@ -215,6 +215,7 @@ private void setUpdatedAgentAttributes(Agent agent, AgentRegisterInput input, En agent.setAsset(endpoint); agent.setProcessName(input.getProcessName()); agent.setLastSeen(input.getLastSeen()); + agent.setStatus(AgentStatus.ACTIVE); agent.setExternalReference(input.getExternalReference()); clearAbilityForAgent(agent); } diff --git a/openaev-api/src/main/java/io/openaev/executors/model/AgentRegisterInput.java b/openaev-api/src/main/java/io/openaev/executors/model/AgentRegisterInput.java index 227638be899..bcdc01d6a40 100644 --- a/openaev-api/src/main/java/io/openaev/executors/model/AgentRegisterInput.java +++ b/openaev-api/src/main/java/io/openaev/executors/model/AgentRegisterInput.java @@ -1,8 +1,9 @@ package io.openaev.executors.model; +import static io.openaev.database.model.Agent.ACTIVE_THRESHOLD_MILLIS; + import io.openaev.database.model.Endpoint; import io.openaev.database.model.Executor; -import io.openaev.helper.AgentHelper; import io.openaev.utils.mapper.EndpointMapper; import java.time.Instant; import lombok.Data; @@ -42,6 +43,8 @@ public void setHostname(String hostname) { } public boolean isActive() { - return new AgentHelper().isAgentActiveFromLastSeen(this.getLastSeen()); + return this.getLastSeen() != null + && (Instant.now().toEpochMilli() - this.getLastSeen().toEpochMilli()) + < ACTIVE_THRESHOLD_MILLIS; } } diff --git a/openaev-api/src/main/java/io/openaev/migration/V6_20260818100000000__Add_status_to_agents.java b/openaev-api/src/main/java/io/openaev/migration/V6_20260818100000000__Add_status_to_agents.java new file mode 100644 index 00000000000..b8d6cd8236e --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/migration/V6_20260818100000000__Add_status_to_agents.java @@ -0,0 +1,25 @@ +package io.openaev.migration; + +import java.sql.Statement; +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; +import org.springframework.stereotype.Component; + +@Component +public class V6_20260818100000000__Add_status_to_agents extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + try (Statement statement = context.getConnection().createStatement()) { + statement.execute( + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS agent_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE';"); + statement.execute( + "UPDATE agents SET agent_status = 'INACTIVE'" + + " WHERE agent_last_seen IS NULL" + + " OR agent_last_seen < NOW() - INTERVAL '1 hour';"); + statement.execute( + "CREATE INDEX IF NOT EXISTS idx_agents_status_last_seen" + + " ON agents(agent_status, agent_last_seen);"); + } + } +} diff --git a/openaev-api/src/main/java/io/openaev/scheduler/PlatformJobDefinitions.java b/openaev-api/src/main/java/io/openaev/scheduler/PlatformJobDefinitions.java index d53850601cf..8fa91b46b00 100644 --- a/openaev-api/src/main/java/io/openaev/scheduler/PlatformJobDefinitions.java +++ b/openaev-api/src/main/java/io/openaev/scheduler/PlatformJobDefinitions.java @@ -1,5 +1,6 @@ package io.openaev.scheduler; +import static io.openaev.scheduler.jobs.AgentInactivityMonitorJob.AGENT_INACTIVITY_MONITOR_JOB; import static io.openaev.scheduler.jobs.EngineDeletionReplayJob.ENGINE_DELETION_REPLAY_JOB; import static io.openaev.scheduler.jobs.ExecutionTraceRetentionJob.EXECUTION_TRACE_RETENTION_JOB; import static io.openaev.scheduler.jobs.TenantPurgeJob.TENANT_PURGE_JOB; @@ -79,6 +80,14 @@ public JobDetail getConnectorPingJob() { .build(); } + @Bean + public JobDetail agentInactivityMonitorJobDetail() { + return JobBuilder.newJob(AgentInactivityMonitorJob.class) + .withIdentity(AGENT_INACTIVITY_MONITOR_JOB) + .storeDurably() + .build(); + } + @Bean public JobDetail userEventRetentionJobDetail() { return JobBuilder.newJob(UserEventRetentionJob.class) diff --git a/openaev-api/src/main/java/io/openaev/scheduler/PlatformTriggers.java b/openaev-api/src/main/java/io/openaev/scheduler/PlatformTriggers.java index 63b8c6ac95c..e1ba8acd3e0 100644 --- a/openaev-api/src/main/java/io/openaev/scheduler/PlatformTriggers.java +++ b/openaev-api/src/main/java/io/openaev/scheduler/PlatformTriggers.java @@ -1,5 +1,6 @@ package io.openaev.scheduler; +import static io.openaev.scheduler.jobs.AgentInactivityMonitorJob.AGENT_INACTIVITY_MONITOR_TRIGGER; import static io.openaev.scheduler.jobs.EngineDeletionReplayJob.ENGINE_DELETION_REPLAY_TRIGGER; import static io.openaev.scheduler.jobs.ExecutionTraceRetentionJob.EXECUTION_TRACE_RETENTION_TRIGGER; import static io.openaev.scheduler.jobs.TenantPurgeJob.TENANT_PURGE_TRIGGER; @@ -31,6 +32,9 @@ public class PlatformTriggers { @Value("${openaev.cron.config.steps.delay.queue.polling.interval:10000}") private int stepDelayQueue; + @Value("${openaev.cron.config.agent.inactivity.monitor.interval:5}") + private int agentInactivityMonitorIntervalMinutes; + @Autowired public void setPlatformJobs(PlatformJobDefinitions platformJobs) { this.platformJobs = platformJobs; @@ -107,6 +111,19 @@ public Trigger connectorPingTrigger() { .build(); } + @Bean + @Profile("!test") + public Trigger agentInactivityMonitorTrigger() { + return newTrigger() + .forJob(this.platformJobs.agentInactivityMonitorJobDetail()) + .withIdentity(AGENT_INACTIVITY_MONITOR_TRIGGER) + .withSchedule( + simpleSchedule() + .withIntervalInMinutes(agentInactivityMonitorIntervalMinutes) + .repeatForever()) + .build(); + } + @Bean public Trigger userEventRetentionTrigger() { return newTrigger() diff --git a/openaev-api/src/main/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJob.java b/openaev-api/src/main/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJob.java new file mode 100644 index 00000000000..02d8e8c8815 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJob.java @@ -0,0 +1,92 @@ +package io.openaev.scheduler.jobs; + +import static io.openaev.aop.audit_log.AuditEventOrigin.SYSTEM; +import static io.openaev.database.model.Agent.ACTIVE_THRESHOLD_MILLIS; + +import io.openaev.aop.audit_log.AuditEvent; +import io.openaev.aop.audit_log.AuditEventScope; +import io.openaev.aop.audit_log.AuditLogger; +import io.openaev.context.TenantContext; +import io.openaev.context.TenantScopedTransaction; +import io.openaev.database.model.Agent; +import io.openaev.database.model.AgentStatus; +import io.openaev.database.model.EventStatus; +import io.openaev.database.model.EventType; +import io.openaev.database.model.ResourceType; +import io.openaev.database.repository.AgentRepository; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +@DisallowConcurrentExecution +public class AgentInactivityMonitorJob implements Job { + + public static final String AGENT_INACTIVITY_MONITOR_JOB = "AgentInactivityMonitorJob"; + public static final String AGENT_INACTIVITY_MONITOR_TRIGGER = "AgentInactivityMonitorTrigger"; + + private final AgentRepository agentRepository; + private final TenantScopedTransaction tenantTx; + private final Optional auditLogger; + + @Override + public void execute(JobExecutionContext context) { + tenantTx.forEachTenant(this::markInactiveAgentsForTenant); + } + + private void markInactiveAgentsForTenant(String tenantId) { + TenantContext.setCurrentTenant(tenantId); + try { + Instant threshold = Instant.now().minus(ACTIVE_THRESHOLD_MILLIS, ChronoUnit.MILLIS); + List newlyInactiveAgents = + agentRepository.findStaleAgentsByStatus(threshold, AgentStatus.ACTIVE); + if (newlyInactiveAgents.isEmpty()) { + return; + } + + newlyInactiveAgents.forEach(agent -> agent.setStatus(AgentStatus.INACTIVE)); + agentRepository.saveAll(newlyInactiveAgents); + newlyInactiveAgents.forEach(this::logCoverageGap); + } finally { + TenantContext.clearCurrentTenant(); + } + } + + private void logCoverageGap(Agent agent) { + auditLogger.ifPresent( + logger -> { + LinkedHashMap contextData = new LinkedHashMap<>(); + contextData.put("agent_id", agent.getId()); + contextData.put( + "endpoint_id", agent.getAsset() != null ? agent.getAsset().getId() : "unknown"); + contextData.put( + "last_seen", agent.getLastSeen() != null ? agent.getLastSeen().toString() : "never"); + contextData.put("previous_status", AgentStatus.ACTIVE.name()); + contextData.put("new_status", AgentStatus.INACTIVE.name()); + + logger.logEvent( + AuditEvent.builder() + .eventType(EventType.EXECUTION) + .eventScope(AuditEventScope.COVERAGE_GAP) + .eventStatus(EventStatus.WARNING) + .resourceType(ResourceType.AGENT) + .resourceId(agent.getId()) + .message( + "Agent '%s' became inactive (no heartbeat for 1 hour)" + .formatted(agent.getId())) + .contextData(contextData) + .origin(SYSTEM) + .build()); + }); + } +} diff --git a/openaev-api/src/main/java/io/openaev/service/EndpointService.java b/openaev-api/src/main/java/io/openaev/service/EndpointService.java index 85ec8e570de..7a6d002d7c5 100644 --- a/openaev-api/src/main/java/io/openaev/service/EndpointService.java +++ b/openaev-api/src/main/java/io/openaev/service/EndpointService.java @@ -429,6 +429,7 @@ public List syncAgentsEndpoints( endpointToSave.setTenant(new Tenant(inputToSave.getExecutor().getTenantId())); agentToUpdate.setAsset(endpointToSave); agentToUpdate.setLastSeen(inputToSave.getLastSeen()); + agentToUpdate.setStatus(AgentStatus.ACTIVE); endpointsToSave.add(endpointToSave); agentsToSave.add(agentToUpdate); inputs.removeIf( @@ -819,6 +820,7 @@ private void setUpdatedEndpointAttributes(Endpoint endpoint, AgentRegisterInput private void setUpdatedAgentAttributes(Agent agent, AgentRegisterInput input, Endpoint endpoint) { agent.setAsset(endpoint); agent.setLastSeen(input.getLastSeen()); + agent.setStatus(AgentStatus.ACTIVE); agent.setExternalReference(input.getExternalReference()); // For OpenAEV agent agent.setVersion(input.getAgentVersion()); @@ -846,6 +848,7 @@ private void setNewAgentAttributes(AgentRegisterInput input, Agent agent) { input.isService() ? Agent.DEPLOYMENT_MODE.service : Agent.DEPLOYMENT_MODE.session); agent.setExecutedByUser(input.getExecutedByUser()); agent.setExecutor(input.getExecutor()); + agent.setStatus(AgentStatus.ACTIVE); agent.setTenant(new Tenant(input.getExecutor().getTenantId())); } diff --git a/openaev-api/src/main/java/io/openaev/service/autonomous/CapabilityResolverService.java b/openaev-api/src/main/java/io/openaev/service/autonomous/CapabilityResolverService.java index abe172490da..cc7ec6a1efa 100644 --- a/openaev-api/src/main/java/io/openaev/service/autonomous/CapabilityResolverService.java +++ b/openaev-api/src/main/java/io/openaev/service/autonomous/CapabilityResolverService.java @@ -7,6 +7,7 @@ import io.openaev.api.autonomous.dto.CapabilityResolution; import io.openaev.api.autonomous.dto.CapabilityResolution.ResolvedContract; import io.openaev.api.autonomous.dto.CapabilityResolution.SuggestedConnector; +import io.openaev.database.model.AgentStatus; import io.openaev.database.model.AttackPattern; import io.openaev.database.model.CatalogConnector; import io.openaev.database.model.ContractOutputType; @@ -17,7 +18,6 @@ import io.openaev.database.repository.CatalogConnectorRepository; import io.openaev.database.repository.InjectorContractRepository; import io.openaev.database.repository.InjectorRepository; -import io.openaev.helper.AgentHelper; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; @@ -162,10 +162,7 @@ private ArsenalInventory buildArsenalInventory() { } } - int activeAgents = - (int) - agentRepository.countByLastSeenAfter( - Instant.ofEpochMilli(now - AgentHelper.ACTIVE_THRESHOLD)); + int activeAgents = (int) agentRepository.countByStatus(AgentStatus.ACTIVE); boolean commandDelivery = activeAgents > 0; return new ArsenalInventory( diff --git a/openaev-api/src/main/resources/application.properties b/openaev-api/src/main/resources/application.properties index 70d88d4ee17..7bc8379b370 100644 --- a/openaev-api/src/main/resources/application.properties +++ b/openaev-api/src/main/resources/application.properties @@ -160,6 +160,8 @@ openaev.queue-config.workflows-update.consumer-qos=1000 openaev.queue-config.workflows-update.publisher-qos=0 openaev.cron.config.steps.delay.queue.polling.interval=10000 +# Agent inactivity monitor polling interval in minutes +openaev.cron.config.agent.inactivity.monitor.interval=5 # Reporting module: server-side rendering (Playwright + in-container headless Chromium) ## Hard cap on a single render (navigation + readiness wait + capture), in seconds diff --git a/openaev-api/src/test/java/io/openaev/executors/execution/service/ExecutionExecutorServiceTest.java b/openaev-api/src/test/java/io/openaev/executors/execution/service/ExecutionExecutorServiceTest.java index 7838e1f3456..4c5686aee6e 100644 --- a/openaev-api/src/test/java/io/openaev/executors/execution/service/ExecutionExecutorServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/executors/execution/service/ExecutionExecutorServiceTest.java @@ -306,6 +306,7 @@ private Inject createInjectWithActiveAgent(Executor executor) throws JsonProcess Agent agent = AgentFixture.createDefaultAgentSession(executor); agent.setAsset(endpoint); agent.setLastSeen(Instant.now()); + agent.setStatus(AgentStatus.ACTIVE); endpoint.setAgents(List.of(agent)); Command payloadCommand = PayloadFixture.createCommand("cmd", "whoami", List.of(), "whoami"); @@ -515,12 +516,14 @@ void given_agentsOnTwoExecutors_should_dispatchToEachSeparately() throws Excepti Agent agent1 = AgentFixture.createDefaultAgentSession(executor1); agent1.setAsset(endpoint1); agent1.setLastSeen(Instant.now()); + agent1.setStatus(AgentStatus.ACTIVE); Endpoint endpoint2 = EndpointFixture.createEndpoint(); endpoint2.setId("endpoint-2"); Agent agent2 = AgentFixture.createDefaultAgentSession(executor2); agent2.setAsset(endpoint2); agent2.setLastSeen(Instant.now()); + agent2.setStatus(AgentStatus.ACTIVE); Command payloadCommand = PayloadFixture.createCommand("cmd", "whoami", List.of(), "whoami"); Injector injector = InjectorFixture.createDefaultPayloadInjector(); diff --git a/openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobIntegrationTest.java b/openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobIntegrationTest.java new file mode 100644 index 00000000000..3ba4565cc35 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobIntegrationTest.java @@ -0,0 +1,122 @@ +package io.openaev.scheduler.jobs; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.openaev.IntegrationTest; +import io.openaev.database.model.Agent; +import io.openaev.database.model.AgentStatus; +import io.openaev.database.model.Endpoint; +import io.openaev.database.model.Tenant; +import io.openaev.database.repository.AgentRepository; +import io.openaev.database.repository.EndpointRepository; +import io.openaev.utils.fixtures.AgentFixture; +import io.openaev.utils.fixtures.EndpointFixture; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Transactional +class AgentInactivityMonitorJobIntegrationTest extends IntegrationTest { + + @Autowired private AgentInactivityMonitorJob agentInactivityMonitorJob; + @Autowired private EndpointRepository endpointRepository; + @Autowired private AgentRepository agentRepository; + @Autowired private PlatformTransactionManager transactionManager; + + private void inTransaction(Runnable work) { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> work.run()); + } + + @Test + @DisplayName("given agent lifecycle should transition inactive active inactive") + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void given_agentLifecycle_should_transitionInactiveActiveInactive() { + String[] ids = new String[2]; + + try { + // Arrange + inTransaction( + () -> { + Endpoint endpoint = EndpointFixture.createEndpoint("lifecycle-endpoint"); + endpoint.setTenant(new Tenant(Tenant.DEFAULT_TENANT_UUID)); + endpointRepository.save(endpoint); + + Agent agent = AgentFixture.createDefaultAgentService(); + agent.setAsset(endpoint); + agent.setTenant(endpoint.getTenant()); + agent.setStatus(AgentStatus.ACTIVE); + agent.setLastSeen(Instant.now().minus(2, ChronoUnit.HOURS)); + agentRepository.save(agent); + + ids[0] = endpoint.getId(); + ids[1] = agent.getId(); + }); + + // Act 1: stale active agent becomes inactive + agentInactivityMonitorJob.execute(null); + + // Assert 1 + inTransaction( + () -> { + Agent persisted = agentRepository.findById(ids[1]).orElseThrow(); + assertThat(persisted.getStatus()).isEqualTo(AgentStatus.INACTIVE); + }); + + // Arrange 2: simulate heartbeat recovery (reactivation) + inTransaction( + () -> { + Agent recovered = agentRepository.findById(ids[1]).orElseThrow(); + recovered.setStatus(AgentStatus.ACTIVE); + recovered.setLastSeen(Instant.now()); + agentRepository.save(recovered); + }); + + // Act 2: recent active agent must stay active + agentInactivityMonitorJob.execute(null); + + // Assert 2 + inTransaction( + () -> { + Agent persisted = agentRepository.findById(ids[1]).orElseThrow(); + assertThat(persisted.getStatus()).isEqualTo(AgentStatus.ACTIVE); + }); + + // Arrange 3: stale again + inTransaction( + () -> { + Agent staleAgain = agentRepository.findById(ids[1]).orElseThrow(); + staleAgain.setLastSeen(Instant.now().minus(2, ChronoUnit.HOURS)); + agentRepository.save(staleAgain); + }); + + // Act 3: stale active agent becomes inactive again + agentInactivityMonitorJob.execute(null); + + // Assert 3 + inTransaction( + () -> { + Agent persisted = agentRepository.findById(ids[1]).orElseThrow(); + assertThat(persisted.getStatus()).isEqualTo(AgentStatus.INACTIVE); + }); + + } finally { + inTransaction( + () -> { + if (ids[1] != null) { + agentRepository.deleteById(ids[1]); + } + if (ids[0] != null) { + endpointRepository.deleteById(ids[0]); + } + }); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobTest.java b/openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobTest.java new file mode 100644 index 00000000000..b54562529e1 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobTest.java @@ -0,0 +1,133 @@ +package io.openaev.scheduler.jobs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import io.openaev.aop.audit_log.AuditEvent; +import io.openaev.aop.audit_log.AuditEventScope; +import io.openaev.aop.audit_log.AuditLogger; +import io.openaev.context.TenantScopedTransaction; +import io.openaev.database.model.Agent; +import io.openaev.database.model.AgentStatus; +import io.openaev.database.model.EventStatus; +import io.openaev.database.model.EventType; +import io.openaev.database.repository.AgentRepository; +import java.time.Instant; +import java.util.List; +import java.util.function.Consumer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AgentInactivityMonitorJob unit tests") +class AgentInactivityMonitorJobTest { + + @Mock private AgentRepository agentRepository; + @Mock private TenantScopedTransaction tenantTx; + @Mock private AuditLogger auditLogger; + + private AgentInactivityMonitorJob createJob() { + return new AgentInactivityMonitorJob( + agentRepository, tenantTx, java.util.Optional.of(auditLogger)); + } + + @Nested + @DisplayName("execute") + class Execute { + + @Test + void given_noStaleAgents_should_notSaveOrLogCoverageGap() { + // Arrange + AgentInactivityMonitorJob job = createJob(); + doAnswer( + invocation -> { + Consumer consumer = invocation.getArgument(0); + consumer.accept("tenant-a"); + return null; + }) + .when(tenantTx) + .forEachTenant(any()); + when(agentRepository.findStaleAgentsByStatus(any(Instant.class), eq(AgentStatus.ACTIVE))) + .thenReturn(List.of()); + + // Act + job.execute(null); + + // Assert + verify(agentRepository, never()).saveAll(any()); + verifyNoInteractions(auditLogger); + } + + @Test + void given_staleActiveAgent_should_markInactiveSaveAndEmitCoverageGap() { + // Arrange + AgentInactivityMonitorJob job = createJob(); + doAnswer( + invocation -> { + Consumer consumer = invocation.getArgument(0); + consumer.accept("tenant-a"); + return null; + }) + .when(tenantTx) + .forEachTenant(any()); + + Agent staleAgent = new Agent(); + staleAgent.setId("agent-1"); + staleAgent.setLastSeen(Instant.now().minusSeconds(7_200)); + + when(agentRepository.findStaleAgentsByStatus(any(Instant.class), eq(AgentStatus.ACTIVE))) + .thenReturn(List.of(staleAgent)); + + // Act + job.execute(null); + + // Assert + assertThat(staleAgent.getStatus()).isEqualTo(AgentStatus.INACTIVE); + verify(agentRepository).saveAll(List.of(staleAgent)); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(AuditEvent.class); + verify(auditLogger).logEvent(eventCaptor.capture()); + AuditEvent event = eventCaptor.getValue(); + assertThat(event.getEventType()).isEqualTo(EventType.EXECUTION); + assertThat(event.getEventScope()).isEqualTo(AuditEventScope.COVERAGE_GAP); + assertThat(event.getEventStatus()).isEqualTo(EventStatus.WARNING); + assertThat(event.getResourceId()).isEqualTo("agent-1"); + assertThat(event.getContextData()) + .containsEntry("previous_status", "ACTIVE") + .containsEntry("new_status", "INACTIVE"); + } + + @Test + void given_alreadyInactivePopulation_should_notEmitDuplicateCoverageGap() { + // Arrange + AgentInactivityMonitorJob job = createJob(); + doAnswer( + invocation -> { + Consumer consumer = invocation.getArgument(0); + consumer.accept("tenant-a"); + return null; + }) + .when(tenantTx) + .forEachTenant(any()); + when(agentRepository.findStaleAgentsByStatus(any(Instant.class), eq(AgentStatus.ACTIVE))) + .thenReturn(List.of()); + + // Act + job.execute(null); + job.execute(null); + + // Assert + verify(agentRepository, times(2)) + .findStaleAgentsByStatus(any(Instant.class), eq(AgentStatus.ACTIVE)); + verify(agentRepository, never()).saveAll(any()); + verifyNoInteractions(auditLogger); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/scheduler/jobs/InjectsExecutionJobTest.java b/openaev-api/src/test/java/io/openaev/scheduler/jobs/InjectsExecutionJobTest.java index bdefd91d246..6934b080eb1 100644 --- a/openaev-api/src/test/java/io/openaev/scheduler/jobs/InjectsExecutionJobTest.java +++ b/openaev-api/src/test/java/io/openaev/scheduler/jobs/InjectsExecutionJobTest.java @@ -471,9 +471,11 @@ void given_injectTargets_should_logTargetResolutionWithEndpointStatuses() throws AgentComposer.Composer activeAgentComposer = agentComposer.forAgent(AgentFixture.createDefaultAgentService()); activeAgentComposer.get().setLastSeen(Instant.now()); + activeAgentComposer.get().setStatus(AgentStatus.ACTIVE); AgentComposer.Composer inactiveAgentComposer = agentComposer.forAgent(AgentFixture.createDefaultAgentSession()); inactiveAgentComposer.get().setLastSeen(Instant.now().minus(2, ChronoUnit.HOURS)); + inactiveAgentComposer.get().setStatus(AgentStatus.INACTIVE); EndpointComposer.Composer endpointWithoutAgentComposer = endpointComposer.forEndpoint(EndpointFixture.createEndpoint()); diff --git a/openaev-api/src/test/java/io/openaev/service/EndpointServiceTest.java b/openaev-api/src/test/java/io/openaev/service/EndpointServiceTest.java index 6b95523d6fc..7e33d83ff39 100644 --- a/openaev-api/src/test/java/io/openaev/service/EndpointServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/service/EndpointServiceTest.java @@ -164,40 +164,40 @@ void given_endpointWithOtherExecutorSourceTag_should_addNewTagAndPreserveExistin } @Test - @DisplayName("given inactive agent should remove source tag and not add it") - void given_inactiveAgent_should_removeSourceTagAndNotAdd() { + @DisplayName("given inactive agent heartbeat should reactivate and restore source tag") + void given_inactiveAgentHeartbeat_should_reactivateAndAddSourceTag() { // Arrange Executor csExecutor = createExecutor("CrowdStrike", "openaev_crowdstrike"); - AgentRegisterInput input = createAgentRegisterInput(csExecutor, "cs-device-inactive"); - input.setLastSeen(Instant.now().minusSeconds(7200)); + AgentRegisterInput input = createAgentRegisterInput(csExecutor, "cs-device-reactivate"); + input.setLastSeen(Instant.now()); Endpoint existingEndpoint = EndpointFixture.createEndpoint(); existingEndpoint.setId("inactive-endpoint-id"); existingEndpoint.setTenant(new Tenant(TENANT_ID)); - Agent existingAgent = AgentFixture.createAgent(existingEndpoint, "cs-device-inactive"); + Agent existingAgent = AgentFixture.createAgent(existingEndpoint, "cs-device-reactivate"); existingAgent.setExecutor(csExecutor); + existingAgent.setStatus(AgentStatus.INACTIVE); Tag csTag = new Tag(); csTag.setName("source:crowdstrike"); + csTag.setColor("#FF0000"); when(agentService.saveAllAgents(any())).thenAnswer(inv -> inv.getArgument(0)); - when(endpointRepository.findAllById(Set.of("inactive-endpoint-id"))) - .thenReturn(List.of(existingEndpoint)); - when(tagRepository.findByAssetIdAndTenantId("inactive-endpoint-id", TENANT_ID)) - .thenReturn(new HashSet<>(Set.of(csTag))); + when(tagRepository.findByNameAndTenantId("source:crowdstrike", TENANT_ID)) + .thenReturn(Optional.of(csTag)); // Act endpointService.syncAgentsEndpoints( new ArrayList<>(List.of(input)), List.of(existingAgent), TENANT_ID); // Assert - verify(tagRepository, never()).findByNameAndTenantId("source:crowdstrike", TENANT_ID); - ArgumentCaptor> savedEndpoints = ArgumentCaptor.forClass(List.class); - verify(endpointRepository).saveAll(savedEndpoints.capture()); - assertThat(savedEndpoints.getValue().getFirst().getTags()) + verify(endpointRepository, never()).saveAll(any()); + verify(endpointRepository).save(existingEndpoint); + assertThat(existingAgent.getStatus()).isEqualTo(AgentStatus.ACTIVE); + assertThat(existingEndpoint.getTags()) .extracting(Tag::getName) - .doesNotContain("source:crowdstrike"); + .contains("source:crowdstrike"); } } diff --git a/openaev-api/src/test/java/io/openaev/utils/fixtures/AgentFixture.java b/openaev-api/src/test/java/io/openaev/utils/fixtures/AgentFixture.java index 4fb21e82c1f..2cc317172d4 100644 --- a/openaev-api/src/test/java/io/openaev/utils/fixtures/AgentFixture.java +++ b/openaev-api/src/test/java/io/openaev/utils/fixtures/AgentFixture.java @@ -2,6 +2,7 @@ import io.openaev.context.TenantContext; import io.openaev.database.model.Agent; +import io.openaev.database.model.AgentStatus; import io.openaev.database.model.Asset; import io.openaev.database.model.Executor; import io.openaev.database.model.Tenant; @@ -44,6 +45,7 @@ public static Agent createAgent(Asset asset, String externalReference) { public static Agent createInactiveAgent() { Agent agent = createDefaultAgentService(); agent.setLastSeen(Instant.now().minusSeconds(3600 * 24 * 30)); + agent.setStatus(AgentStatus.INACTIVE); return agent; } } diff --git a/openaev-api/src/test/resources/application.properties b/openaev-api/src/test/resources/application.properties index 1edf773c60e..34438b8da39 100644 --- a/openaev-api/src/test/resources/application.properties +++ b/openaev-api/src/test/resources/application.properties @@ -65,6 +65,7 @@ openaev.queue-config.workflows-update.consumer-qos=30 openaev.queue-config.workflows-update.publisher-qos=0 openaev.cron.config.steps.delay.queue.polling.interval=10000 +openaev.cron.config.agent.inactivity.monitor.interval=5 # Session management (schema created by Flyway; built-in cleanup replaced by SessionManager) diff --git a/openaev-model/src/main/java/io/openaev/database/model/Agent.java b/openaev-model/src/main/java/io/openaev/database/model/Agent.java index 99ccc567692..cc83b999fc3 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/Agent.java +++ b/openaev-model/src/main/java/io/openaev/database/model/Agent.java @@ -10,7 +10,6 @@ import io.openaev.database.audit.AuditStateIgnore; import io.openaev.database.audit.ModelBaseListener; import io.openaev.database.audit.TenantBaseListener; -import io.openaev.helper.AgentHelper; import io.openaev.helper.MonoIdSerializer; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.persistence.*; @@ -34,6 +33,7 @@ @Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") public class Agent implements TenantBase, AuditStateCapturable { + public static final long ACTIVE_THRESHOLD_MILLIS = 3_600_000L; public static final String ADMIN_SYSTEM_WINDOWS = "nt authority\\system"; public static final String ADMIN_SYSTEM_UNIX = "root"; @@ -125,9 +125,15 @@ public enum DEPLOYMENT_MODE { @JsonProperty("agent_active") public boolean isActive() { - return new AgentHelper().isAgentActiveFromLastSeen(this.getLastSeen()); + return this.status == AgentStatus.ACTIVE; } + @AuditStateIgnore + @Column(name = "agent_status", nullable = false) + @Enumerated(EnumType.STRING) + @JsonIgnore + private AgentStatus status = AgentStatus.ACTIVE; + /** Used for Caldera only */ @Column(name = "agent_process_name") @JsonProperty("agent_process_name") diff --git a/openaev-model/src/main/java/io/openaev/database/model/AgentStatus.java b/openaev-model/src/main/java/io/openaev/database/model/AgentStatus.java new file mode 100644 index 00000000000..12156aeac77 --- /dev/null +++ b/openaev-model/src/main/java/io/openaev/database/model/AgentStatus.java @@ -0,0 +1,6 @@ +package io.openaev.database.model; + +public enum AgentStatus { + ACTIVE, + INACTIVE +} diff --git a/openaev-model/src/main/java/io/openaev/database/model/Asset.java b/openaev-model/src/main/java/io/openaev/database/model/Asset.java index 9d500115f0b..0609139889b 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/Asset.java +++ b/openaev-model/src/main/java/io/openaev/database/model/Asset.java @@ -297,15 +297,15 @@ public void setHostname(String hostname) { // Read-only activity status derived from the agents linked to this asset (agents live on the // Endpoint subclass, but the correlated subquery works on the base assets table for every - // category - non-agent assets simply resolve to AGENTLESS). The active window mirrors - // AgentHelper.ACTIVE_THRESHOLD (1 hour). Filterable so the inventory can filter by status. + // category - non-agent assets simply resolve to AGENTLESS). Filterable so the inventory can + // filter by status. @Queryable(filterable = true, sortable = true, refEnumClazz = ASSET_ACTIVITY_STATUS.class) @Formula( "(CASE" + " WHEN NOT EXISTS (SELECT 1 FROM agents ag WHERE ag.agent_asset = asset_id)" + " THEN 'AGENTLESS'" + " WHEN EXISTS (SELECT 1 FROM agents ag WHERE ag.agent_asset = asset_id" - + " AND ag.agent_last_seen > now() - interval '1 hour') THEN 'ACTIVE'" + + " AND ag.agent_status = 'ACTIVE') THEN 'ACTIVE'" + " ELSE 'INACTIVE' END)") @Enumerated(EnumType.STRING) @JsonProperty("asset_status") diff --git a/openaev-model/src/main/java/io/openaev/database/raw/RawVulnerableEndpointIndexing.java b/openaev-model/src/main/java/io/openaev/database/raw/RawVulnerableEndpointIndexing.java index 02b502e930b..0b79b204ce8 100644 --- a/openaev-model/src/main/java/io/openaev/database/raw/RawVulnerableEndpointIndexing.java +++ b/openaev-model/src/main/java/io/openaev/database/raw/RawVulnerableEndpointIndexing.java @@ -29,8 +29,7 @@ public interface RawVulnerableEndpointIndexing extends RawTenant { List getVulnerable_endpoint_agents_privileges(); - // cannot use Instant class directly because of "Projection type must be an interface" - List getVulnerable_endpoint_agents_last_seen(); + List getVulnerable_endpoint_agents_statuses(); Set getVulnerable_endpoint_findings(); diff --git a/openaev-model/src/main/java/io/openaev/database/repository/AgentRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/AgentRepository.java index 44fcc48e030..a766418a7e8 100644 --- a/openaev-model/src/main/java/io/openaev/database/repository/AgentRepository.java +++ b/openaev-model/src/main/java/io/openaev/database/repository/AgentRepository.java @@ -1,19 +1,21 @@ package io.openaev.database.repository; import io.openaev.database.model.Agent; +import io.openaev.database.model.AgentStatus; +import java.time.Instant; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @Repository public interface AgentRepository - extends CrudRepository, JpaSpecificationExecutor { + extends JpaRepository, JpaSpecificationExecutor { @Query( value = @@ -48,11 +50,12 @@ void deleteAllByExecutorIdAndTenantId( List findByExternalReferenceAndTenantId(String externalReference, String tenantId); - // Count agents whose last heartbeat is recent enough to be considered active. Used by the - // autonomous capability resolver to tell the orchestrator whether a crafted Command payload has - // any live host to execute on (tenant filter applies via the enclosing transactional session). - @Query("SELECT COUNT(a) FROM Agent a WHERE a.lastSeen > :since") - long countByLastSeenAfter(@Param("since") java.time.Instant since); + long countByStatus(AgentStatus status); + + @Query( + "SELECT a FROM Agent a WHERE a.status = :status AND (a.lastSeen IS NULL OR a.lastSeen < :threshold)") + List findStaleAgentsByStatus( + @Param("threshold") Instant threshold, @Param("status") AgentStatus status); @Modifying @Query(value = "DELETE FROM agents agent where agent.agent_id = :agentId;", nativeQuery = true) diff --git a/openaev-model/src/main/java/io/openaev/database/repository/VulnerableEndpointRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/VulnerableEndpointRepository.java index 2f219492683..eae5aa1bdb9 100644 --- a/openaev-model/src/main/java/io/openaev/database/repository/VulnerableEndpointRepository.java +++ b/openaev-model/src/main/java/io/openaev/database/repository/VulnerableEndpointRepository.java @@ -79,8 +79,8 @@ ORDER BY GREATEST(e.exercise_updated_at, a.asset_updated_at, fm.max_finding) ASC (SELECT array_agg(ag.agent_id) FILTER (WHERE ag.agent_id IS NOT NULL) FROM agents ag WHERE ag.agent_asset = a.asset_id) as vulnerable_endpoint_agents, array_agg(f.finding_id) FILTER ( WHERE f.finding_id IS NOT NULL AND f.finding_type = 'CVE' ) as vulnerable_endpoint_cves, - (SELECT array_agg(ag.agent_last_seen) FILTER (WHERE ag.agent_id IS NOT NULL) - FROM agents ag WHERE ag.agent_asset = a.asset_id) as vulnerable_endpoint_agents_last_seen, + (SELECT array_agg(ag.agent_status) FILTER (WHERE ag.agent_id IS NOT NULL) + FROM agents ag WHERE ag.agent_asset = a.asset_id) as vulnerable_endpoint_agents_statuses, (SELECT array_agg(ag.agent_privilege) FILTER (WHERE ag.agent_id IS NOT NULL) FROM agents ag WHERE ag.agent_asset = a.asset_id) as vulnerable_endpoint_agents_privileges FROM ranked_vulnerable_endpoints rve diff --git a/openaev-model/src/main/java/io/openaev/engine/model/vulnerableendpoint/VulnerableEndpointHandler.java b/openaev-model/src/main/java/io/openaev/engine/model/vulnerableendpoint/VulnerableEndpointHandler.java index 41257d0f08b..cf3170fed26 100644 --- a/openaev-model/src/main/java/io/openaev/engine/model/vulnerableendpoint/VulnerableEndpointHandler.java +++ b/openaev-model/src/main/java/io/openaev/engine/model/vulnerableendpoint/VulnerableEndpointHandler.java @@ -7,9 +7,7 @@ import io.openaev.database.repository.FindingRepository; import io.openaev.database.repository.VulnerableEndpointRepository; import io.openaev.engine.Handler; -import io.openaev.helper.AgentHelper; import java.time.Instant; -import java.time.ZoneOffset; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -59,24 +57,13 @@ public List fetch(Instant from, int limit) { endpoint.getVulnerable_endpoint_agents_privileges()); // endpoint status - if (endpoint.getVulnerable_endpoint_agents_last_seen() == null - || endpoint.getVulnerable_endpoint_agents_last_seen().isEmpty()) { + if (endpoint.getVulnerable_endpoint_agents_statuses() == null + || endpoint.getVulnerable_endpoint_agents_statuses().isEmpty()) { esVulnerableEndpoint.setVulnerable_endpoint_agents_active_status(List.of()); } else { - AgentHelper agentHelper = new AgentHelper(); esVulnerableEndpoint.setVulnerable_endpoint_agents_active_status( - endpoint.getVulnerable_endpoint_agents_last_seen().stream() - .map( - status -> - agentHelper.isAgentActiveFromLastSeen( - /* - timestamp in database has no timezone but was recorded as UTC - first convert to a LocalDateTime (which has no timezone) and - then assign a UTC timezone without changing the numbers. - The .toInstant() method wasn't suitable as it assumed the numbers - were of the local timezone and was doing an offset to convert to UTC. - */ - status.toLocalDateTime().toInstant(ZoneOffset.UTC))) + endpoint.getVulnerable_endpoint_agents_statuses().stream() + .map("ACTIVE"::equals) .toList()); } diff --git a/openaev-model/src/main/java/io/openaev/helper/AgentHelper.java b/openaev-model/src/main/java/io/openaev/helper/AgentHelper.java deleted file mode 100644 index 5cfaf6f2679..00000000000 --- a/openaev-model/src/main/java/io/openaev/helper/AgentHelper.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.openaev.helper; - -import static java.time.Instant.now; - -import java.time.Instant; - -/** - * Helper class for agent-related operations and status calculations. - * - *

This class provides utility methods for determining agent connectivity status based on their - * last communication timestamp. - * - * @see io.openaev.database.model.Agent - */ -public class AgentHelper { - - /** Threshold in milliseconds to consider an agent as active (1 hour = 3,600,000 ms). */ - public static final int ACTIVE_THRESHOLD = 3600000; - - /** - * Determines if an agent is currently active based on its last seen timestamp. - * - *

An agent is considered active if it has communicated with the platform within the last hour - * (defined by {@link #ACTIVE_THRESHOLD}). - * - * @param lastSeen the timestamp of the agent's last communication, or {@code null} if never seen - * @return {@code true} if the agent is considered active, {@code false} otherwise - */ - public boolean isAgentActiveFromLastSeen(Instant lastSeen) { - return lastSeen != null && (now().toEpochMilli() - lastSeen.toEpochMilli()) < ACTIVE_THRESHOLD; - } -} From 08d2c6ef63b532e7b6b0d729a485134063f37c6d Mon Sep 17 00:00:00 2001 From: Damien Goujard Date: Tue, 18 Aug 2026 16:21:04 +0200 Subject: [PATCH 2/6] [backend] Spotless --- .../openaev/scheduler/jobs/AgentInactivityMonitorJobTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobTest.java b/openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobTest.java index b54562529e1..671a79953b7 100644 --- a/openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobTest.java +++ b/openaev-api/src/test/java/io/openaev/scheduler/jobs/AgentInactivityMonitorJobTest.java @@ -35,7 +35,7 @@ class AgentInactivityMonitorJobTest { private AgentInactivityMonitorJob createJob() { return new AgentInactivityMonitorJob( - agentRepository, tenantTx, java.util.Optional.of(auditLogger)); + agentRepository, tenantTx, java.util.Optional.of(auditLogger)); } @Nested From b45b6a931ccf81aedc0611f1529fd107d85622c8 Mon Sep 17 00:00:00 2001 From: Damien Goujard Date: Tue, 18 Aug 2026 17:09:36 +0200 Subject: [PATCH 3/6] [backend] Migration --- ...nts.java => V6_20260818200000000__Add_status_to_agents.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename openaev-api/src/main/java/io/openaev/migration/{V6_20260818100000000__Add_status_to_agents.java => V6_20260818200000000__Add_status_to_agents.java} (93%) diff --git a/openaev-api/src/main/java/io/openaev/migration/V6_20260818100000000__Add_status_to_agents.java b/openaev-api/src/main/java/io/openaev/migration/V6_20260818200000000__Add_status_to_agents.java similarity index 93% rename from openaev-api/src/main/java/io/openaev/migration/V6_20260818100000000__Add_status_to_agents.java rename to openaev-api/src/main/java/io/openaev/migration/V6_20260818200000000__Add_status_to_agents.java index b8d6cd8236e..702eb80c21c 100644 --- a/openaev-api/src/main/java/io/openaev/migration/V6_20260818100000000__Add_status_to_agents.java +++ b/openaev-api/src/main/java/io/openaev/migration/V6_20260818200000000__Add_status_to_agents.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component; @Component -public class V6_20260818100000000__Add_status_to_agents extends BaseJavaMigration { +public class V6_20260818200000000__Add_status_to_agents extends BaseJavaMigration { @Override public void migrate(Context context) throws Exception { From 184794162a728146a1ee708c50caffe16eca101b Mon Sep 17 00:00:00 2001 From: damgouj Date: Wed, 19 Aug 2026 09:07:00 +0200 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../migration/V6_20260818200000000__Add_status_to_agents.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openaev-api/src/main/java/io/openaev/migration/V6_20260818200000000__Add_status_to_agents.java b/openaev-api/src/main/java/io/openaev/migration/V6_20260818200000000__Add_status_to_agents.java index 702eb80c21c..31cebc1af55 100644 --- a/openaev-api/src/main/java/io/openaev/migration/V6_20260818200000000__Add_status_to_agents.java +++ b/openaev-api/src/main/java/io/openaev/migration/V6_20260818200000000__Add_status_to_agents.java @@ -17,9 +17,10 @@ public void migrate(Context context) throws Exception { "UPDATE agents SET agent_status = 'INACTIVE'" + " WHERE agent_last_seen IS NULL" + " OR agent_last_seen < NOW() - INTERVAL '1 hour';"); + statement.execute("DROP INDEX IF EXISTS idx_agents_status_last_seen;"); statement.execute( "CREATE INDEX IF NOT EXISTS idx_agents_status_last_seen" - + " ON agents(agent_status, agent_last_seen);"); + + " ON agents(tenant_id, agent_status, agent_last_seen);"); } } } From 37fa3026ec8d30b6d56f4794d57340049d241022 Mon Sep 17 00:00:00 2001 From: Damien Goujard Date: Tue, 25 Aug 2026 15:37:44 +0200 Subject: [PATCH 5/6] [backend] Gael's comments --- .../io/openaev/utils/object/ObjectRedactionUtils.java | 10 ++++++++++ openaev-front/src/utils/api-types.d.ts | 1 + .../src/main/java/io/openaev/database/model/Agent.java | 1 - 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java b/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java index 4e4c90ab9b2..74b141a4002 100644 --- a/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java +++ b/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java @@ -66,6 +66,9 @@ private ObjectRedactionUtils() {} private static final Set USER_ENTITY_TYPES = Set.of(ResourceType.USER, ResourceType.PLATFORM_USER, ResourceType.PLAYER); + /** Non-sensitive fields removed from audit payloads to avoid noisy diffs. */ + private static final Set AUDIT_FIELDS_TO_REMOVE = Set.of("asset_status"); + /** * Redacts sensitive field values in a JSON tree. Operates on a deep copy — the original is never * modified. @@ -137,6 +140,9 @@ private static ArrayNode redactArrayNode(ArrayNode original, boolean isUserEntit private static void redactProperty( ObjectNode result, String key, JsonNode value, boolean isUserEntity) { String fieldName = key.toLowerCase(Locale.ROOT); + if (shouldDropField(fieldName)) { + return; + } if (isUserEntity && USER_PII_FIELDS_TO_REMOVE.contains(fieldName)) { return; } @@ -154,6 +160,10 @@ private static void redactProperty( result.set(key, redactNode(value, isUserEntity)); } + private static boolean shouldDropField(String fieldName) { + return AUDIT_FIELDS_TO_REMOVE.contains(fieldName); + } + private static boolean shouldHash(String fieldName) { return matchesAnyRegex(fieldName, SENSITIVE_FIELDS_REGEX_TO_HASH) && !matchesAnyRegex(fieldName, ALLOWED_SENSITIVE_FIELDS_REGEX_TO_HASH); diff --git a/openaev-front/src/utils/api-types.d.ts b/openaev-front/src/utils/api-types.d.ts index 3d7e9dac22b..9217da52935 100644 --- a/openaev-front/src/utils/api-types.d.ts +++ b/openaev-front/src/utils/api-types.d.ts @@ -83,6 +83,7 @@ export interface Agent { agent_updated_at: string; agent_version?: string; listened?: boolean; + status?: "ACTIVE" | "INACTIVE"; } export interface AgentCallInput { diff --git a/openaev-model/src/main/java/io/openaev/database/model/Agent.java b/openaev-model/src/main/java/io/openaev/database/model/Agent.java index cc83b999fc3..de00e19811f 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/Agent.java +++ b/openaev-model/src/main/java/io/openaev/database/model/Agent.java @@ -131,7 +131,6 @@ public boolean isActive() { @AuditStateIgnore @Column(name = "agent_status", nullable = false) @Enumerated(EnumType.STRING) - @JsonIgnore private AgentStatus status = AgentStatus.ACTIVE; /** Used for Caldera only */ From df9cd75562f5b0cfad3c4eb785a865ff85c248e3 Mon Sep 17 00:00:00 2001 From: Gael Leblan Date: Wed, 26 Aug 2026 10:44:41 +0200 Subject: [PATCH 6/6] feat(audit-log): add an annotation to ignore a property to audit log (#7034) --- .../AccessControlAuditLogAspect.java | 7 ++-- .../aop/audit_log/AuditObjectMapper.java | 35 +++++++++++++++++++ .../utils/object/ObjectRedactionUtils.java | 10 ------ ...ccessControlAuditLogAspectPayloadTest.java | 5 ++- .../aop/audit_log/AuditObjectMapperTest.java | 33 +++++++++++++++++ .../database/audit/AuditLogIgnore.java | 11 ++++++ .../java/io/openaev/database/model/Asset.java | 2 ++ 7 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java create mode 100644 openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java create mode 100644 openaev-model/src/main/java/io/openaev/database/audit/AuditLogIgnore.java diff --git a/openaev-api/src/main/java/io/openaev/aop/audit_log/AccessControlAuditLogAspect.java b/openaev-api/src/main/java/io/openaev/aop/audit_log/AccessControlAuditLogAspect.java index bd5bb7e9cde..2204a42447c 100644 --- a/openaev-api/src/main/java/io/openaev/aop/audit_log/AccessControlAuditLogAspect.java +++ b/openaev-api/src/main/java/io/openaev/aop/audit_log/AccessControlAuditLogAspect.java @@ -70,6 +70,7 @@ public class AccessControlAuditLogAspect { private final AuditLogger auditLogger; private final ObjectMapper objectMapper; + private final AuditObjectMapper auditObjectMapper; private final ExpressionParser parser = new SpelExpressionParser(); @PersistenceContext private EntityManager entityManager; @@ -249,12 +250,12 @@ private JsonNode getInputNode(JoinPoint joinPoint, int payloadIndex, AuditEventS } Object requestBody = joinPoint.getArgs()[payloadIndex]; - return requestBody != null ? objectMapper.valueToTree(requestBody) : null; + return requestBody != null ? auditObjectMapper.valueToTree(requestBody) : null; } private JsonNode getOutputNode(Object output) { try { - return output != null ? objectMapper.valueToTree(output) : null; + return output != null ? auditObjectMapper.valueToTree(output) : null; } catch (Exception e) { log.warn("[AUDIT] Failed to serialize output: {}", e.getMessage(), e); } @@ -362,7 +363,7 @@ private void setParameterNode(ObjectNode params, String paramName, Object value) return; } try { - params.set(paramName, objectMapper.valueToTree(value)); + params.set(paramName, auditObjectMapper.valueToTree(value)); } catch (Exception ex) { params.put(paramName, value != null ? value.toString() : "null"); } diff --git a/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java b/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java new file mode 100644 index 00000000000..f2a34e319c1 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java @@ -0,0 +1,35 @@ +package io.openaev.aop.audit_log; + +import com.fasterxml.jackson.databind.AnnotationIntrospector; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.introspect.AnnotatedMember; +import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector; +import io.openaev.database.audit.AuditLogIgnore; +import org.springframework.stereotype.Component; + +/** Dedicated mapper for audit payloads that can ignore {@link AuditLogIgnore} fields. */ +@Component +public class AuditObjectMapper { + + private final ObjectMapper mapper; + + public AuditObjectMapper(ObjectMapper source) { + ObjectMapper copy = source.copy(); + AnnotationIntrospector base = copy.getSerializationConfig().getAnnotationIntrospector(); + copy.setAnnotationIntrospector( + AnnotationIntrospector.pair(new AuditLogIgnoreIntrospector(), base)); + this.mapper = copy; + } + + public JsonNode valueToTree(Object value) { + return mapper.valueToTree(value); + } + + private static class AuditLogIgnoreIntrospector extends JacksonAnnotationIntrospector { + @Override + public boolean hasIgnoreMarker(AnnotatedMember member) { + return member.hasAnnotation(AuditLogIgnore.class) || super.hasIgnoreMarker(member); + } + } +} diff --git a/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java b/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java index 74b141a4002..4e4c90ab9b2 100644 --- a/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java +++ b/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java @@ -66,9 +66,6 @@ private ObjectRedactionUtils() {} private static final Set USER_ENTITY_TYPES = Set.of(ResourceType.USER, ResourceType.PLATFORM_USER, ResourceType.PLAYER); - /** Non-sensitive fields removed from audit payloads to avoid noisy diffs. */ - private static final Set AUDIT_FIELDS_TO_REMOVE = Set.of("asset_status"); - /** * Redacts sensitive field values in a JSON tree. Operates on a deep copy — the original is never * modified. @@ -140,9 +137,6 @@ private static ArrayNode redactArrayNode(ArrayNode original, boolean isUserEntit private static void redactProperty( ObjectNode result, String key, JsonNode value, boolean isUserEntity) { String fieldName = key.toLowerCase(Locale.ROOT); - if (shouldDropField(fieldName)) { - return; - } if (isUserEntity && USER_PII_FIELDS_TO_REMOVE.contains(fieldName)) { return; } @@ -160,10 +154,6 @@ private static void redactProperty( result.set(key, redactNode(value, isUserEntity)); } - private static boolean shouldDropField(String fieldName) { - return AUDIT_FIELDS_TO_REMOVE.contains(fieldName); - } - private static boolean shouldHash(String fieldName) { return matchesAnyRegex(fieldName, SENSITIVE_FIELDS_REGEX_TO_HASH) && !matchesAnyRegex(fieldName, ALLOWED_SENSITIVE_FIELDS_REGEX_TO_HASH); diff --git a/openaev-api/src/test/java/io/openaev/aop/audit_log/AccessControlAuditLogAspectPayloadTest.java b/openaev-api/src/test/java/io/openaev/aop/audit_log/AccessControlAuditLogAspectPayloadTest.java index 6580ba775f3..411074941ba 100644 --- a/openaev-api/src/test/java/io/openaev/aop/audit_log/AccessControlAuditLogAspectPayloadTest.java +++ b/openaev-api/src/test/java/io/openaev/aop/audit_log/AccessControlAuditLogAspectPayloadTest.java @@ -92,7 +92,10 @@ void deleteWithRequestBody(@RequestBody PayloadInput input) {} @BeforeEach void setup() { - aspect = new AccessControlAuditLogAspect(auditLogger, new ObjectMapper()); + ObjectMapper objectMapper = new ObjectMapper(); + aspect = + new AccessControlAuditLogAspect( + auditLogger, objectMapper, new AuditObjectMapper(objectMapper)); inputCaptor = ArgumentCaptor.forClass(JsonNode.class); signatureCaptor = ArgumentCaptor.forClass(JsonNode.class); diff --git a/openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java b/openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java new file mode 100644 index 00000000000..acbd83a31d0 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java @@ -0,0 +1,33 @@ +package io.openaev.aop.audit_log; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.database.audit.AuditLogIgnore; +import org.junit.jupiter.api.Test; + +class AuditObjectMapperTest { + + @Test + void given_auditIgnoredField_should_excludeItFromAuditPayload_only() { + // Arrange + ObjectMapper httpMapper = new ObjectMapper(); + AuditObjectMapper auditMapper = new AuditObjectMapper(httpMapper); + SamplePayload payload = new SamplePayload("id-1", "visible", "secret"); + + // Act + var httpJson = httpMapper.valueToTree(payload); + var auditJson = auditMapper.valueToTree(payload); + + // Assert + assertThat(httpJson.path("secret_value").isMissingNode()).isFalse(); + assertThat(auditJson.path("secret_value").isMissingNode()).isTrue(); + assertThat(auditJson.path("public_value").asText()).isEqualTo("visible"); + } + + private record SamplePayload( + @JsonProperty("payload_id") String id, + @JsonProperty("public_value") String publicValue, + @AuditLogIgnore @JsonProperty("secret_value") String secretValue) {} +} diff --git a/openaev-model/src/main/java/io/openaev/database/audit/AuditLogIgnore.java b/openaev-model/src/main/java/io/openaev/database/audit/AuditLogIgnore.java new file mode 100644 index 00000000000..0721d234977 --- /dev/null +++ b/openaev-model/src/main/java/io/openaev/database/audit/AuditLogIgnore.java @@ -0,0 +1,11 @@ +package io.openaev.database.audit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks a field or getter to be excluded from audit-log payload serialization only. */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuditLogIgnore {} diff --git a/openaev-model/src/main/java/io/openaev/database/model/Asset.java b/openaev-model/src/main/java/io/openaev/database/model/Asset.java index 0609139889b..263776d8704 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/Asset.java +++ b/openaev-model/src/main/java/io/openaev/database/model/Asset.java @@ -12,6 +12,7 @@ import io.openaev.annotation.DomainConstraint; import io.openaev.annotation.Ipv4OrIpv6Constraint; import io.openaev.annotation.Queryable; +import io.openaev.database.audit.AuditLogIgnore; import io.openaev.database.audit.AuditStateIgnore; import io.openaev.database.audit.ModelBaseListener; import io.openaev.database.audit.TenantBaseListener; @@ -310,6 +311,7 @@ public void setHostname(String hostname) { @Enumerated(EnumType.STRING) @JsonProperty("asset_status") @Schema(description = "Activity status derived from agents (ACTIVE / INACTIVE / AGENTLESS)") + @AuditLogIgnore private ASSET_ACTIVITY_STATUS activityStatus; @ManyToOne