+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..67bcd63
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,71 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.3
+
+
+
+ br.fatec.esiii
+ soc-agent
+ 0.1.0-SNAPSHOT
+ soc-agent
+ Agente de triagem de incidentes de seguranca com Spring AI e modelos abertos
+
+
+
+ MIT License
+ https://opensource.org/licenses/MIT
+
+
+
+
+ 21
+ 1.0.0
+
+
+
+
+
+ org.springframework.ai
+ spring-ai-bom
+ ${spring-ai.version}
+ pom
+ import
+
+
+
+
+
+
+
+ org.springframework.ai
+ spring-ai-starter-model-ollama
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/src/main/java/br/fatec/esiii/socagent/SocAgentApplication.java b/src/main/java/br/fatec/esiii/socagent/SocAgentApplication.java
new file mode 100644
index 0000000..3017f29
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/SocAgentApplication.java
@@ -0,0 +1,17 @@
+package br.fatec.esiii.socagent;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+
+/**
+ * Ponto de entrada do agente de triagem de incidentes de seguranca.
+ */
+@SpringBootApplication
+@ConfigurationPropertiesScan
+public class SocAgentApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(SocAgentApplication.class, args);
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/ai/AnalystResponses.java b/src/main/java/br/fatec/esiii/socagent/ai/AnalystResponses.java
new file mode 100644
index 0000000..35598bb
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/ai/AnalystResponses.java
@@ -0,0 +1,40 @@
+package br.fatec.esiii.socagent.ai;
+
+import java.util.List;
+
+/**
+ * Estruturas que o modelo preenche via saida estruturada do Spring AI.
+ *
+ * Sao planas de proposito: modelos de 7B lidam mal com objetos aninhados e
+ * mapas livres, e campos nomeados reduzem a chance de alucinacao de formato.
+ */
+public final class AnalystResponses {
+
+ private AnalystResponses() {
+ }
+
+ /** Classificacao produzida pelo modelo. */
+ public record VerdictResponse(
+ String classification,
+ Double confidence,
+ String rationale,
+ List techniqueIds) {
+ }
+
+ /** Acao sugerida, com todos os argumentos possiveis como campos opcionais. */
+ public record ActionResponse(
+ String tool,
+ String hostname,
+ String ipAddress,
+ String techniqueId,
+ String reason) {
+ }
+
+ /** Plano completo devolvido em uma unica interacao. */
+ public record PlanResponse(List actions) {
+ }
+
+ /** Proximo passo do ciclo ReAct. {@code done} encerra o ciclo. */
+ public record NextStepResponse(Boolean done, ActionResponse action, String thought) {
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/ai/LlmThreatAnalyst.java b/src/main/java/br/fatec/esiii/socagent/ai/LlmThreatAnalyst.java
new file mode 100644
index 0000000..ac44db9
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/ai/LlmThreatAnalyst.java
@@ -0,0 +1,213 @@
+package br.fatec.esiii.socagent.ai;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.ai.AnalystResponses.ActionResponse;
+import br.fatec.esiii.socagent.ai.AnalystResponses.NextStepResponse;
+import br.fatec.esiii.socagent.ai.AnalystResponses.PlanResponse;
+import br.fatec.esiii.socagent.ai.AnalystResponses.VerdictResponse;
+import br.fatec.esiii.socagent.domain.Alert;
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+import br.fatec.esiii.socagent.state.Incident;
+import br.fatec.esiii.socagent.strategy.CommandFactory;
+import br.fatec.esiii.socagent.strategy.ProposedAction;
+import br.fatec.esiii.socagent.strategy.ThreatAnalyst;
+
+/**
+ * Implementacao da porta {@link ThreatAnalyst} sobre Spring AI e Ollama.
+ *
+ * Papel deliberadamente restrito: o modelo classifica, justifica e sugere
+ * nomes de ferramenta. Ele nao executa nada e nao decide se a acao acontece —
+ * isso cabe ao State, ao Command e ao aprovador humano.
+ *
+ *
Toda falha do modelo degrada para revisao humana. Em seguranca, um agente
+ * que nao consegue concluir precisa escalar, nunca adivinhar.
+ */
+@Component
+public class LlmThreatAnalyst implements ThreatAnalyst {
+
+ private static final Logger log = LoggerFactory.getLogger(LlmThreatAnalyst.class);
+
+ private static final String SYSTEM_PROMPT = """
+ Voce e um analista de seguranca de nivel 1 em um centro de operacoes (SOC).
+ Sua funcao e classificar alertas e sugerir acoes de resposta.
+
+ Regras invioláveis:
+ - Voce NAO executa acoes. Voce apenas sugere.
+ - Use exclusivamente as ferramentas desta lista: %s
+ - Se as evidencias forem insuficientes, classifique como NEEDS_HUMAN_REVIEW.
+ - Nunca invente nomes de host, enderecos IP ou identificadores de tecnica
+ que nao estejam presentes no alerta.
+ - Prefira coletar evidencia antes de propor contencao.
+ """.formatted(String.join(", ", CommandFactory.allowedTools()));
+
+ private final ChatClient chatClient;
+
+ public LlmThreatAnalyst(ChatClient.Builder builder) {
+ this.chatClient = builder.defaultSystem(SYSTEM_PROMPT).build();
+ }
+
+ @Override
+ public TriageVerdict classify(Incident incident) {
+ try {
+ VerdictResponse response = chatClient.prompt()
+ .user(u -> u.text("""
+ Classifique o incidente abaixo.
+
+ Incidente: {id}
+ Host afetado: {host}
+ Severidade maxima: {severity}
+ Alertas:
+ {alerts}
+
+ Responda com:
+ - classification: TRUE_POSITIVE, FALSE_POSITIVE ou NEEDS_HUMAN_REVIEW
+ - confidence: numero entre 0.0 e 1.0
+ - rationale: uma frase justificando
+ - techniqueIds: identificadores MITRE ATT&CK aplicaveis (ex: T1041)
+ """)
+ .param("id", incident.id())
+ .param("host", incident.affectedHost())
+ .param("severity", incident.highestSeverity().name())
+ .param("alerts", formatAlerts(incident)))
+ .call()
+ .entity(VerdictResponse.class);
+
+ return toVerdict(response);
+ } catch (RuntimeException ex) {
+ log.warn("Classificacao falhou para {}: {}", incident.id(), ex.getMessage());
+ return TriageVerdict.needsReview(
+ "O modelo nao conseguiu classificar o incidente: " + ex.getMessage());
+ }
+ }
+
+ @Override
+ public List proposePlan(Incident incident) {
+ try {
+ PlanResponse response = chatClient.prompt()
+ .user(u -> u.text("""
+ Proponha o plano de resposta para o incidente abaixo.
+
+ Host afetado: {host}
+ Alertas:
+ {alerts}
+
+ Liste de 1 a 4 acoes, em ordem de execucao, usando apenas as
+ ferramentas permitidas. Preencha somente os campos relevantes
+ para cada ferramenta e deixe os demais vazios.
+ """)
+ .param("host", incident.affectedHost())
+ .param("alerts", formatAlerts(incident)))
+ .call()
+ .entity(PlanResponse.class);
+
+ if (response == null || response.actions() == null) {
+ return List.of();
+ }
+ return response.actions().stream()
+ .map(this::toProposedAction)
+ .flatMap(Optional::stream)
+ .toList();
+ } catch (RuntimeException ex) {
+ log.warn("Planejamento falhou para {}: {}", incident.id(), ex.getMessage());
+ return List.of();
+ }
+ }
+
+ @Override
+ public Optional proposeNext(Incident incident, List observations) {
+ try {
+ NextStepResponse response = chatClient.prompt()
+ .user(u -> u.text("""
+ Voce esta investigando o incidente de forma iterativa.
+
+ Host afetado: {host}
+ Alertas:
+ {alerts}
+
+ Observacoes ja coletadas:
+ {observations}
+
+ Decida a proxima acao. Se a investigacao ja estiver completa,
+ responda com done = true e nenhuma acao.
+ """)
+ .param("host", incident.affectedHost())
+ .param("alerts", formatAlerts(incident))
+ .param("observations", observations.isEmpty()
+ ? "nenhuma ainda" : String.join("\n", observations)))
+ .call()
+ .entity(NextStepResponse.class);
+
+ if (response == null || Boolean.TRUE.equals(response.done())) {
+ return Optional.empty();
+ }
+ return toProposedAction(response.action());
+ } catch (RuntimeException ex) {
+ log.warn("Passo ReAct falhou para {}: {}", incident.id(), ex.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private String formatAlerts(Incident incident) {
+ return incident.alerts().stream()
+ .map(Alert::toPromptLine)
+ .reduce((a, b) -> a + "\n" + b)
+ .orElse("nenhum alerta");
+ }
+
+ private TriageVerdict toVerdict(VerdictResponse response) {
+ if (response == null || response.classification() == null) {
+ return TriageVerdict.needsReview("Resposta do modelo veio vazia");
+ }
+ TriageVerdict.Classification classification;
+ try {
+ classification = TriageVerdict.Classification
+ .valueOf(response.classification().trim().toUpperCase());
+ } catch (IllegalArgumentException ex) {
+ log.warn("Classificacao desconhecida '{}', escalando para revisao humana",
+ response.classification());
+ return TriageVerdict.needsReview(
+ "Classificacao nao reconhecida: " + response.classification());
+ }
+ return new TriageVerdict(
+ classification,
+ response.confidence() == null ? 0.0 : response.confidence(),
+ response.rationale() == null ? "sem justificativa" : response.rationale(),
+ response.techniqueIds() == null ? List.of() : response.techniqueIds());
+ }
+
+ private Optional toProposedAction(ActionResponse action) {
+ if (action == null || action.tool() == null || action.tool().isBlank()) {
+ return Optional.empty();
+ }
+ Map arguments = new HashMap<>();
+ putIfPresent(arguments, "hostname", action.hostname());
+ putIfPresent(arguments, "ip_address", action.ipAddress());
+ putIfPresent(arguments, "technique_id", action.techniqueId());
+ putIfPresent(arguments, "reason", action.reason());
+ return Optional.of(new ProposedAction(
+ action.tool().trim().toLowerCase(),
+ arguments,
+ action.reason() == null ? "sugerido pelo modelo" : action.reason()));
+ }
+
+ private void putIfPresent(Map target, String key, String value) {
+ if (value != null && !value.isBlank()) {
+ target.put(key, value.trim());
+ }
+ }
+
+ /** Exposto para diagnostico na GUI. */
+ public List allowedTools() {
+ return new ArrayList<>(CommandFactory.allowedTools());
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/command/AgentCommand.java b/src/main/java/br/fatec/esiii/socagent/command/AgentCommand.java
new file mode 100644
index 0000000..02d8047
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/command/AgentCommand.java
@@ -0,0 +1,41 @@
+package br.fatec.esiii.socagent.command;
+
+/**
+ * Acao executavel do agente (padrao Command).
+ *
+ * Encapsular a acao como objeto e o que permite enfileirar, auditar, exigir
+ * aprovacao e desfazer de forma uniforme. O {@link CommandInvoker} trata todos
+ * os comandos igualmente, sem conhecer nenhum deles.
+ *
+ *
Decisao de projeto deliberada: comandos sao codigo Java, nunca
+ * texto gerado pelo modelo. O modelo escolhe qual comando invocar e com quais
+ * parametros; o que cada comando faz e determinado em tempo de compilacao.
+ */
+public interface AgentCommand {
+
+ /** Identificador estavel, usado no log de auditoria. */
+ String name();
+
+ /** Descricao legivel exibida na GUI e na solicitacao de aprovacao. */
+ String description();
+
+ CommandResult execute();
+
+ /**
+ * Comandos que alteram o estado de um ativo exigem aprovacao humana e so
+ * podem rodar quando o incidente esta na fase de contencao.
+ */
+ default boolean requiresApproval() {
+ return false;
+ }
+
+ default boolean undoable() {
+ return false;
+ }
+
+ /** Acao compensatoria. So e chamada se {@link #undoable()} for verdadeiro. */
+ default CommandResult undo() {
+ throw new UnsupportedOperationException(
+ "Comando '%s' nao suporta desfazer".formatted(name()));
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/command/BlockIpCommand.java b/src/main/java/br/fatec/esiii/socagent/command/BlockIpCommand.java
new file mode 100644
index 0000000..42dc203
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/command/BlockIpCommand.java
@@ -0,0 +1,45 @@
+package br.fatec.esiii.socagent.command;
+
+import java.time.Duration;
+import java.time.Instant;
+
+/**
+ * Bloqueia um endereco IP na borda da rede. Destrutiva, aprovavel e reversivel.
+ */
+public record BlockIpCommand(ContainmentGateway gateway, String ipAddress, String reason)
+ implements AgentCommand {
+
+ @Override
+ public String name() {
+ return "block_ip";
+ }
+
+ @Override
+ public String description() {
+ return "Bloquear o IP %s na borda (%s)".formatted(ipAddress, reason);
+ }
+
+ @Override
+ public boolean requiresApproval() {
+ return true;
+ }
+
+ @Override
+ public boolean undoable() {
+ return true;
+ }
+
+ @Override
+ public CommandResult execute() {
+ Instant start = Instant.now();
+ String output = gateway.blockIp(ipAddress, reason);
+ return CommandResult.ok(output, Duration.between(start, Instant.now()));
+ }
+
+ @Override
+ public CommandResult undo() {
+ Instant start = Instant.now();
+ String output = gateway.unblockIp(ipAddress);
+ return CommandResult.ok(output, Duration.between(start, Instant.now()));
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/command/CollectForensicsCommand.java b/src/main/java/br/fatec/esiii/socagent/command/CollectForensicsCommand.java
new file mode 100644
index 0000000..bbd1da1
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/command/CollectForensicsCommand.java
@@ -0,0 +1,29 @@
+package br.fatec.esiii.socagent.command;
+
+import java.time.Duration;
+import java.time.Instant;
+
+/**
+ * Coleta artefatos forenses do host. Nao altera o ativo, portanto dispensa
+ * aprovacao e nao possui acao compensatoria.
+ */
+public record CollectForensicsCommand(ContainmentGateway gateway, String hostname)
+ implements AgentCommand {
+
+ @Override
+ public String name() {
+ return "collect_forensics";
+ }
+
+ @Override
+ public String description() {
+ return "Coletar evidencias forenses do host %s".formatted(hostname);
+ }
+
+ @Override
+ public CommandResult execute() {
+ Instant start = Instant.now();
+ String output = gateway.collectForensics(hostname);
+ return CommandResult.ok(output, Duration.between(start, Instant.now()));
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/command/CommandInvoker.java b/src/main/java/br/fatec/esiii/socagent/command/CommandInvoker.java
new file mode 100644
index 0000000..c862790
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/command/CommandInvoker.java
@@ -0,0 +1,127 @@
+package br.fatec.esiii.socagent.command;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.observer.AgentEvent;
+import br.fatec.esiii.socagent.observer.AgentEvent.EventType;
+import br.fatec.esiii.socagent.observer.AgentEventBus;
+import br.fatec.esiii.socagent.state.Incident;
+
+/**
+ * Invoker do padrao Command: enfileira, autoriza, executa e desfaz comandos
+ * sem conhecer nenhuma implementacao concreta.
+ *
+ *
Aqui os tres padroes se encontram. O invoker consulta o
+ * {@link Incident} (State) para saber se acoes destrutivas estao liberadas, e
+ * publica cada passo no {@link AgentEventBus} (Observer). Nenhum comando
+ * destrutivo escapa da barreira: a checagem e feita no invoker, nao em cada
+ * comando, de modo que um comando novo ja nasce protegido.
+ */
+@Component
+public class CommandInvoker {
+
+ private final AgentEventBus eventBus;
+ private final Deque queue = new ArrayDeque<>();
+ private final Deque undoStack = new ArrayDeque<>();
+ private final List history = new ArrayList<>();
+
+ public CommandInvoker(AgentEventBus eventBus) {
+ this.eventBus = eventBus;
+ }
+
+ /** Registro imutavel de uma execucao, base da trilha de auditoria. */
+ public record ExecutionRecord(String commandName, String description, CommandResult result) {
+ }
+
+ public void enqueue(Incident incident, AgentCommand command) {
+ queue.addLast(command);
+ eventBus.publish(AgentEvent.of(EventType.COMMAND_QUEUED, incident.id(),
+ command.name(), command.description()));
+ }
+
+ /**
+ * Executa toda a fila. Comandos que exigem aprovacao sao recusados enquanto
+ * o incidente nao estiver na fase de contencao.
+ */
+ public List executeQueue(Incident incident) {
+ List executed = new ArrayList<>();
+ while (!queue.isEmpty()) {
+ executed.add(execute(incident, queue.pollFirst()));
+ }
+ return executed;
+ }
+
+ public ExecutionRecord execute(Incident incident, AgentCommand command) {
+ if (command.requiresApproval() && !incident.allowsContainmentCommands()) {
+ CommandResult refused = CommandResult.refused(
+ "Bloqueado: '%s' exige aprovacao humana e o incidente esta na fase %s"
+ .formatted(command.name(), incident.phase()));
+ return record(incident, command, refused, EventType.COMMAND_FAILED);
+ }
+
+ CommandResult result;
+ try {
+ result = command.execute();
+ } catch (RuntimeException ex) {
+ result = CommandResult.failure(
+ "Falha ao executar '%s': %s".formatted(command.name(), ex.getMessage()),
+ java.time.Duration.ZERO);
+ }
+
+ if (result.success() && command.undoable()) {
+ undoStack.push(command);
+ }
+ return record(incident, command, result,
+ result.success() ? EventType.COMMAND_EXECUTED : EventType.COMMAND_FAILED);
+ }
+
+ /**
+ * Desfaz o ultimo comando reversivel executado. Em resposta a incidentes,
+ * reverter uma contencao equivocada rapidamente vale tanto quanto aplica-la.
+ */
+ public ExecutionRecord undoLast(Incident incident) {
+ AgentCommand command = undoStack.poll();
+ if (command == null) {
+ return new ExecutionRecord("undo", "nada a desfazer",
+ CommandResult.refused("Nenhum comando reversivel no historico"));
+ }
+ CommandResult result = command.undo();
+ ExecutionRecord entry = new ExecutionRecord(command.name(), "desfazer: " + command.description(), result);
+ history.add(entry);
+ eventBus.publish(AgentEvent.of(EventType.COMMAND_UNDONE, incident.id(),
+ command.name(), result.output()));
+ return entry;
+ }
+
+ private ExecutionRecord record(Incident incident, AgentCommand command,
+ CommandResult result, EventType type) {
+ ExecutionRecord entry = new ExecutionRecord(command.name(), command.description(), result);
+ history.add(entry);
+ eventBus.publish(AgentEvent.of(type, incident.id(), command.name(), result.output())
+ .with("success", result.success()));
+ return entry;
+ }
+
+ public List history() {
+ return List.copyOf(history);
+ }
+
+ public int pendingCount() {
+ return queue.size();
+ }
+
+ public int undoableCount() {
+ return undoStack.size();
+ }
+
+ public void reset() {
+ queue.clear();
+ undoStack.clear();
+ history.clear();
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/command/CommandResult.java b/src/main/java/br/fatec/esiii/socagent/command/CommandResult.java
new file mode 100644
index 0000000..23f548f
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/command/CommandResult.java
@@ -0,0 +1,27 @@
+package br.fatec.esiii.socagent.command;
+
+import java.time.Duration;
+import java.time.Instant;
+
+/**
+ * Resultado da execucao de um comando. Alimenta a trilha de auditoria e serve
+ * de observacao devolvida ao modelo no ciclo do agente.
+ */
+public record CommandResult(
+ boolean success,
+ String output,
+ Instant executedAt,
+ Duration elapsed) {
+
+ public static CommandResult ok(String output, Duration elapsed) {
+ return new CommandResult(true, output, Instant.now(), elapsed);
+ }
+
+ public static CommandResult failure(String reason, Duration elapsed) {
+ return new CommandResult(false, reason, Instant.now(), elapsed);
+ }
+
+ public static CommandResult refused(String reason) {
+ return new CommandResult(false, reason, Instant.now(), Duration.ZERO);
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/command/ContainmentGateway.java b/src/main/java/br/fatec/esiii/socagent/command/ContainmentGateway.java
new file mode 100644
index 0000000..64e2e20
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/command/ContainmentGateway.java
@@ -0,0 +1,24 @@
+package br.fatec.esiii.socagent.command;
+
+/**
+ * Porta de saida para os sistemas que efetivamente executam a contencao
+ * (EDR, firewall, NAC).
+ *
+ * Isolar essa fronteira permite que os comandos sejam testados sem
+ * infraestrutura e que a implementacao real seja trocada sem tocar no
+ * restante do agente.
+ */
+public interface ContainmentGateway {
+
+ String isolateHost(String hostname, String reason);
+
+ String restoreHost(String hostname);
+
+ String blockIp(String ipAddress, String reason);
+
+ String unblockIp(String ipAddress);
+
+ String collectForensics(String hostname);
+
+ boolean isIsolated(String hostname);
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/command/IsolateHostCommand.java b/src/main/java/br/fatec/esiii/socagent/command/IsolateHostCommand.java
new file mode 100644
index 0000000..926e261
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/command/IsolateHostCommand.java
@@ -0,0 +1,45 @@
+package br.fatec.esiii.socagent.command;
+
+import java.time.Duration;
+import java.time.Instant;
+
+/**
+ * Isola um host da rede. Acao destrutiva: exige aprovacao e e reversivel.
+ */
+public record IsolateHostCommand(ContainmentGateway gateway, String hostname, String reason)
+ implements AgentCommand {
+
+ @Override
+ public String name() {
+ return "isolate_host";
+ }
+
+ @Override
+ public String description() {
+ return "Isolar o host %s da rede corporativa (%s)".formatted(hostname, reason);
+ }
+
+ @Override
+ public boolean requiresApproval() {
+ return true;
+ }
+
+ @Override
+ public boolean undoable() {
+ return true;
+ }
+
+ @Override
+ public CommandResult execute() {
+ Instant start = Instant.now();
+ String output = gateway.isolateHost(hostname, reason);
+ return CommandResult.ok(output, Duration.between(start, Instant.now()));
+ }
+
+ @Override
+ public CommandResult undo() {
+ Instant start = Instant.now();
+ String output = gateway.restoreHost(hostname);
+ return CommandResult.ok(output, Duration.between(start, Instant.now()));
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/command/LookupMitreCommand.java b/src/main/java/br/fatec/esiii/socagent/command/LookupMitreCommand.java
new file mode 100644
index 0000000..e12d9e0
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/command/LookupMitreCommand.java
@@ -0,0 +1,33 @@
+package br.fatec.esiii.socagent.command;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.function.Function;
+
+import br.fatec.esiii.socagent.domain.MitreTechnique;
+
+/**
+ * Consulta uma tecnica MITRE ATT&CK na base local. Comando somente de leitura.
+ */
+public record LookupMitreCommand(Function repository, String techniqueId)
+ implements AgentCommand {
+
+ @Override
+ public String name() {
+ return "lookup_mitre";
+ }
+
+ @Override
+ public String description() {
+ return "Consultar a tecnica MITRE %s".formatted(techniqueId);
+ }
+
+ @Override
+ public CommandResult execute() {
+ Instant start = Instant.now();
+ MitreTechnique technique = repository.apply(techniqueId);
+ return CommandResult.ok(
+ "%s | %s".formatted(technique, technique.description()),
+ Duration.between(start, Instant.now()));
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/command/SimulatedContainmentGateway.java b/src/main/java/br/fatec/esiii/socagent/command/SimulatedContainmentGateway.java
new file mode 100644
index 0000000..5f9145f
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/command/SimulatedContainmentGateway.java
@@ -0,0 +1,70 @@
+package br.fatec.esiii.socagent.command;
+
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * Implementacao simulada da contencao, usada em laboratorio e na apresentacao.
+ *
+ * Mantem o estado dos ativos em memoria para que o desfazer possa ser
+ * demonstrado de verdade: isolar e restaurar produzem efeitos observaveis.
+ */
+@Component
+public class SimulatedContainmentGateway implements ContainmentGateway {
+
+ private final Set isolatedHosts = ConcurrentHashMap.newKeySet();
+ private final Set blockedIps = ConcurrentHashMap.newKeySet();
+
+ @Override
+ public String isolateHost(String hostname, String reason) {
+ if (!isolatedHosts.add(hostname)) {
+ return "Host %s ja estava isolado".formatted(hostname);
+ }
+ return "Host %s isolado da rede. Motivo: %s".formatted(hostname, reason);
+ }
+
+ @Override
+ public String restoreHost(String hostname) {
+ if (!isolatedHosts.remove(hostname)) {
+ return "Host %s nao estava isolado".formatted(hostname);
+ }
+ return "Host %s reconectado a rede".formatted(hostname);
+ }
+
+ @Override
+ public String blockIp(String ipAddress, String reason) {
+ if (!blockedIps.add(ipAddress)) {
+ return "IP %s ja estava bloqueado".formatted(ipAddress);
+ }
+ return "IP %s bloqueado na borda. Motivo: %s".formatted(ipAddress, reason);
+ }
+
+ @Override
+ public String unblockIp(String ipAddress) {
+ if (!blockedIps.remove(ipAddress)) {
+ return "IP %s nao estava bloqueado".formatted(ipAddress);
+ }
+ return "Bloqueio do IP %s removido".formatted(ipAddress);
+ }
+
+ @Override
+ public String collectForensics(String hostname) {
+ return "Coleta forense de %s concluida: memoria, processos e conexoes preservados"
+ .formatted(hostname);
+ }
+
+ @Override
+ public boolean isIsolated(String hostname) {
+ return isolatedHosts.contains(hostname);
+ }
+
+ public Set isolatedHosts() {
+ return Set.copyOf(isolatedHosts);
+ }
+
+ public Set blockedIps() {
+ return Set.copyOf(blockedIps);
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/domain/Alert.java b/src/main/java/br/fatec/esiii/socagent/domain/Alert.java
new file mode 100644
index 0000000..b45ea0f
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/domain/Alert.java
@@ -0,0 +1,35 @@
+package br.fatec.esiii.socagent.domain;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Alerta bruto recebido de uma fonte de deteccao (Suricata, Zeek, EDR, regra Sigma).
+ * E imutavel: o agente nunca altera o dado original, apenas o correlaciona.
+ */
+public record Alert(
+ String id,
+ String source,
+ String hostname,
+ String message,
+ Severity severity,
+ Instant detectedAt,
+ List indicators) {
+
+ public Alert {
+ Objects.requireNonNull(id, "id nao pode ser nulo");
+ Objects.requireNonNull(severity, "severity nao pode ser nula");
+ Objects.requireNonNull(detectedAt, "detectedAt nao pode ser nulo");
+ indicators = indicators == null ? List.of() : List.copyOf(indicators);
+ }
+
+ /** Representacao compacta usada no prompt enviado ao modelo. */
+ public String toPromptLine() {
+ String iocs = indicators.isEmpty()
+ ? "nenhum"
+ : indicators.stream().map(Ioc::toString).reduce((a, b) -> a + ", " + b).orElse("nenhum");
+ return "[%s] severidade=%s host=%s fonte=%s | %s | IOCs: %s"
+ .formatted(id, severity, hostname, source, message, iocs);
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/domain/Ioc.java b/src/main/java/br/fatec/esiii/socagent/domain/Ioc.java
new file mode 100644
index 0000000..0aec6b1
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/domain/Ioc.java
@@ -0,0 +1,39 @@
+package br.fatec.esiii.socagent.domain;
+
+import java.util.Objects;
+
+/**
+ * Indicador de comprometimento (Indicator of Compromise).
+ * E a folha da arvore de evidencias exibida na GUI.
+ */
+public record Ioc(IocType type, String value) {
+
+ public Ioc {
+ Objects.requireNonNull(type, "type nao pode ser nulo");
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException("value do IOC nao pode ser vazio");
+ }
+ value = value.trim();
+ }
+
+ public enum IocType {
+ IP,
+ DOMAIN,
+ FILE_HASH,
+ USER_ACCOUNT,
+ PROCESS
+ }
+
+ public static Ioc ip(String value) {
+ return new Ioc(IocType.IP, value);
+ }
+
+ public static Ioc host(String value) {
+ return new Ioc(IocType.PROCESS, value);
+ }
+
+ @Override
+ public String toString() {
+ return "%s:%s".formatted(type, value);
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/domain/MitreTechnique.java b/src/main/java/br/fatec/esiii/socagent/domain/MitreTechnique.java
new file mode 100644
index 0000000..b8e3a60
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/domain/MitreTechnique.java
@@ -0,0 +1,17 @@
+package br.fatec.esiii.socagent.domain;
+
+/**
+ * Tecnica do framework MITRE ATT&CK usada para classificar o comportamento observado.
+ */
+public record MitreTechnique(String id, String name, String tactic, String description) {
+
+ public static MitreTechnique unknown(String id) {
+ return new MitreTechnique(id, "Desconhecida", "N/A",
+ "Tecnica nao encontrada na base local do ATT&CK.");
+ }
+
+ @Override
+ public String toString() {
+ return "%s - %s (%s)".formatted(id, name, tactic);
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/domain/Severity.java b/src/main/java/br/fatec/esiii/socagent/domain/Severity.java
new file mode 100644
index 0000000..359b666
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/domain/Severity.java
@@ -0,0 +1,34 @@
+package br.fatec.esiii.socagent.domain;
+
+/**
+ * Severidade de um alerta, alinhada aos niveis usuais de um SOC.
+ * O peso permite comparacao e agregacao na arvore de evidencias.
+ */
+public enum Severity {
+
+ INFO(0),
+ LOW(1),
+ MEDIUM(2),
+ HIGH(3),
+ CRITICAL(4);
+
+ private final int weight;
+
+ Severity(int weight) {
+ this.weight = weight;
+ }
+
+ public int weight() {
+ return weight;
+ }
+
+ /** Severidade mais alta entre duas. Usada na agregacao recursiva do Composite. */
+ public Severity max(Severity other) {
+ return other == null || this.weight >= other.weight ? this : other;
+ }
+
+ /** Acoes de contencao so podem ser propostas a partir deste nivel. */
+ public boolean requiresContainment() {
+ return weight >= HIGH.weight;
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/domain/TriageVerdict.java b/src/main/java/br/fatec/esiii/socagent/domain/TriageVerdict.java
new file mode 100644
index 0000000..d50de5c
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/domain/TriageVerdict.java
@@ -0,0 +1,36 @@
+package br.fatec.esiii.socagent.domain;
+
+import java.util.List;
+
+/**
+ * Conclusao da triagem produzida pelo modelo de linguagem.
+ *
+ * O modelo classifica e justifica, mas nao executa nada: a decisao de agir
+ * pertence ao planejador (Strategy) e a execucao aos comandos (Command).
+ */
+public record TriageVerdict(
+ Classification classification,
+ double confidence,
+ String rationale,
+ List techniqueIds) {
+
+ public TriageVerdict {
+ confidence = Math.clamp(confidence, 0.0, 1.0);
+ techniqueIds = techniqueIds == null ? List.of() : List.copyOf(techniqueIds);
+ }
+
+ public enum Classification {
+ TRUE_POSITIVE,
+ FALSE_POSITIVE,
+ NEEDS_HUMAN_REVIEW
+ }
+
+ public static TriageVerdict needsReview(String rationale) {
+ return new TriageVerdict(Classification.NEEDS_HUMAN_REVIEW, 0.0, rationale, List.of());
+ }
+
+ /** Confianca insuficiente obriga revisao humana, independente da classificacao. */
+ public boolean isActionable(double minimumConfidence) {
+ return classification == Classification.TRUE_POSITIVE && confidence >= minimumConfidence;
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/gui/GuiLauncher.java b/src/main/java/br/fatec/esiii/socagent/gui/GuiLauncher.java
new file mode 100644
index 0000000..970669d
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/gui/GuiLauncher.java
@@ -0,0 +1,61 @@
+package br.fatec.esiii.socagent.gui;
+
+import java.awt.GraphicsEnvironment;
+
+import javax.swing.SwingUtilities;
+import javax.swing.UIManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.gui.tree.EvidenceTreeBuilder;
+import br.fatec.esiii.socagent.observer.AgentEventBus;
+import br.fatec.esiii.socagent.service.IncidentTriageService;
+import br.fatec.esiii.socagent.service.SampleAlertCatalog;
+
+/**
+ * Abre o painel apos a inicializacao do contexto.
+ *
+ * Em ambiente sem interface grafica a aplicacao segue funcionando: o
+ * lancador apenas registra o aviso e nao abre janela alguma.
+ */
+@Component
+@Profile("!headless")
+public class GuiLauncher implements ApplicationRunner {
+
+ private static final Logger log = LoggerFactory.getLogger(GuiLauncher.class);
+
+ private final IncidentTriageService triageService;
+ private final EvidenceTreeBuilder treeBuilder;
+ private final SampleAlertCatalog catalog;
+ private final AgentEventBus eventBus;
+
+ public GuiLauncher(IncidentTriageService triageService, EvidenceTreeBuilder treeBuilder,
+ SampleAlertCatalog catalog, AgentEventBus eventBus) {
+ this.triageService = triageService;
+ this.treeBuilder = treeBuilder;
+ this.catalog = catalog;
+ this.eventBus = eventBus;
+ }
+
+ @Override
+ public void run(ApplicationArguments args) {
+ if (GraphicsEnvironment.isHeadless()) {
+ log.warn("Ambiente sem interface grafica: o painel nao sera aberto.");
+ return;
+ }
+ SwingUtilities.invokeLater(() -> {
+ try {
+ UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
+ } catch (Exception ex) {
+ log.debug("Look and feel do sistema indisponivel, usando o padrao.");
+ }
+ new SocDashboardFrame(triageService, treeBuilder, catalog, eventBus).setVisible(true);
+ log.info("Painel aberto. Observadores registrados: {}", eventBus.listenerCount());
+ });
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/gui/SocDashboardFrame.java b/src/main/java/br/fatec/esiii/socagent/gui/SocDashboardFrame.java
new file mode 100644
index 0000000..69199fb
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/gui/SocDashboardFrame.java
@@ -0,0 +1,269 @@
+package br.fatec.esiii.socagent.gui;
+
+import java.awt.BorderLayout;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Font;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+import javax.swing.BorderFactory;
+import javax.swing.Box;
+import javax.swing.JButton;
+import javax.swing.JComboBox;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JSplitPane;
+import javax.swing.JTextArea;
+import javax.swing.JTree;
+import javax.swing.SwingUtilities;
+import javax.swing.SwingWorker;
+import javax.swing.tree.DefaultMutableTreeNode;
+import javax.swing.tree.DefaultTreeModel;
+import javax.swing.tree.TreeNode;
+
+import br.fatec.esiii.socagent.gui.tree.EvidenceNode;
+import br.fatec.esiii.socagent.gui.tree.EvidenceTreeBuilder;
+import br.fatec.esiii.socagent.observer.AgentEvent;
+import br.fatec.esiii.socagent.observer.AgentEventBus;
+import br.fatec.esiii.socagent.observer.AgentEventListener;
+import br.fatec.esiii.socagent.service.IncidentTriageService;
+import br.fatec.esiii.socagent.service.SampleAlertCatalog;
+import br.fatec.esiii.socagent.state.Incident;
+import br.fatec.esiii.socagent.state.IncidentPhase;
+import br.fatec.esiii.socagent.strategy.TriagePlanner;
+
+/**
+ * Painel de operacao do agente.
+ *
+ *
Cada regiao da tela corresponde a um padrao: a arvore a esquerda e o
+ * Composite, o log a direita e o Observer, o seletor no topo e o Strategy e os
+ * botoes inferiores sao Commands habilitados conforme o State.
+ *
+ *
A janela e ela propria um {@link AgentEventListener}: registra-se no
+ * barramento e recebe os eventos do agente. Como o agente publica de uma
+ * thread de trabalho, toda atualizacao visual e reenviada para a EDT.
+ */
+public class SocDashboardFrame extends JFrame implements AgentEventListener {
+
+ private static final DateTimeFormatter TIME =
+ DateTimeFormatter.ofPattern("HH:mm:ss").withZone(java.time.ZoneId.systemDefault());
+
+ private final transient IncidentTriageService triageService;
+ private final transient EvidenceTreeBuilder treeBuilder;
+ private final transient SampleAlertCatalog catalog;
+
+ private final JComboBox scenarioSelector = new JComboBox<>();
+ private final JComboBox plannerSelector = new JComboBox<>();
+ private final JTextArea eventLog = new JTextArea();
+ private final JTree evidenceTree = new JTree(new DefaultMutableTreeNode("Nenhum incidente"));
+ private final JLabel statusLabel = new JLabel("Pronto");
+
+ private final JButton triageButton = new JButton("Triar incidente");
+ private final JButton approveButton = new JButton("Aprovar contencao");
+ private final JButton denyButton = new JButton("Negar");
+ private final JButton undoButton = new JButton("Desfazer ultima acao");
+
+ private transient Incident current;
+
+ public SocDashboardFrame(IncidentTriageService triageService, EvidenceTreeBuilder treeBuilder,
+ SampleAlertCatalog catalog, AgentEventBus eventBus) {
+ super("Agente de Triagem de Incidentes de Seguranca");
+ this.triageService = triageService;
+ this.treeBuilder = treeBuilder;
+ this.catalog = catalog;
+
+ buildLayout();
+ wireActions();
+ eventBus.subscribe(this);
+ refreshControls();
+
+ setDefaultCloseOperation(EXIT_ON_CLOSE);
+ setSize(1100, 680);
+ setLocationRelativeTo(null);
+ }
+
+ private void buildLayout() {
+ catalog.scenarios().forEach(scenario -> scenarioSelector.addItem(scenario.name()));
+ triageService.planners().available().stream()
+ .map(TriagePlanner::displayName)
+ .forEach(plannerSelector::addItem);
+ plannerSelector.setSelectedItem(triageService.planners().active().displayName());
+
+ JPanel top = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
+ top.add(new JLabel("Cenario:"));
+ top.add(scenarioSelector);
+ top.add(Box.createHorizontalStrut(24));
+ top.add(new JLabel("Estrategia (Strategy):"));
+ top.add(plannerSelector);
+
+ eventLog.setEditable(false);
+ eventLog.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+
+ JScrollPane treeScroll = new JScrollPane(evidenceTree);
+ treeScroll.setBorder(BorderFactory.createTitledBorder("Arvore de evidencias (Composite)"));
+ treeScroll.setPreferredSize(new Dimension(520, 0));
+
+ JScrollPane logScroll = new JScrollPane(eventLog);
+ logScroll.setBorder(BorderFactory.createTitledBorder("Eventos do agente (Observer)"));
+
+ JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, treeScroll, logScroll);
+ split.setResizeWeight(0.5);
+
+ JPanel bottom = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
+ bottom.add(triageButton);
+ bottom.add(approveButton);
+ bottom.add(denyButton);
+ bottom.add(undoButton);
+ bottom.add(Box.createHorizontalStrut(24));
+ bottom.add(statusLabel);
+
+ setLayout(new BorderLayout());
+ add(top, BorderLayout.NORTH);
+ add(split, BorderLayout.CENTER);
+ add(bottom, BorderLayout.SOUTH);
+ }
+
+ private void wireActions() {
+ plannerSelector.addActionListener(event -> {
+ int index = plannerSelector.getSelectedIndex();
+ if (index >= 0) {
+ TriagePlanner selected = triageService.planners().available().get(index);
+ triageService.planners().activate(selected.id());
+ appendLog("Estrategia ativa: " + selected.displayName());
+ }
+ });
+
+ triageButton.addActionListener(event -> runTriage());
+ approveButton.addActionListener(event -> {
+ triageService.approve(current, null);
+ refreshAll();
+ });
+ denyButton.addActionListener(event -> {
+ triageService.deny(current, null, "Negado pelo operador no painel");
+ refreshAll();
+ });
+ undoButton.addActionListener(event -> {
+ triageService.undoLastContainment(current);
+ refreshAll();
+ });
+ }
+
+ /**
+ * A triagem chama o modelo de linguagem e pode levar segundos. Rodar isso na
+ * EDT congelaria a interface, entao vai para um {@link SwingWorker}.
+ */
+ private void runTriage() {
+ String scenarioName = (String) scenarioSelector.getSelectedItem();
+ var scenario = catalog.byName(scenarioName);
+
+ setBusy(true);
+ appendLog("Iniciando triagem do cenario: " + scenarioName);
+
+ new SwingWorker() {
+ @Override
+ protected Incident doInBackground() {
+ Incident incident = triageService.open(scenario.alerts());
+ triageService.triage(incident);
+ return incident;
+ }
+
+ @Override
+ protected void done() {
+ try {
+ current = get();
+ } catch (Exception ex) {
+ appendLog("Falha na triagem: " + ex.getMessage());
+ } finally {
+ setBusy(false);
+ refreshAll();
+ }
+ }
+ }.execute();
+ }
+
+ // ---------------------------------------------------------------
+ // Observer
+ // ---------------------------------------------------------------
+
+ @Override
+ public void onEvent(AgentEvent event) {
+ String line = "%s | %-20s | %s%s".formatted(
+ TIME.format(event.occurredAt()),
+ event.type(),
+ event.title(),
+ event.detail() == null || event.detail().isBlank() ? "" : " -- " + event.detail());
+ SwingUtilities.invokeLater(() -> {
+ appendLog(line);
+ refreshControls();
+ });
+ }
+
+ // ---------------------------------------------------------------
+ // Atualizacao da interface
+ // ---------------------------------------------------------------
+
+ private void refreshAll() {
+ SwingUtilities.invokeLater(() -> {
+ refreshTree();
+ refreshControls();
+ });
+ }
+
+ private void refreshTree() {
+ if (current == null) {
+ return;
+ }
+ EvidenceNode root = treeBuilder.build(current);
+ evidenceTree.setModel(new DefaultTreeModel(toSwingNode(root)));
+ for (int row = 0; row < evidenceTree.getRowCount(); row++) {
+ evidenceTree.expandRow(row);
+ }
+ }
+
+ /** Converte o Composite de dominio na arvore que o Swing sabe renderizar. */
+ private TreeNode toSwingNode(EvidenceNode node) {
+ DefaultMutableTreeNode swingNode = new DefaultMutableTreeNode(
+ node.isLeaf()
+ ? node.label()
+ : "%s (%d evidencia(s), max %s)"
+ .formatted(node.label(), node.leafCount(), node.highestSeverity()));
+ node.children().forEach(child -> swingNode.add((DefaultMutableTreeNode) toSwingNode(child)));
+ return swingNode;
+ }
+
+ private void refreshControls() {
+ boolean hasIncident = current != null;
+ IncidentPhase phase = hasIncident ? current.phase() : null;
+
+ approveButton.setEnabled(hasIncident && phase == IncidentPhase.AWAITING_APPROVAL);
+ denyButton.setEnabled(hasIncident && phase == IncidentPhase.AWAITING_APPROVAL);
+ undoButton.setEnabled(hasIncident && triageService.invoker().undoableCount() > 0);
+
+ statusLabel.setText(hasIncident
+ ? "Incidente %s | Fase: %s | Comandos reversiveis: %d"
+ .formatted(current.id(), phase.label(), triageService.invoker().undoableCount())
+ : "Pronto");
+ }
+
+ private void setBusy(boolean busy) {
+ triageButton.setEnabled(!busy);
+ scenarioSelector.setEnabled(!busy);
+ plannerSelector.setEnabled(!busy);
+ if (busy) {
+ statusLabel.setText("Consultando o modelo...");
+ }
+ }
+
+ private void appendLog(String line) {
+ eventLog.append(line + System.lineSeparator());
+ eventLog.setCaretPosition(eventLog.getDocument().getLength());
+ }
+
+ /** Exposto para diagnostico: o texto do Composite sem depender do Swing. */
+ public String renderEvidenceAsText() {
+ return current == null ? "" : treeBuilder.build(current).render(0);
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceGroup.java b/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceGroup.java
new file mode 100644
index 0000000..078d45a
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceGroup.java
@@ -0,0 +1,64 @@
+package br.fatec.esiii.socagent.gui.tree;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import br.fatec.esiii.socagent.domain.Severity;
+
+/**
+ * Composto da arvore de evidencias: agrupa outros nos, que podem ser folhas ou
+ * novos grupos.
+ *
+ * A severidade propria e apenas um piso; {@link #highestSeverity()} agrega
+ * recursivamente os filhos. Assim, um grupo marcado como LOW que contenha um
+ * IOC CRITICAL aparece como CRITICAL na GUI, sem que ninguem precise
+ * recalcular isso manualmente.
+ */
+public class EvidenceGroup implements EvidenceNode {
+
+ private final String label;
+ private final Severity ownSeverity;
+ private final List children = new ArrayList<>();
+
+ public EvidenceGroup(String label, Severity ownSeverity) {
+ this.label = label;
+ this.ownSeverity = ownSeverity == null ? Severity.INFO : ownSeverity;
+ }
+
+ public EvidenceGroup(String label) {
+ this(label, Severity.INFO);
+ }
+
+ @Override
+ public String label() {
+ return label;
+ }
+
+ @Override
+ public Severity severity() {
+ return ownSeverity;
+ }
+
+ @Override
+ public List children() {
+ return List.copyOf(children);
+ }
+
+ @Override
+ public boolean isLeaf() {
+ return false;
+ }
+
+ @Override
+ public void add(EvidenceNode child) {
+ if (child != null) {
+ children.add(child);
+ }
+ }
+
+ /** Encadeamento conveniente para montar a arvore em uma expressao. */
+ public EvidenceGroup with(EvidenceNode child) {
+ add(child);
+ return this;
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceLeaf.java b/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceLeaf.java
new file mode 100644
index 0000000..cd6575a
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceLeaf.java
@@ -0,0 +1,31 @@
+package br.fatec.esiii.socagent.gui.tree;
+
+import java.util.List;
+
+import br.fatec.esiii.socagent.domain.Ioc;
+import br.fatec.esiii.socagent.domain.MitreTechnique;
+import br.fatec.esiii.socagent.domain.Severity;
+
+/**
+ * Folha da arvore de evidencias: um indicador, uma tecnica ou uma observacao
+ * que nao se decompoe.
+ */
+public record EvidenceLeaf(String label, Severity severity) implements EvidenceNode {
+
+ @Override
+ public List children() {
+ return List.of();
+ }
+
+ public static EvidenceLeaf ofIoc(Ioc ioc, Severity severity) {
+ return new EvidenceLeaf("%s: %s".formatted(ioc.type(), ioc.value()), severity);
+ }
+
+ public static EvidenceLeaf ofTechnique(MitreTechnique technique) {
+ return new EvidenceLeaf(technique.toString(), Severity.MEDIUM);
+ }
+
+ public static EvidenceLeaf ofNote(String text) {
+ return new EvidenceLeaf(text, Severity.INFO);
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceNode.java b/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceNode.java
new file mode 100644
index 0000000..00024d0
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceNode.java
@@ -0,0 +1,71 @@
+package br.fatec.esiii.socagent.gui.tree;
+
+import java.util.List;
+
+import br.fatec.esiii.socagent.domain.Severity;
+
+/**
+ * Componente do padrao Composite: um no da arvore de evidencias.
+ *
+ * Cliente algum precisa saber se esta lidando com uma folha (um IOC isolado)
+ * ou com um grupo (um alerta com varios indicadores). As operacoes recursivas
+ * ficam aqui como metodos padrao, de modo que folha e composto respondem
+ * exatamente a mesma interface — que e o proposito do padrao.
+ *
+ *
A GUI consome esta arvore para montar o {@code JTree}, e o relatorio em
+ * texto usa {@link #render(int)}. Nenhum dos dois conhece as classes concretas.
+ */
+public interface EvidenceNode {
+
+ String label();
+
+ /** Severidade propria do no. Grupos costumam derivar a sua dos filhos. */
+ Severity severity();
+
+ List children();
+
+ default boolean isLeaf() {
+ return children().isEmpty();
+ }
+
+ /**
+ * Adiciona um filho. Folhas recusam, o que mantem a transparencia da
+ * interface sem permitir composicao invalida.
+ */
+ default void add(EvidenceNode child) {
+ throw new UnsupportedOperationException(
+ "'%s' e uma folha e nao aceita filhos".formatted(label()));
+ }
+
+ /** Conta as folhas de toda a subarvore. Operacao recursiva uniforme. */
+ default int leafCount() {
+ if (isLeaf()) {
+ return 1;
+ }
+ return children().stream().mapToInt(EvidenceNode::leafCount).sum();
+ }
+
+ /** Maior severidade da subarvore, incluindo a do proprio no. */
+ default Severity highestSeverity() {
+ return children().stream()
+ .map(EvidenceNode::highestSeverity)
+ .reduce(severity(), Severity::max);
+ }
+
+ /** Profundidade maxima da subarvore, usada em diagnostico. */
+ default int depth() {
+ return isLeaf() ? 1 : 1 + children().stream().mapToInt(EvidenceNode::depth).max().orElse(0);
+ }
+
+ /** Representacao textual indentada, util em relatorio e em teste. */
+ default String render(int indent) {
+ StringBuilder builder = new StringBuilder();
+ builder.append(" ".repeat(indent))
+ .append(isLeaf() ? "- " : "+ ")
+ .append(label())
+ .append(" [").append(severity()).append("]")
+ .append(System.lineSeparator());
+ children().forEach(child -> builder.append(child.render(indent + 1)));
+ return builder.toString();
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceTreeBuilder.java b/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceTreeBuilder.java
new file mode 100644
index 0000000..0e7fe02
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/gui/tree/EvidenceTreeBuilder.java
@@ -0,0 +1,70 @@
+package br.fatec.esiii.socagent.gui.tree;
+
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.domain.Severity;
+import br.fatec.esiii.socagent.mitre.MitreRepository;
+import br.fatec.esiii.socagent.state.Incident;
+
+/**
+ * Monta a arvore de evidencias a partir de um incidente.
+ *
+ * Estrutura produzida:
+ *
+ * Incidente (composto)
+ * +-- Alerta (composto)
+ * | +-- IOC (folha)
+ * +-- Tecnicas ATT&CK (composto)
+ * | +-- Tecnica (folha)
+ * +-- Veredito (composto)
+ * +-- Justificativa (folha)
+ *
+ */
+@Component
+public class EvidenceTreeBuilder {
+
+ private final MitreRepository mitreRepository;
+
+ public EvidenceTreeBuilder(MitreRepository mitreRepository) {
+ this.mitreRepository = mitreRepository;
+ }
+
+ public EvidenceNode build(Incident incident) {
+ EvidenceGroup root = new EvidenceGroup(
+ "%s | %s | host %s".formatted(incident.id(), incident.phase().label(), incident.affectedHost()),
+ incident.highestSeverity());
+
+ incident.alerts().forEach(alert -> {
+ EvidenceGroup alertNode = new EvidenceGroup(
+ "[%s] %s (%s)".formatted(alert.id(), alert.message(), alert.source()),
+ alert.severity());
+ alert.indicators().forEach(ioc -> alertNode.add(EvidenceLeaf.ofIoc(ioc, alert.severity())));
+ root.add(alertNode);
+ });
+
+ incident.verdict().ifPresent(verdict -> {
+ if (!verdict.techniqueIds().isEmpty()) {
+ EvidenceGroup techniques = new EvidenceGroup("Tecnicas MITRE ATT&CK", Severity.MEDIUM);
+ mitreRepository.findAll(verdict.techniqueIds())
+ .forEach(technique -> techniques.add(EvidenceLeaf.ofTechnique(technique)));
+ root.add(techniques);
+ }
+
+ EvidenceGroup verdictNode = new EvidenceGroup(
+ "Veredito: %s (confianca %.2f)".formatted(verdict.classification(), verdict.confidence()),
+ Severity.INFO);
+ verdictNode.add(EvidenceLeaf.ofNote(verdict.rationale()));
+ root.add(verdictNode);
+ });
+
+ incident.approver().ifPresent(approver -> {
+ EvidenceGroup approval = new EvidenceGroup("Decisao humana", Severity.INFO);
+ approval.add(EvidenceLeaf.ofNote("%s por %s"
+ .formatted(incident.isApproved() ? "Aprovado" : "Negado", approver)));
+ incident.approvalReason().ifPresent(reason -> approval.add(EvidenceLeaf.ofNote(reason)));
+ root.add(approval);
+ });
+
+ return root;
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/mitre/MitreRepository.java b/src/main/java/br/fatec/esiii/socagent/mitre/MitreRepository.java
new file mode 100644
index 0000000..50b5ee7
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/mitre/MitreRepository.java
@@ -0,0 +1,51 @@
+package br.fatec.esiii.socagent.mitre;
+
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.domain.MitreTechnique;
+
+/**
+ * Base local de tecnicas MITRE ATT&CK.
+ *
+ * Mantida em memoria e offline por decisao de projeto: o agente precisa
+ * funcionar sem rede e sem servico externo, e o subconjunto abaixo cobre os
+ * cenarios usados na demonstracao.
+ */
+@Component
+public class MitreRepository {
+
+ private static final Map TECHNIQUES = Map.of(
+ "T1041", new MitreTechnique("T1041", "Exfiltration Over C2 Channel", "Exfiltration",
+ "Adversarios enviam dados roubados pelo mesmo canal usado para comando e controle."),
+ "T1071", new MitreTechnique("T1071", "Application Layer Protocol", "Command and Control",
+ "Comunicacao com a infraestrutura do atacante disfarcada em protocolos legitimos."),
+ "T1059", new MitreTechnique("T1059", "Command and Scripting Interpreter", "Execution",
+ "Execucao de comandos e scripts por interpretadores como PowerShell e bash."),
+ "T1110", new MitreTechnique("T1110", "Brute Force", "Credential Access",
+ "Tentativas repetidas de autenticacao para descobrir credenciais validas."),
+ "T1486", new MitreTechnique("T1486", "Data Encrypted for Impact", "Impact",
+ "Cifragem de dados da vitima para interromper a operacao, tipico de ransomware."),
+ "T1021", new MitreTechnique("T1021", "Remote Services", "Lateral Movement",
+ "Uso de servicos remotos legitimos como RDP e SSH para mover-se pela rede."),
+ "T1567", new MitreTechnique("T1567", "Exfiltration Over Web Service", "Exfiltration",
+ "Envio de dados para servicos web legitimos usados como canal de saida."));
+
+ public MitreTechnique find(String techniqueId) {
+ if (techniqueId == null) {
+ return MitreTechnique.unknown("desconhecida");
+ }
+ String normalized = techniqueId.trim().toUpperCase();
+ return TECHNIQUES.getOrDefault(normalized, MitreTechnique.unknown(normalized));
+ }
+
+ public List findAll(List ids) {
+ return ids.stream().map(this::find).toList();
+ }
+
+ public int size() {
+ return TECHNIQUES.size();
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/observer/AgentEvent.java b/src/main/java/br/fatec/esiii/socagent/observer/AgentEvent.java
new file mode 100644
index 0000000..4dcb3b4
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/observer/AgentEvent.java
@@ -0,0 +1,48 @@
+package br.fatec.esiii.socagent.observer;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Evento imutavel publicado pelo agente. E a mensagem trocada no padrao Observer.
+ */
+public record AgentEvent(
+ EventType type,
+ String incidentId,
+ String title,
+ String detail,
+ Instant occurredAt,
+ Map attributes) {
+
+ public AgentEvent {
+ Objects.requireNonNull(type, "type nao pode ser nulo");
+ occurredAt = occurredAt == null ? Instant.now() : occurredAt;
+ attributes = attributes == null ? Map.of() : Map.copyOf(attributes);
+ }
+
+ public static AgentEvent of(EventType type, String incidentId, String title, String detail) {
+ return new AgentEvent(type, incidentId, title, detail, Instant.now(), Map.of());
+ }
+
+ public AgentEvent with(String key, Object value) {
+ var merged = new java.util.HashMap<>(attributes);
+ merged.put(key, value);
+ return new AgentEvent(type, incidentId, title, detail, occurredAt, merged);
+ }
+
+ public enum EventType {
+ INCIDENT_CREATED,
+ STATE_CHANGED,
+ TRIAGE_COMPLETED,
+ PLAN_CREATED,
+ COMMAND_QUEUED,
+ COMMAND_EXECUTED,
+ COMMAND_FAILED,
+ COMMAND_UNDONE,
+ APPROVAL_REQUESTED,
+ APPROVAL_GRANTED,
+ APPROVAL_DENIED,
+ INCIDENT_CLOSED
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/observer/AgentEventBus.java b/src/main/java/br/fatec/esiii/socagent/observer/AgentEventBus.java
new file mode 100644
index 0000000..2a57f2d
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/observer/AgentEventBus.java
@@ -0,0 +1,55 @@
+package br.fatec.esiii.socagent.observer;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * Sujeito (Subject) do padrao Observer: mantem os observadores registrados e
+ * difunde os eventos do agente.
+ *
+ * Usa {@link CopyOnWriteArrayList} porque a leitura (publicacao) e muito mais
+ * frequente que a escrita (registro) e porque a GUI se registra a partir da
+ * Event Dispatch Thread do Swing enquanto o agente publica de outra thread.
+ *
+ *
Falha de um observador nunca interrompe a difusao para os demais: em um
+ * contexto de seguranca, perder a trilha de auditoria por causa de um erro de
+ * renderizacao na GUI seria inaceitavel.
+ */
+@Component
+public class AgentEventBus {
+
+ private static final Logger log = LoggerFactory.getLogger(AgentEventBus.class);
+
+ private final List listeners = new CopyOnWriteArrayList<>();
+
+ public void subscribe(AgentEventListener listener) {
+ if (listener != null && !listeners.contains(listener)) {
+ listeners.add(listener);
+ }
+ }
+
+ public void unsubscribe(AgentEventListener listener) {
+ listeners.remove(listener);
+ }
+
+ public void publish(AgentEvent event) {
+ for (AgentEventListener listener : listeners) {
+ try {
+ if (listener.supports(event)) {
+ listener.onEvent(event);
+ }
+ } catch (RuntimeException ex) {
+ log.warn("Observador {} falhou ao tratar {}: {}",
+ listener.listenerName(), event.type(), ex.getMessage());
+ }
+ }
+ }
+
+ public int listenerCount() {
+ return listeners.size();
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/observer/AgentEventListener.java b/src/main/java/br/fatec/esiii/socagent/observer/AgentEventListener.java
new file mode 100644
index 0000000..ffc660c
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/observer/AgentEventListener.java
@@ -0,0 +1,23 @@
+package br.fatec.esiii.socagent.observer;
+
+/**
+ * Observador do ciclo de vida do agente.
+ *
+ * Implementacoes nao devem lancar excecoes nem bloquear: o barramento isola
+ * falhas, mas um observador lento atrasa todos os demais.
+ */
+@FunctionalInterface
+public interface AgentEventListener {
+
+ void onEvent(AgentEvent event);
+
+ /** Nome usado em log de diagnostico quando o observador falha. */
+ default String listenerName() {
+ return getClass().getSimpleName();
+ }
+
+ /** Permite que o observador ignore tipos que nao lhe interessam. */
+ default boolean supports(AgentEvent event) {
+ return true;
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/observer/AuditTrailListener.java b/src/main/java/br/fatec/esiii/socagent/observer/AuditTrailListener.java
new file mode 100644
index 0000000..55c097a
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/observer/AuditTrailListener.java
@@ -0,0 +1,40 @@
+package br.fatec.esiii.socagent.observer;
+
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * Observador que materializa a trilha de auditoria do incidente.
+ *
+ *
Em um SOC real esta trilha e requisito de conformidade: toda acao tomada
+ * sobre um ativo precisa ser reconstituivel. Aqui ela e mantida em memoria e
+ * exposta para a GUI e para o relatorio final.
+ */
+@Component
+public class AuditTrailListener implements AgentEventListener {
+
+ private static final DateTimeFormatter TIME =
+ DateTimeFormatter.ofPattern("HH:mm:ss").withZone(java.time.ZoneId.systemDefault());
+
+ private final List entries = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void onEvent(AgentEvent event) {
+ entries.add("%s | %-20s | %s%s".formatted(
+ TIME.format(event.occurredAt()),
+ event.type(),
+ event.title(),
+ event.detail() == null || event.detail().isBlank() ? "" : " -- " + event.detail()));
+ }
+
+ public List entries() {
+ return List.copyOf(entries);
+ }
+
+ public void clear() {
+ entries.clear();
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/observer/ConsoleListener.java b/src/main/java/br/fatec/esiii/socagent/observer/ConsoleListener.java
new file mode 100644
index 0000000..e03868e
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/observer/ConsoleListener.java
@@ -0,0 +1,21 @@
+package br.fatec.esiii.socagent.observer;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * Observador que espelha o progresso do agente no console, util durante a
+ * apresentacao e para depuracao sem abrir a GUI.
+ */
+@Component
+public class ConsoleListener implements AgentEventListener {
+
+ private static final Logger log = LoggerFactory.getLogger(ConsoleListener.class);
+
+ @Override
+ public void onEvent(AgentEvent event) {
+ log.info("[{}] {} {}", event.type(), event.title(),
+ event.detail() == null ? "" : "-- " + event.detail());
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/observer/EventListenerRegistrar.java b/src/main/java/br/fatec/esiii/socagent/observer/EventListenerRegistrar.java
new file mode 100644
index 0000000..b2651d9
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/observer/EventListenerRegistrar.java
@@ -0,0 +1,30 @@
+package br.fatec.esiii.socagent.observer;
+
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * Inscreve no barramento todo observador declarado como bean.
+ *
+ * Sem isto, um observador podia existir no contexto e nunca receber evento
+ * algum: era exatamente o que acontecia com a trilha de auditoria, que subia
+ * vazia porque ninguem a registrava. Centralizar o registro aqui garante que
+ * acrescentar um observador novo baste declara-lo como bean.
+ *
+ *
Observadores que nao sao beans, como a janela do painel, continuam se
+ * inscrevendo por conta propria.
+ */
+@Component
+public class EventListenerRegistrar {
+
+ private static final Logger log = LoggerFactory.getLogger(EventListenerRegistrar.class);
+
+ public EventListenerRegistrar(AgentEventBus eventBus, List listeners) {
+ listeners.forEach(eventBus::subscribe);
+ log.info("Observadores registrados no barramento: {}",
+ listeners.stream().map(AgentEventListener::listenerName).toList());
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/service/AgentProperties.java b/src/main/java/br/fatec/esiii/socagent/service/AgentProperties.java
new file mode 100644
index 0000000..17b3e8a
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/service/AgentProperties.java
@@ -0,0 +1,24 @@
+package br.fatec.esiii.socagent.service;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Configuracao do agente.
+ *
+ * @param planner id da estrategia ativa na inicializacao
+ * @param minimumConfidence confianca minima para considerar um veredito acionavel
+ * @param defaultApprover identificacao usada quando a aprovacao vem da GUI
+ */
+@ConfigurationProperties(prefix = "soc-agent")
+public record AgentProperties(
+ String planner,
+ double minimumConfidence,
+ String defaultApprover) {
+
+ public AgentProperties {
+ planner = planner == null || planner.isBlank() ? "human-in-the-loop" : planner;
+ minimumConfidence = minimumConfidence <= 0 ? 0.6 : minimumConfidence;
+ defaultApprover = defaultApprover == null || defaultApprover.isBlank()
+ ? "analista.local" : defaultApprover;
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/service/HeadlessDemoRunner.java b/src/main/java/br/fatec/esiii/socagent/service/HeadlessDemoRunner.java
new file mode 100644
index 0000000..b42d692
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/service/HeadlessDemoRunner.java
@@ -0,0 +1,86 @@
+package br.fatec.esiii.socagent.service;
+
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.command.CommandInvoker;
+import br.fatec.esiii.socagent.gui.tree.EvidenceTreeBuilder;
+import br.fatec.esiii.socagent.observer.AuditTrailListener;
+import br.fatec.esiii.socagent.state.Incident;
+import br.fatec.esiii.socagent.state.IncidentPhase;
+
+/**
+ * Executa um cenario completo sem interface grafica.
+ *
+ * Serve para validar a integracao com o modelo em terminal e para gerar o
+ * relatorio usado na apresentacao. Ative com
+ * {@code --spring.profiles.active=headless}.
+ */
+@Component
+@Profile("headless")
+public class HeadlessDemoRunner implements ApplicationRunner {
+
+ private final IncidentTriageService triageService;
+ private final SampleAlertCatalog catalog;
+ private final EvidenceTreeBuilder treeBuilder;
+ private final AuditTrailListener auditTrail;
+ private final CommandInvoker invoker;
+
+ public HeadlessDemoRunner(IncidentTriageService triageService, SampleAlertCatalog catalog,
+ EvidenceTreeBuilder treeBuilder, AuditTrailListener auditTrail, CommandInvoker invoker) {
+ this.triageService = triageService;
+ this.catalog = catalog;
+ this.treeBuilder = treeBuilder;
+ this.auditTrail = auditTrail;
+ this.invoker = invoker;
+ }
+
+ @Override
+ public void run(ApplicationArguments args) {
+ String scenarioName = args.containsOption("cenario")
+ ? args.getOptionValues("cenario").getFirst()
+ : catalog.scenarios().getFirst().name();
+
+ var scenario = catalog.byName(scenarioName);
+
+ System.out.println("\n================ CENARIO ================");
+ System.out.println(scenario.name() + " -- " + scenario.description());
+ System.out.println("Estrategia ativa: " + triageService.planners().active().displayName());
+
+ Incident incident = triageService.open(scenario.alerts());
+ triageService.triage(incident);
+
+ if (incident.phase() == IncidentPhase.AWAITING_APPROVAL) {
+ System.out.println("\n--- APROVACAO HUMANA REQUERIDA ---");
+ System.out.println("Comandos aguardando decisao: " + invoker.pendingCount());
+
+ // Por padrao o agente para aqui, como pararia em producao. A aprovacao
+ // automatica existe apenas para exercitar o fluxo completo em terminal
+ // e precisa ser pedida explicitamente.
+ if (args.containsOption("aprovar")) {
+ System.out.println("Aprovacao simulada via --aprovar");
+ triageService.approve(incident, "demo.operador");
+ } else {
+ System.out.println("Nenhuma acao executada. Use --aprovar para simular a decisao humana.");
+ }
+ }
+
+ System.out.println("\n================ ARVORE DE EVIDENCIAS (Composite) ================");
+ System.out.print(treeBuilder.build(incident).render(0));
+
+ System.out.println("\n================ TRILHA DE AUDITORIA (Observer) ================");
+ auditTrail.entries().forEach(System.out::println);
+
+ System.out.println("\n================ COMANDOS EXECUTADOS (Command) ================");
+ invoker.history().forEach(record -> System.out.println(" %s -> %s | %s".formatted(
+ record.commandName(),
+ record.result().success() ? "OK" : "RECUSADO/FALHOU",
+ record.result().output())));
+
+ System.out.println("\nFase final: " + incident.phase().label());
+ incident.closingSummary().ifPresent(summary -> System.out.println("Conclusao: " + summary));
+ System.out.println();
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/service/IncidentTriageService.java b/src/main/java/br/fatec/esiii/socagent/service/IncidentTriageService.java
new file mode 100644
index 0000000..80f8866
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/service/IncidentTriageService.java
@@ -0,0 +1,112 @@
+package br.fatec.esiii.socagent.service;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.springframework.stereotype.Service;
+
+import br.fatec.esiii.socagent.command.CommandInvoker;
+import br.fatec.esiii.socagent.domain.Alert;
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+import br.fatec.esiii.socagent.observer.AgentEvent;
+import br.fatec.esiii.socagent.observer.AgentEvent.EventType;
+import br.fatec.esiii.socagent.observer.AgentEventBus;
+import br.fatec.esiii.socagent.state.Incident;
+import br.fatec.esiii.socagent.strategy.TriagePlanner;
+
+/**
+ * Orquestrador do agente: e onde os quatro padroes se encontram.
+ *
+ *
State define o que pode acontecer, Strategy decide o que fazer, Command
+ * executa e Observer conta a todos o que aconteceu. O servico em si nao contem
+ * regra de seguranca alguma — ele apenas coordena, e por isso permanece curto.
+ */
+@Service
+public class IncidentTriageService {
+
+ private final PlannerRegistry plannerRegistry;
+ private final CommandInvoker invoker;
+ private final AgentEventBus eventBus;
+ private final AgentProperties properties;
+ private final AtomicInteger sequence = new AtomicInteger(1);
+
+ public IncidentTriageService(PlannerRegistry plannerRegistry, CommandInvoker invoker,
+ AgentEventBus eventBus, AgentProperties properties) {
+ this.plannerRegistry = plannerRegistry;
+ this.invoker = invoker;
+ this.eventBus = eventBus;
+ this.properties = properties;
+ }
+
+ /** Abre um incidente a partir dos alertas correlacionados. */
+ public Incident open(List alerts) {
+ String id = "INC-%04d".formatted(sequence.getAndIncrement());
+ return new Incident(id, alerts, eventBus);
+ }
+
+ /**
+ * Executa a triagem ate o ponto em que a decisao humana passa a ser
+ * necessaria. Nenhuma acao destrutiva ocorre neste metodo: o State barra e
+ * o Invoker recusa.
+ */
+ public TriagePlanner.Plan triage(Incident incident) {
+ TriagePlanner planner = plannerRegistry.active();
+ TriagePlanner.Plan plan = planner.plan(incident);
+
+ eventBus.publish(AgentEvent.of(EventType.PLAN_CREATED, incident.id(),
+ "Plano gerado por " + planner.displayName(),
+ "%d comando(s) proposto(s)".formatted(plan.commands().size()))
+ .with("planner", planner.id()));
+
+ incident.triage(plan.verdict());
+
+ if (plan.verdict().classification() == TriageVerdict.Classification.FALSE_POSITIVE) {
+ incident.close("Falso positivo: " + plan.verdict().rationale());
+ return plan;
+ }
+
+ incident.correlate();
+
+ if (plan.isEmpty()) {
+ incident.close("Nenhuma acao aplicavel: " + plan.verdict().rationale());
+ return plan;
+ }
+
+ plan.commands().forEach(command -> invoker.enqueue(incident, command));
+
+ if (plan.requiresApproval() || !plan.verdict().isActionable(properties.minimumConfidence())) {
+ incident.requestApproval();
+ } else {
+ // Plano composto apenas por acoes de leitura: o Invoker ja recusaria
+ // qualquer comando destrutivo que escapasse ate aqui.
+ invoker.executeQueue(incident);
+ incident.close("Acoes de leitura concluidas sem necessidade de contencao");
+ }
+ return plan;
+ }
+
+ /** Aprova o plano pendente e executa a contencao. */
+ public void approve(Incident incident, String approver) {
+ incident.approve(approver == null ? properties.defaultApprover() : approver);
+ invoker.executeQueue(incident);
+ incident.close("Contencao concluida e evidencias preservadas");
+ }
+
+ /** Nega o plano pendente; o incidente e encerrado sem qualquer acao. */
+ public void deny(Incident incident, String approver, String reason) {
+ incident.deny(approver == null ? properties.defaultApprover() : approver, reason);
+ }
+
+ /** Reverte a ultima contencao aplicada. */
+ public void undoLastContainment(Incident incident) {
+ invoker.undoLast(incident);
+ }
+
+ public PlannerRegistry planners() {
+ return plannerRegistry;
+ }
+
+ public CommandInvoker invoker() {
+ return invoker;
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/service/PlannerRegistry.java b/src/main/java/br/fatec/esiii/socagent/service/PlannerRegistry.java
new file mode 100644
index 0000000..18e4933
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/service/PlannerRegistry.java
@@ -0,0 +1,44 @@
+package br.fatec.esiii.socagent.service;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.strategy.TriagePlanner;
+
+/**
+ * Guarda as estrategias disponiveis e qual esta ativa.
+ *
+ * E o que torna o padrao Strategy visivel na GUI: o operador troca o
+ * planejador em tempo de execucao e o orquestrador nao percebe a diferenca,
+ * porque continua conversando apenas com {@link TriagePlanner}.
+ */
+@Component
+public class PlannerRegistry {
+
+ private final Map planners = new LinkedHashMap<>();
+ private TriagePlanner active;
+
+ public PlannerRegistry(List available, AgentProperties properties) {
+ available.forEach(planner -> planners.put(planner.id(), planner));
+ this.active = planners.getOrDefault(properties.planner(), available.getFirst());
+ }
+
+ public TriagePlanner active() {
+ return active;
+ }
+
+ public void activate(String plannerId) {
+ TriagePlanner selected = planners.get(plannerId);
+ if (selected == null) {
+ throw new IllegalArgumentException("Estrategia desconhecida: " + plannerId);
+ }
+ this.active = selected;
+ }
+
+ public List available() {
+ return List.copyOf(planners.values());
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/service/SampleAlertCatalog.java b/src/main/java/br/fatec/esiii/socagent/service/SampleAlertCatalog.java
new file mode 100644
index 0000000..c9236e6
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/service/SampleAlertCatalog.java
@@ -0,0 +1,110 @@
+package br.fatec.esiii.socagent.service;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.domain.Alert;
+import br.fatec.esiii.socagent.domain.Ioc;
+import br.fatec.esiii.socagent.domain.Severity;
+
+/**
+ * Cenarios de alerta usados na demonstracao.
+ *
+ * Reproduzem padroes documentados no MITRE ATT&CK, com hosts e enderecos
+ * ficticios. O IP 185.220.101.7 pertence a faixa historicamente associada a
+ * nos de saida Tor, escolhido por ser reconhecivel em apresentacao sem
+ * envolver nenhum alvo real.
+ */
+@Component
+public class SampleAlertCatalog {
+
+ public record Scenario(String name, String description, List alerts) {
+ }
+
+ public List scenarios() {
+ return List.of(exfiltracao(), forcaBruta(), ransomware(), falsoPositivo());
+ }
+
+ /**
+ * Localiza o cenario por nome completo ou por trecho.
+ *
+ * A busca parcial existe porque a linha de comando quebra o argumento nos
+ * espacos: {@code --cenario=Cifragem} precisa encontrar "Cifragem em massa".
+ */
+ public Scenario byName(String name) {
+ if (name == null || name.isBlank()) {
+ return scenarios().getFirst();
+ }
+ String needle = name.trim().toLowerCase();
+ return scenarios().stream()
+ .filter(scenario -> scenario.name().toLowerCase().contains(needle))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException(
+ "Cenario desconhecido: '%s'. Disponiveis: %s".formatted(
+ name, scenarios().stream().map(Scenario::name).toList())));
+ }
+
+ private Scenario exfiltracao() {
+ Instant now = Instant.now();
+ return new Scenario("Exfiltracao via canal C2",
+ "Volume anomalo de saida para infraestrutura suspeita",
+ List.of(
+ new Alert("ALR-1001", "suricata", "WKS-4471",
+ "Volume de saida 480 MB para destino externo em 12 minutos",
+ Severity.CRITICAL, now.minus(12, ChronoUnit.MINUTES),
+ List.of(Ioc.ip("185.220.101.7"))),
+ new Alert("ALR-1002", "zeek", "WKS-4471",
+ "Conexao TLS persistente com certificado autoassinado",
+ Severity.HIGH, now.minus(15, ChronoUnit.MINUTES),
+ List.of(Ioc.ip("185.220.101.7"), Ioc.host("rundll32.exe")))));
+ }
+
+ private Scenario forcaBruta() {
+ Instant now = Instant.now();
+ return new Scenario("Forca bruta em VPN",
+ "Sequencia de falhas de autenticacao seguida de sucesso",
+ List.of(
+ new Alert("ALR-2001", "wazuh", "VPN-GW-01",
+ "312 falhas de autenticacao para a conta j.silva em 4 minutos",
+ Severity.HIGH, now.minus(30, ChronoUnit.MINUTES),
+ List.of(new Ioc(Ioc.IocType.USER_ACCOUNT, "j.silva"),
+ Ioc.ip("45.155.205.233"))),
+ new Alert("ALR-2002", "wazuh", "VPN-GW-01",
+ "Autenticacao bem-sucedida da mesma origem apos as falhas",
+ Severity.CRITICAL, now.minus(26, ChronoUnit.MINUTES),
+ List.of(new Ioc(Ioc.IocType.USER_ACCOUNT, "j.silva"),
+ Ioc.ip("45.155.205.233")))));
+ }
+
+ private Scenario ransomware() {
+ Instant now = Instant.now();
+ return new Scenario("Cifragem em massa",
+ "Renomeacao acelerada de arquivos em compartilhamento de rede",
+ List.of(
+ new Alert("ALR-3001", "edr", "FS-PROD-02",
+ "1.847 arquivos renomeados com extensao desconhecida em 3 minutos",
+ Severity.CRITICAL, now.minus(5, ChronoUnit.MINUTES),
+ List.of(Ioc.host("svchost32.exe"),
+ new Ioc(Ioc.IocType.FILE_HASH,
+ "d41d8cd98f00b204e9800998ecf8427e"))),
+ new Alert("ALR-3002", "edr", "FS-PROD-02",
+ "Exclusao de copias de sombra via vssadmin",
+ Severity.CRITICAL, now.minus(4, ChronoUnit.MINUTES),
+ List.of(Ioc.host("vssadmin.exe")))));
+ }
+
+ private Scenario falsoPositivo() {
+ Instant now = Instant.now();
+ return new Scenario("Backup noturno",
+ "Trafego alto de saida em janela de manutencao conhecida",
+ List.of(
+ new Alert("ALR-4001", "suricata", "BKP-SRV-01",
+ "Transferencia de 12 GB para destino externo na janela 02:00-04:00",
+ Severity.MEDIUM, now.minus(2, ChronoUnit.HOURS),
+ List.of(Ioc.ip("203.0.113.45"),
+ Ioc.host("veeam-agent")))));
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/state/IllegalTransitionException.java b/src/main/java/br/fatec/esiii/socagent/state/IllegalTransitionException.java
new file mode 100644
index 0000000..3eacaac
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/state/IllegalTransitionException.java
@@ -0,0 +1,29 @@
+package br.fatec.esiii.socagent.state;
+
+/**
+ * Lancada quando se tenta uma operacao que a fase atual do incidente nao permite.
+ *
+ *
E o mecanismo que torna o fluxo deterministico: nenhuma acao de contencao
+ * pode ocorrer antes da aprovacao humana, porque o estado simplesmente nao
+ * expoe a operacao.
+ */
+public class IllegalTransitionException extends RuntimeException {
+
+ private final IncidentPhase currentPhase;
+ private final String attemptedOperation;
+
+ public IllegalTransitionException(IncidentPhase currentPhase, String attemptedOperation) {
+ super("Operacao '%s' nao e permitida na fase %s"
+ .formatted(attemptedOperation, currentPhase));
+ this.currentPhase = currentPhase;
+ this.attemptedOperation = attemptedOperation;
+ }
+
+ public IncidentPhase currentPhase() {
+ return currentPhase;
+ }
+
+ public String attemptedOperation() {
+ return attemptedOperation;
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/state/Incident.java b/src/main/java/br/fatec/esiii/socagent/state/Incident.java
new file mode 100644
index 0000000..9949d54
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/state/Incident.java
@@ -0,0 +1,178 @@
+package br.fatec.esiii.socagent.state;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import br.fatec.esiii.socagent.domain.Alert;
+import br.fatec.esiii.socagent.domain.Severity;
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+import br.fatec.esiii.socagent.observer.AgentEvent;
+import br.fatec.esiii.socagent.observer.AgentEvent.EventType;
+import br.fatec.esiii.socagent.observer.AgentEventBus;
+
+/**
+ * Contexto do padrao State: agrega os alertas de um incidente e delega todo o
+ * comportamento dependente de fase ao {@link IncidentState} corrente.
+ *
+ *
Repare que nao existe nenhum {@code switch} sobre a fase. Adicionar uma
+ * nova fase significa criar uma classe de estado, sem tocar neste arquivo.
+ *
+ *
Cada transicao publica um evento no {@link AgentEventBus}, ligando o
+ * padrao State ao Observer.
+ */
+public class Incident {
+
+ private final String id;
+ private final List alerts;
+ private final Instant createdAt;
+ private final AgentEventBus eventBus;
+ private final List phaseHistory = new ArrayList<>();
+
+ private IncidentState state = IncidentStates.RECEIVED;
+ private TriageVerdict verdict;
+ private String approver;
+ private String approvalReason;
+ private boolean approved;
+ private String closingSummary;
+
+ public Incident(String id, List alerts, AgentEventBus eventBus) {
+ this.id = Objects.requireNonNull(id, "id nao pode ser nulo");
+ this.alerts = List.copyOf(Objects.requireNonNull(alerts, "alerts nao pode ser nulo"));
+ if (this.alerts.isEmpty()) {
+ throw new IllegalArgumentException("um incidente exige ao menos um alerta");
+ }
+ this.eventBus = Objects.requireNonNull(eventBus, "eventBus nao pode ser nulo");
+ this.createdAt = Instant.now();
+ this.phaseHistory.add(state.phase());
+ eventBus.publish(AgentEvent.of(EventType.INCIDENT_CREATED, id,
+ "Incidente aberto com %d alerta(s)".formatted(this.alerts.size()),
+ "severidade maxima " + highestSeverity()));
+ }
+
+ // ---------------------------------------------------------------
+ // Operacoes delegadas ao estado corrente
+ // ---------------------------------------------------------------
+
+ public void triage(TriageVerdict newVerdict) {
+ transitionTo(state.triage(this, newVerdict), "triagem concluida");
+ }
+
+ public void correlate() {
+ transitionTo(state.correlate(this), "correlacao de evidencias iniciada");
+ }
+
+ public void requestApproval() {
+ transitionTo(state.requestApproval(this), "plano submetido a aprovacao humana");
+ eventBus.publish(AgentEvent.of(EventType.APPROVAL_REQUESTED, id,
+ "Aprovacao humana requerida", "o agente nao prossegue sem decisao"));
+ }
+
+ public void approve(String who) {
+ transitionTo(state.approve(this, who), "contencao autorizada por " + who);
+ eventBus.publish(AgentEvent.of(EventType.APPROVAL_GRANTED, id,
+ "Contencao aprovada", "responsavel: " + who));
+ }
+
+ public void deny(String who, String reason) {
+ transitionTo(state.deny(this, who, reason), "contencao negada por " + who);
+ eventBus.publish(AgentEvent.of(EventType.APPROVAL_DENIED, id,
+ "Contencao negada", reason));
+ }
+
+ public void close(String summary) {
+ transitionTo(state.close(this, summary), "incidente encerrado");
+ eventBus.publish(AgentEvent.of(EventType.INCIDENT_CLOSED, id, "Incidente encerrado", summary));
+ }
+
+ private void transitionTo(IncidentState next, String reason) {
+ IncidentPhase from = state.phase();
+ this.state = next;
+ this.phaseHistory.add(next.phase());
+ eventBus.publish(AgentEvent.of(EventType.STATE_CHANGED, id,
+ "%s -> %s".formatted(from.label(), next.phase().label()), reason)
+ .with("from", from)
+ .with("to", next.phase()));
+ }
+
+ // ---------------------------------------------------------------
+ // Registros feitos pelos estados concretos
+ // ---------------------------------------------------------------
+
+ void recordVerdict(TriageVerdict newVerdict) {
+ this.verdict = newVerdict;
+ eventBus.publish(AgentEvent.of(EventType.TRIAGE_COMPLETED, id,
+ "Veredito: " + newVerdict.classification(),
+ "confianca %.2f -- %s".formatted(newVerdict.confidence(), newVerdict.rationale())));
+ }
+
+ void recordApproval(String who, boolean granted, String reason) {
+ this.approver = who;
+ this.approved = granted;
+ this.approvalReason = reason;
+ }
+
+ void recordClosure(String summary) {
+ this.closingSummary = summary;
+ }
+
+ // ---------------------------------------------------------------
+ // Consultas
+ // ---------------------------------------------------------------
+
+ public String id() {
+ return id;
+ }
+
+ public List alerts() {
+ return alerts;
+ }
+
+ public Instant createdAt() {
+ return createdAt;
+ }
+
+ public IncidentPhase phase() {
+ return state.phase();
+ }
+
+ public boolean allowsContainmentCommands() {
+ return state.allowsContainmentCommands();
+ }
+
+ public Optional verdict() {
+ return Optional.ofNullable(verdict);
+ }
+
+ public Optional approver() {
+ return Optional.ofNullable(approver);
+ }
+
+ public Optional approvalReason() {
+ return Optional.ofNullable(approvalReason);
+ }
+
+ public boolean isApproved() {
+ return approved;
+ }
+
+ public Optional closingSummary() {
+ return Optional.ofNullable(closingSummary);
+ }
+
+ public List phaseHistory() {
+ return List.copyOf(phaseHistory);
+ }
+
+ public Severity highestSeverity() {
+ return alerts.stream()
+ .map(Alert::severity)
+ .reduce(Severity.INFO, Severity::max);
+ }
+
+ public String affectedHost() {
+ return alerts.getFirst().hostname();
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/state/IncidentPhase.java b/src/main/java/br/fatec/esiii/socagent/state/IncidentPhase.java
new file mode 100644
index 0000000..4380452
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/state/IncidentPhase.java
@@ -0,0 +1,31 @@
+package br.fatec.esiii.socagent.state;
+
+/**
+ * Fases do ciclo de vida de um incidente de seguranca.
+ *
+ * Existe separada das classes de estado para que a GUI e os relatorios
+ * possam referenciar a fase sem depender da implementacao do comportamento.
+ */
+public enum IncidentPhase {
+
+ RECEIVED("Recebido"),
+ TRIAGING("Em triagem"),
+ CORRELATING("Correlacionando"),
+ AWAITING_APPROVAL("Aguardando aprovacao"),
+ CONTAINING("Em contencao"),
+ CLOSED("Encerrado");
+
+ private final String label;
+
+ IncidentPhase(String label) {
+ this.label = label;
+ }
+
+ public String label() {
+ return label;
+ }
+
+ public boolean isTerminal() {
+ return this == CLOSED;
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/state/IncidentState.java b/src/main/java/br/fatec/esiii/socagent/state/IncidentState.java
new file mode 100644
index 0000000..04c63ad
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/state/IncidentState.java
@@ -0,0 +1,53 @@
+package br.fatec.esiii.socagent.state;
+
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+
+/**
+ * Estado do incidente (padrao State).
+ *
+ *
Cada operacao devolve o proximo estado. As implementacoes padrao lancam
+ * {@link IllegalTransitionException}, de modo que um estado concreto so precisa
+ * sobrescrever as operacoes que realmente permite. Transicao ilegal deixa de
+ * ser um {@code if} espalhado pelo servico e passa a ser impossivel por
+ * construcao.
+ *
+ *
Os estados sao imutaveis e sem campos, portanto compartilhaveis como
+ * instancias unicas.
+ */
+public interface IncidentState {
+
+ IncidentPhase phase();
+
+ default IncidentState triage(Incident incident, TriageVerdict verdict) {
+ throw new IllegalTransitionException(phase(), "triar");
+ }
+
+ default IncidentState correlate(Incident incident) {
+ throw new IllegalTransitionException(phase(), "correlacionar");
+ }
+
+ default IncidentState requestApproval(Incident incident) {
+ throw new IllegalTransitionException(phase(), "solicitar aprovacao");
+ }
+
+ default IncidentState approve(Incident incident, String approver) {
+ throw new IllegalTransitionException(phase(), "aprovar");
+ }
+
+ default IncidentState deny(Incident incident, String approver, String reason) {
+ throw new IllegalTransitionException(phase(), "negar aprovacao");
+ }
+
+ default IncidentState contain(Incident incident) {
+ throw new IllegalTransitionException(phase(), "conter");
+ }
+
+ default IncidentState close(Incident incident, String summary) {
+ throw new IllegalTransitionException(phase(), "encerrar");
+ }
+
+ /** Indica se comandos de contencao podem ser executados nesta fase. */
+ default boolean allowsContainmentCommands() {
+ return false;
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/state/IncidentStates.java b/src/main/java/br/fatec/esiii/socagent/state/IncidentStates.java
new file mode 100644
index 0000000..d56e2b9
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/state/IncidentStates.java
@@ -0,0 +1,130 @@
+package br.fatec.esiii.socagent.state;
+
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+
+/**
+ * Estados concretos do incidente, agrupados como instancias unicas.
+ *
+ *
Fluxo permitido:
+ *
+ * RECEIVED -> TRIAGING -> CORRELATING -> AWAITING_APPROVAL -> CONTAINING -> CLOSED
+ * | | |
+ * +------------+----------------+--> CLOSED (falso positivo ou negado)
+ *
+ */
+public final class IncidentStates {
+
+ public static final IncidentState RECEIVED = new ReceivedState();
+ public static final IncidentState TRIAGING = new TriagingState();
+ public static final IncidentState CORRELATING = new CorrelatingState();
+ public static final IncidentState AWAITING_APPROVAL = new AwaitingApprovalState();
+ public static final IncidentState CONTAINING = new ContainingState();
+ public static final IncidentState CLOSED = new ClosedState();
+
+ private IncidentStates() {
+ }
+
+ /** Incidente recem-criado a partir de um ou mais alertas. */
+ private static final class ReceivedState implements IncidentState {
+ @Override
+ public IncidentPhase phase() {
+ return IncidentPhase.RECEIVED;
+ }
+
+ @Override
+ public IncidentState triage(Incident incident, TriageVerdict verdict) {
+ incident.recordVerdict(verdict);
+ return TRIAGING;
+ }
+ }
+
+ /** O modelo ja classificou; decide-se entre aprofundar ou encerrar. */
+ private static final class TriagingState implements IncidentState {
+ @Override
+ public IncidentPhase phase() {
+ return IncidentPhase.TRIAGING;
+ }
+
+ @Override
+ public IncidentState correlate(Incident incident) {
+ return CORRELATING;
+ }
+
+ /** Falso positivo encerra sem passar por contencao. */
+ @Override
+ public IncidentState close(Incident incident, String summary) {
+ incident.recordClosure(summary);
+ return CLOSED;
+ }
+ }
+
+ /** Evidencias reunidas; o plano de resposta pode ser submetido a aprovacao. */
+ private static final class CorrelatingState implements IncidentState {
+ @Override
+ public IncidentPhase phase() {
+ return IncidentPhase.CORRELATING;
+ }
+
+ @Override
+ public IncidentState requestApproval(Incident incident) {
+ return AWAITING_APPROVAL;
+ }
+
+ @Override
+ public IncidentState close(Incident incident, String summary) {
+ incident.recordClosure(summary);
+ return CLOSED;
+ }
+ }
+
+ /**
+ * Barreira de governanca: nenhuma acao destrutiva ocorre sem decisao humana.
+ * E o estado que justifica o padrao no dominio de seguranca.
+ */
+ private static final class AwaitingApprovalState implements IncidentState {
+ @Override
+ public IncidentPhase phase() {
+ return IncidentPhase.AWAITING_APPROVAL;
+ }
+
+ @Override
+ public IncidentState approve(Incident incident, String approver) {
+ incident.recordApproval(approver, true, null);
+ return CONTAINING;
+ }
+
+ @Override
+ public IncidentState deny(Incident incident, String approver, String reason) {
+ incident.recordApproval(approver, false, reason);
+ incident.recordClosure("Contencao negada por " + approver + ": " + reason);
+ return CLOSED;
+ }
+ }
+
+ /** Unica fase em que comandos de contencao podem ser executados. */
+ private static final class ContainingState implements IncidentState {
+ @Override
+ public IncidentPhase phase() {
+ return IncidentPhase.CONTAINING;
+ }
+
+ @Override
+ public boolean allowsContainmentCommands() {
+ return true;
+ }
+
+ @Override
+ public IncidentState close(Incident incident, String summary) {
+ incident.recordClosure(summary);
+ return CLOSED;
+ }
+ }
+
+ /** Estado terminal: nenhuma operacao e aceita. */
+ private static final class ClosedState implements IncidentState {
+ @Override
+ public IncidentPhase phase() {
+ return IncidentPhase.CLOSED;
+ }
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/strategy/CommandFactory.java b/src/main/java/br/fatec/esiii/socagent/strategy/CommandFactory.java
new file mode 100644
index 0000000..f1a2dbb
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/strategy/CommandFactory.java
@@ -0,0 +1,84 @@
+package br.fatec.esiii.socagent.strategy;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.command.AgentCommand;
+import br.fatec.esiii.socagent.command.BlockIpCommand;
+import br.fatec.esiii.socagent.command.CollectForensicsCommand;
+import br.fatec.esiii.socagent.command.ContainmentGateway;
+import br.fatec.esiii.socagent.command.IsolateHostCommand;
+import br.fatec.esiii.socagent.command.LookupMitreCommand;
+import br.fatec.esiii.socagent.mitre.MitreRepository;
+
+/**
+ * Converte a sugestao do modelo em um comando executavel.
+ *
+ * Este e o unico ponto do sistema onde a saida do modelo vira acao, e ele
+ * opera por lista de permissao: nome de ferramenta desconhecido e descartado
+ * com registro em log, nunca interpretado. Um modelo que alucine
+ * {@code delete_all_logs} nao encontra tradutor.
+ */
+@Component
+public class CommandFactory {
+
+ private static final Logger log = LoggerFactory.getLogger(CommandFactory.class);
+
+ private static final Set ALLOWED_TOOLS =
+ Set.of("isolate_host", "block_ip", "collect_forensics", "lookup_mitre");
+
+ private final ContainmentGateway gateway;
+ private final MitreRepository mitreRepository;
+
+ public CommandFactory(ContainmentGateway gateway, MitreRepository mitreRepository) {
+ this.gateway = gateway;
+ this.mitreRepository = mitreRepository;
+ }
+
+ public Optional create(ProposedAction action, String defaultHostname) {
+ if (action == null || !ALLOWED_TOOLS.contains(action.tool())) {
+ log.warn("Acao descartada: ferramenta '{}' fora da lista de permissao",
+ action == null ? "null" : action.tool());
+ return Optional.empty();
+ }
+
+ String reason = action.argumentOr("reason", action.rationale() == null
+ ? "sugerido pela triagem automatizada" : action.rationale());
+
+ return switch (action.tool()) {
+ case "isolate_host" -> Optional.of(new IsolateHostCommand(
+ gateway, action.argumentOr("hostname", defaultHostname), reason));
+ case "block_ip" -> {
+ String ip = action.argument("ip_address");
+ yield ip == null || ip.isBlank()
+ ? Optional.empty()
+ : Optional.of(new BlockIpCommand(gateway, ip, reason));
+ }
+ case "collect_forensics" -> Optional.of(new CollectForensicsCommand(
+ gateway, action.argumentOr("hostname", defaultHostname)));
+ case "lookup_mitre" -> {
+ String id = action.argument("technique_id");
+ yield id == null || id.isBlank()
+ ? Optional.empty()
+ : Optional.of(new LookupMitreCommand(mitreRepository::find, id));
+ }
+ default -> Optional.empty();
+ };
+ }
+
+ public List createAll(List actions, String defaultHostname) {
+ return actions.stream()
+ .map(action -> create(action, defaultHostname))
+ .flatMap(Optional::stream)
+ .toList();
+ }
+
+ public static Set allowedTools() {
+ return ALLOWED_TOOLS;
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/strategy/HumanInTheLoopPlanner.java b/src/main/java/br/fatec/esiii/socagent/strategy/HumanInTheLoopPlanner.java
new file mode 100644
index 0000000..fb29b63
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/strategy/HumanInTheLoopPlanner.java
@@ -0,0 +1,46 @@
+package br.fatec.esiii.socagent.strategy;
+
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.state.Incident;
+
+/**
+ * Estrategia que envolve outra e forca aprovacao humana em qualquer plano nao
+ * vazio, mesmo quando composto apenas por acoes de leitura.
+ *
+ * E um Strategy que tambem se comporta como Decorator: acrescenta
+ * governanca sem duplicar o planejamento. Em ambientes regulados costuma ser a
+ * unica configuracao aceitavel em producao.
+ */
+@Component
+public class HumanInTheLoopPlanner implements TriagePlanner {
+
+ private final TriagePlanner delegate;
+
+ public HumanInTheLoopPlanner(PlanThenExecutePlanner delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public String id() {
+ return "human-in-the-loop";
+ }
+
+ @Override
+ public String displayName() {
+ return "Humano no circuito (aprovacao sempre)";
+ }
+
+ @Override
+ public Plan plan(Incident incident) {
+ Plan original = delegate.plan(incident);
+ if (original.isEmpty()) {
+ return original;
+ }
+ return new Plan(original.verdict(), original.commands(), true);
+ }
+
+ public String delegateId() {
+ return delegate.id();
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/strategy/PlanThenExecutePlanner.java b/src/main/java/br/fatec/esiii/socagent/strategy/PlanThenExecutePlanner.java
new file mode 100644
index 0000000..17790fd
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/strategy/PlanThenExecutePlanner.java
@@ -0,0 +1,54 @@
+package br.fatec.esiii.socagent.strategy;
+
+import java.util.List;
+
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.command.AgentCommand;
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+import br.fatec.esiii.socagent.state.Incident;
+
+/**
+ * Estrategia que pede o plano completo ao modelo em uma unica interacao e
+ * so depois executa.
+ *
+ *
Vantagem: previsivel, barato e auditavel — o plano inteiro pode ser
+ * inspecionado antes de qualquer efeito colateral. Desvantagem: nao se adapta
+ * ao que for descoberto durante a execucao.
+ */
+@Component
+public class PlanThenExecutePlanner implements TriagePlanner {
+
+ private final ThreatAnalyst analyst;
+ private final CommandFactory commandFactory;
+
+ public PlanThenExecutePlanner(ThreatAnalyst analyst, CommandFactory commandFactory) {
+ this.analyst = analyst;
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public String id() {
+ return "plan-then-execute";
+ }
+
+ @Override
+ public String displayName() {
+ return "Planejar e depois executar";
+ }
+
+ @Override
+ public Plan plan(Incident incident) {
+ TriageVerdict verdict = analyst.classify(incident);
+
+ if (verdict.classification() == TriageVerdict.Classification.FALSE_POSITIVE) {
+ return new Plan(verdict, List.of(), false);
+ }
+
+ List proposals = analyst.proposePlan(incident);
+ List commands = commandFactory.createAll(proposals, incident.affectedHost());
+
+ boolean needsApproval = commands.stream().anyMatch(AgentCommand::requiresApproval);
+ return new Plan(verdict, commands, needsApproval);
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/strategy/ProposedAction.java b/src/main/java/br/fatec/esiii/socagent/strategy/ProposedAction.java
new file mode 100644
index 0000000..d6146a9
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/strategy/ProposedAction.java
@@ -0,0 +1,28 @@
+package br.fatec.esiii.socagent.strategy;
+
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Acao sugerida pelo modelo de linguagem.
+ *
+ * E deliberadamente um dado inerte: nome de ferramenta e argumentos, sem
+ * qualquer capacidade de execucao. Somente a {@code CommandFactory} converte
+ * isso em um {@link br.fatec.esiii.socagent.command.AgentCommand}, e apenas
+ * para nomes previstos na lista de permissao.
+ */
+public record ProposedAction(String tool, Map arguments, String rationale) {
+
+ public ProposedAction {
+ Objects.requireNonNull(tool, "tool nao pode ser nulo");
+ arguments = arguments == null ? Map.of() : Map.copyOf(arguments);
+ }
+
+ public String argument(String key) {
+ return arguments.get(key);
+ }
+
+ public String argumentOr(String key, String fallback) {
+ return arguments.getOrDefault(key, fallback);
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/strategy/ReActPlanner.java b/src/main/java/br/fatec/esiii/socagent/strategy/ReActPlanner.java
new file mode 100644
index 0000000..5aff680
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/strategy/ReActPlanner.java
@@ -0,0 +1,81 @@
+package br.fatec.esiii.socagent.strategy;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.stereotype.Component;
+
+import br.fatec.esiii.socagent.command.AgentCommand;
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+import br.fatec.esiii.socagent.state.Incident;
+
+/**
+ * Estrategia ReAct: alterna raciocinio e acao, decidindo o proximo passo a
+ * partir do que ja foi observado.
+ *
+ * Somente comandos de leitura sao executados durante o ciclo; qualquer acao
+ * destrutiva e acumulada para depois da aprovacao. Sem essa separacao, o ciclo
+ * adaptativo contornaria a barreira de governanca.
+ *
+ *
O limite de passos evita que um modelo indeciso itere indefinidamente.
+ */
+@Component
+public class ReActPlanner implements TriagePlanner {
+
+ private static final int MAX_STEPS = 5;
+
+ private final ThreatAnalyst analyst;
+ private final CommandFactory commandFactory;
+
+ public ReActPlanner(ThreatAnalyst analyst, CommandFactory commandFactory) {
+ this.analyst = analyst;
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public String id() {
+ return "react";
+ }
+
+ @Override
+ public String displayName() {
+ return "ReAct (raciocinio e acao alternados)";
+ }
+
+ @Override
+ public Plan plan(Incident incident) {
+ TriageVerdict verdict = analyst.classify(incident);
+
+ if (verdict.classification() == TriageVerdict.Classification.FALSE_POSITIVE) {
+ return new Plan(verdict, List.of(), false);
+ }
+
+ List observations = new ArrayList<>();
+ List pendingContainment = new ArrayList<>();
+
+ for (int step = 0; step < MAX_STEPS; step++) {
+ Optional next = analyst.proposeNext(incident, observations);
+ if (next.isEmpty()) {
+ break;
+ }
+
+ Optional command = commandFactory.create(next.get(), incident.affectedHost());
+ if (command.isEmpty()) {
+ observations.add("Acao '%s' rejeitada pela lista de permissao".formatted(next.get().tool()));
+ continue;
+ }
+
+ AgentCommand candidate = command.get();
+ if (candidate.requiresApproval()) {
+ pendingContainment.add(candidate);
+ observations.add("Acao '%s' acumulada para apos aprovacao humana".formatted(candidate.name()));
+ } else {
+ observations.add(candidate.execute().output());
+ }
+ }
+
+ boolean needsApproval = !pendingContainment.isEmpty();
+ return new Plan(verdict, pendingContainment, needsApproval);
+ }
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/strategy/ThreatAnalyst.java b/src/main/java/br/fatec/esiii/socagent/strategy/ThreatAnalyst.java
new file mode 100644
index 0000000..2a1df72
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/strategy/ThreatAnalyst.java
@@ -0,0 +1,26 @@
+package br.fatec.esiii.socagent.strategy;
+
+import java.util.List;
+import java.util.Optional;
+
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+import br.fatec.esiii.socagent.state.Incident;
+
+/**
+ * Porta de saida para o modelo de linguagem.
+ *
+ * Os planejadores dependem desta interface, nunca do Spring AI diretamente.
+ * Isso permite testar cada estrategia com um analista falso e deterministico,
+ * e trocar o modelo aberto sem tocar na logica de planejamento.
+ */
+public interface ThreatAnalyst {
+
+ /** Classifica o incidente: verdadeiro positivo, falso positivo ou revisao. */
+ TriageVerdict classify(Incident incident);
+
+ /** Produz o plano completo de resposta de uma unica vez. */
+ List proposePlan(Incident incident);
+
+ /** Sugere a proxima acao a partir do que ja foi observado. Vazio encerra o ciclo. */
+ Optional proposeNext(Incident incident, List observations);
+}
diff --git a/src/main/java/br/fatec/esiii/socagent/strategy/TriagePlanner.java b/src/main/java/br/fatec/esiii/socagent/strategy/TriagePlanner.java
new file mode 100644
index 0000000..3b866ab
--- /dev/null
+++ b/src/main/java/br/fatec/esiii/socagent/strategy/TriagePlanner.java
@@ -0,0 +1,44 @@
+package br.fatec.esiii.socagent.strategy;
+
+import java.util.List;
+
+import br.fatec.esiii.socagent.command.AgentCommand;
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+import br.fatec.esiii.socagent.state.Incident;
+
+/**
+ * Estrategia de planejamento da resposta (padrao Strategy).
+ *
+ * O runtime do agente conhece apenas esta interface. Trocar de
+ * {@link ReActPlanner} para {@link PlanThenExecutePlanner} nao altera uma linha
+ * do orquestrador, e envolver qualquer um deles em
+ * {@link HumanInTheLoopPlanner} acrescenta governanca sem modifica-los.
+ */
+public interface TriagePlanner {
+
+ /** Identificador tecnico, usado em configuracao e metricas. */
+ String id();
+
+ /** Nome exibido na GUI ao selecionar a estrategia. */
+ String displayName();
+
+ Plan plan(Incident incident);
+
+ /**
+ * Resultado do planejamento.
+ *
+ * @param verdict classificacao produzida pelo modelo
+ * @param commands comandos ja traduzidos e validados
+ * @param requiresApproval se a estrategia exige decisao humana antes de executar
+ */
+ record Plan(TriageVerdict verdict, List commands, boolean requiresApproval) {
+
+ public Plan {
+ commands = commands == null ? List.of() : List.copyOf(commands);
+ }
+
+ public boolean isEmpty() {
+ return commands.isEmpty();
+ }
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..ddedc2e
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,25 @@
+spring:
+ application:
+ name: soc-agent
+ main:
+ web-application-type: none
+ banner-mode: off
+ ai:
+ ollama:
+ base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
+ chat:
+ options:
+ model: ${SOC_AGENT_MODEL:qwen2.5:7b}
+ temperature: 0.1
+
+soc-agent:
+ # Estrategia ativa: human-in-the-loop | plan-then-execute | react
+ planner: human-in-the-loop
+ # Abaixo desta confianca o veredito nunca e acionavel sem revisao humana
+ minimum-confidence: 0.6
+ default-approver: analista.local
+
+logging:
+ level:
+ root: WARN
+ br.fatec.esiii.socagent: INFO
diff --git a/src/test/java/br/fatec/esiii/socagent/command/CommandInvokerTest.java b/src/test/java/br/fatec/esiii/socagent/command/CommandInvokerTest.java
new file mode 100644
index 0000000..fc3bc71
--- /dev/null
+++ b/src/test/java/br/fatec/esiii/socagent/command/CommandInvokerTest.java
@@ -0,0 +1,146 @@
+package br.fatec.esiii.socagent.command;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Instant;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import br.fatec.esiii.socagent.domain.Alert;
+import br.fatec.esiii.socagent.domain.Ioc;
+import br.fatec.esiii.socagent.domain.Severity;
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+import br.fatec.esiii.socagent.observer.AgentEventBus;
+import br.fatec.esiii.socagent.state.Incident;
+
+class CommandInvokerTest {
+
+ private SimulatedContainmentGateway gateway;
+ private CommandInvoker invoker;
+ private AgentEventBus eventBus;
+
+ @BeforeEach
+ void setUp() {
+ gateway = new SimulatedContainmentGateway();
+ eventBus = new AgentEventBus();
+ invoker = new CommandInvoker(eventBus);
+ }
+
+ private Incident incidenteEmContencao() {
+ Incident incidente = novoIncidente();
+ incidente.triage(new TriageVerdict(TriageVerdict.Classification.TRUE_POSITIVE, 0.92,
+ "Exfiltracao confirmada", List.of("T1041")));
+ incidente.correlate();
+ incidente.requestApproval();
+ incidente.approve("analista.turno1");
+ return incidente;
+ }
+
+ private Incident novoIncidente() {
+ Alert alerta = new Alert("ALR-1", "suricata", "WKS-4471",
+ "Trafego de exfiltracao", Severity.CRITICAL, Instant.now(),
+ List.of(Ioc.ip("185.220.101.7")));
+ return new Incident("INC-1", List.of(alerta), eventBus);
+ }
+
+ @Test
+ @DisplayName("recusa comando destrutivo antes da aprovacao humana")
+ void recusaSemAprovacao() {
+ Incident incidente = novoIncidente();
+ AgentCommand isolar = new IsolateHostCommand(gateway, "WKS-4471", "exfiltracao");
+
+ var registro = invoker.execute(incidente, isolar);
+
+ assertThat(registro.result().success()).isFalse();
+ assertThat(registro.result().output()).contains("exige aprovacao humana");
+ assertThat(gateway.isIsolated("WKS-4471")).isFalse();
+ }
+
+ @Test
+ @DisplayName("executa comando destrutivo apos aprovacao")
+ void executaAposAprovacao() {
+ Incident incidente = incidenteEmContencao();
+ AgentCommand isolar = new IsolateHostCommand(gateway, "WKS-4471", "exfiltracao");
+
+ var registro = invoker.execute(incidente, isolar);
+
+ assertThat(registro.result().success()).isTrue();
+ assertThat(gateway.isIsolated("WKS-4471")).isTrue();
+ }
+
+ @Test
+ @DisplayName("comando somente de leitura dispensa aprovacao")
+ void leituraDispensaAprovacao() {
+ Incident incidente = novoIncidente();
+ AgentCommand coleta = new CollectForensicsCommand(gateway, "WKS-4471");
+
+ var registro = invoker.execute(incidente, coleta);
+
+ assertThat(registro.result().success()).isTrue();
+ }
+
+ @Test
+ @DisplayName("desfaz a ultima contencao aplicada")
+ void desfazContencao() {
+ Incident incidente = incidenteEmContencao();
+ invoker.execute(incidente, new IsolateHostCommand(gateway, "WKS-4471", "exfiltracao"));
+ assertThat(gateway.isIsolated("WKS-4471")).isTrue();
+
+ var desfeito = invoker.undoLast(incidente);
+
+ assertThat(desfeito.result().success()).isTrue();
+ assertThat(gateway.isIsolated("WKS-4471")).isFalse();
+ assertThat(invoker.undoableCount()).isZero();
+ }
+
+ @Test
+ @DisplayName("processa a fila inteira preservando a ordem")
+ void processaFila() {
+ Incident incidente = incidenteEmContencao();
+ invoker.enqueue(incidente, new CollectForensicsCommand(gateway, "WKS-4471"));
+ invoker.enqueue(incidente, new IsolateHostCommand(gateway, "WKS-4471", "exfiltracao"));
+ invoker.enqueue(incidente, new BlockIpCommand(gateway, "185.220.101.7", "C2"));
+
+ var executados = invoker.executeQueue(incidente);
+
+ assertThat(executados).hasSize(3);
+ assertThat(executados).extracting(CommandInvoker.ExecutionRecord::commandName)
+ .containsExactly("collect_forensics", "isolate_host", "block_ip");
+ assertThat(invoker.pendingCount()).isZero();
+ assertThat(gateway.blockedIps()).contains("185.220.101.7");
+ }
+
+ @Test
+ @DisplayName("falha de um comando nao interrompe a fila")
+ void falhaNaoInterrompeFila() {
+ Incident incidente = incidenteEmContencao();
+ AgentCommand quebrado = new AgentCommand() {
+ @Override
+ public String name() {
+ return "comando_quebrado";
+ }
+
+ @Override
+ public String description() {
+ return "simula falha de integracao";
+ }
+
+ @Override
+ public CommandResult execute() {
+ throw new IllegalStateException("EDR indisponivel");
+ }
+ };
+
+ invoker.enqueue(incidente, quebrado);
+ invoker.enqueue(incidente, new CollectForensicsCommand(gateway, "WKS-4471"));
+ var executados = invoker.executeQueue(incidente);
+
+ assertThat(executados).hasSize(2);
+ assertThat(executados.get(0).result().success()).isFalse();
+ assertThat(executados.get(0).result().output()).contains("EDR indisponivel");
+ assertThat(executados.get(1).result().success()).isTrue();
+ }
+}
diff --git a/src/test/java/br/fatec/esiii/socagent/gui/tree/EvidenceNodeTest.java b/src/test/java/br/fatec/esiii/socagent/gui/tree/EvidenceNodeTest.java
new file mode 100644
index 0000000..104fb24
--- /dev/null
+++ b/src/test/java/br/fatec/esiii/socagent/gui/tree/EvidenceNodeTest.java
@@ -0,0 +1,67 @@
+package br.fatec.esiii.socagent.gui.tree;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import br.fatec.esiii.socagent.domain.Ioc;
+import br.fatec.esiii.socagent.domain.Severity;
+
+class EvidenceNodeTest {
+
+ private EvidenceGroup arvore() {
+ EvidenceGroup raiz = new EvidenceGroup("INC-0001", Severity.INFO);
+ EvidenceGroup alerta = new EvidenceGroup("Exfiltracao detectada", Severity.LOW);
+ alerta.add(EvidenceLeaf.ofIoc(Ioc.ip("185.220.101.7"), Severity.CRITICAL));
+ alerta.add(EvidenceLeaf.ofIoc(Ioc.host("powershell.exe"), Severity.MEDIUM));
+ raiz.add(alerta);
+ raiz.add(EvidenceLeaf.ofNote("coleta forense concluida"));
+ return raiz;
+ }
+
+ @Test
+ @DisplayName("conta folhas recursivamente atraves de toda a arvore")
+ void contaFolhas() {
+ assertThat(arvore().leafCount()).isEqualTo(3);
+ }
+
+ @Test
+ @DisplayName("agrega a maior severidade dos descendentes")
+ void agregaSeveridade() {
+ EvidenceGroup raiz = arvore();
+
+ assertThat(raiz.severity()).isEqualTo(Severity.INFO);
+ assertThat(raiz.highestSeverity()).isEqualTo(Severity.CRITICAL);
+ }
+
+ @Test
+ @DisplayName("folha e composto respondem a mesma interface")
+ void tratamentoUniforme() {
+ EvidenceNode folha = EvidenceLeaf.ofNote("observacao isolada");
+ EvidenceNode composto = arvore();
+
+ assertThat(folha.leafCount()).isEqualTo(1);
+ assertThat(composto.leafCount()).isEqualTo(3);
+ assertThat(folha.render(0)).contains("- observacao isolada");
+ assertThat(composto.render(0)).contains("+ INC-0001");
+ }
+
+ @Test
+ @DisplayName("folha recusa filhos")
+ void folhaRecusaFilhos() {
+ EvidenceNode folha = EvidenceLeaf.ofNote("sem filhos");
+
+ assertThatThrownBy(() -> folha.add(EvidenceLeaf.ofNote("tentativa")))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("folha");
+ }
+
+ @Test
+ @DisplayName("calcula a profundidade da arvore")
+ void calculaProfundidade() {
+ assertThat(arvore().depth()).isEqualTo(3);
+ assertThat(EvidenceLeaf.ofNote("isolada").depth()).isEqualTo(1);
+ }
+}
diff --git a/src/test/java/br/fatec/esiii/socagent/observer/AgentEventBusTest.java b/src/test/java/br/fatec/esiii/socagent/observer/AgentEventBusTest.java
new file mode 100644
index 0000000..0f13639
--- /dev/null
+++ b/src/test/java/br/fatec/esiii/socagent/observer/AgentEventBusTest.java
@@ -0,0 +1,85 @@
+package br.fatec.esiii.socagent.observer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import br.fatec.esiii.socagent.observer.AgentEvent.EventType;
+
+class AgentEventBusTest {
+
+ @Test
+ @DisplayName("difunde o evento para todos os observadores inscritos")
+ void difundeParaTodos() {
+ AgentEventBus bus = new AgentEventBus();
+ List recebidosA = new ArrayList<>();
+ List recebidosB = new ArrayList<>();
+
+ bus.subscribe(event -> recebidosA.add(event.title()));
+ bus.subscribe(event -> recebidosB.add(event.title()));
+
+ bus.publish(AgentEvent.of(EventType.STATE_CHANGED, "INC-1", "mudou", null));
+
+ assertThat(recebidosA).containsExactly("mudou");
+ assertThat(recebidosB).containsExactly("mudou");
+ }
+
+ @Test
+ @DisplayName("falha de um observador nao impede os demais de receber")
+ void falhaIsolada() {
+ AgentEventBus bus = new AgentEventBus();
+ List sobreviventes = new ArrayList<>();
+
+ bus.subscribe(event -> {
+ throw new IllegalStateException("observador com defeito");
+ });
+ bus.subscribe(event -> sobreviventes.add(event.title()));
+
+ bus.publish(AgentEvent.of(EventType.COMMAND_EXECUTED, "INC-1", "executado", null));
+
+ assertThat(sobreviventes).containsExactly("executado");
+ }
+
+ @Test
+ @DisplayName("o registrar inscreve todos os observadores declarados como bean")
+ void registrarInscreveBeans() {
+ AgentEventBus bus = new AgentEventBus();
+ AuditTrailListener auditoria = new AuditTrailListener();
+
+ new EventListenerRegistrar(bus, List.of(auditoria, new ConsoleListener()));
+ assertThat(bus.listenerCount()).isEqualTo(2);
+
+ bus.publish(AgentEvent.of(EventType.INCIDENT_CREATED, "INC-1", "aberto", "2 alertas"));
+
+ assertThat(auditoria.entries()).hasSize(1);
+ assertThat(auditoria.entries().getFirst()).contains("INCIDENT_CREATED").contains("aberto");
+ }
+
+ @Test
+ @DisplayName("observador pode filtrar os tipos que lhe interessam")
+ void filtraPorTipo() {
+ AgentEventBus bus = new AgentEventBus();
+ List apenasComandos = new ArrayList<>();
+
+ bus.subscribe(new AgentEventListener() {
+ @Override
+ public void onEvent(AgentEvent event) {
+ apenasComandos.add(event.title());
+ }
+
+ @Override
+ public boolean supports(AgentEvent event) {
+ return event.type() == EventType.COMMAND_EXECUTED;
+ }
+ });
+
+ bus.publish(AgentEvent.of(EventType.STATE_CHANGED, "INC-1", "transicao", null));
+ bus.publish(AgentEvent.of(EventType.COMMAND_EXECUTED, "INC-1", "comando", null));
+
+ assertThat(apenasComandos).containsExactly("comando");
+ }
+}
diff --git a/src/test/java/br/fatec/esiii/socagent/state/IncidentStateTest.java b/src/test/java/br/fatec/esiii/socagent/state/IncidentStateTest.java
new file mode 100644
index 0000000..b74e6b8
--- /dev/null
+++ b/src/test/java/br/fatec/esiii/socagent/state/IncidentStateTest.java
@@ -0,0 +1,96 @@
+package br.fatec.esiii.socagent.state;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.Instant;
+import java.util.List;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import br.fatec.esiii.socagent.domain.Alert;
+import br.fatec.esiii.socagent.domain.Ioc;
+import br.fatec.esiii.socagent.domain.Severity;
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+import br.fatec.esiii.socagent.observer.AgentEventBus;
+
+class IncidentStateTest {
+
+ private Incident novoIncidente() {
+ Alert alerta = new Alert("ALR-1", "suricata", "WKS-4471",
+ "Trafego de exfiltracao detectado", Severity.CRITICAL, Instant.now(),
+ List.of(Ioc.ip("185.220.101.7")));
+ return new Incident("INC-1", List.of(alerta), new AgentEventBus());
+ }
+
+ private TriageVerdict verdadeiroPositivo() {
+ return new TriageVerdict(TriageVerdict.Classification.TRUE_POSITIVE, 0.9,
+ "Padrao compativel com exfiltracao", List.of("T1041"));
+ }
+
+ @Test
+ @DisplayName("percorre o fluxo completo ate a contencao")
+ void fluxoCompleto() {
+ Incident incidente = novoIncidente();
+ assertThat(incidente.phase()).isEqualTo(IncidentPhase.RECEIVED);
+
+ incidente.triage(verdadeiroPositivo());
+ assertThat(incidente.phase()).isEqualTo(IncidentPhase.TRIAGING);
+
+ incidente.correlate();
+ assertThat(incidente.phase()).isEqualTo(IncidentPhase.CORRELATING);
+
+ incidente.requestApproval();
+ assertThat(incidente.phase()).isEqualTo(IncidentPhase.AWAITING_APPROVAL);
+
+ incidente.approve("analista.turno1");
+ assertThat(incidente.phase()).isEqualTo(IncidentPhase.CONTAINING);
+ assertThat(incidente.allowsContainmentCommands()).isTrue();
+
+ incidente.close("Host isolado e evidencias preservadas");
+ assertThat(incidente.phase()).isEqualTo(IncidentPhase.CLOSED);
+ }
+
+ @Test
+ @DisplayName("nao permite contencao antes da aprovacao humana")
+ void contencaoExigeAprovacao() {
+ Incident incidente = novoIncidente();
+ incidente.triage(verdadeiroPositivo());
+ incidente.correlate();
+
+ assertThat(incidente.allowsContainmentCommands()).isFalse();
+
+ assertThatThrownBy(() -> incidente.approve("analista.turno1"))
+ .isInstanceOf(IllegalTransitionException.class)
+ .hasMessageContaining("CORRELATING");
+ }
+
+ @Test
+ @DisplayName("aprovacao negada encerra o incidente sem conter")
+ void aprovacaoNegadaEncerra() {
+ Incident incidente = novoIncidente();
+ incidente.triage(verdadeiroPositivo());
+ incidente.correlate();
+ incidente.requestApproval();
+
+ incidente.deny("analista.turno1", "host critico em janela de faturamento");
+
+ assertThat(incidente.phase()).isEqualTo(IncidentPhase.CLOSED);
+ assertThat(incidente.isApproved()).isFalse();
+ assertThat(incidente.allowsContainmentCommands()).isFalse();
+ }
+
+ @Test
+ @DisplayName("incidente encerrado nao aceita mais nenhuma operacao")
+ void estadoTerminalRejeitaTudo() {
+ Incident incidente = novoIncidente();
+ incidente.triage(new TriageVerdict(TriageVerdict.Classification.FALSE_POSITIVE, 0.95,
+ "Trafego legitimo de backup", List.of()));
+ incidente.close("Falso positivo confirmado");
+
+ assertThat(incidente.phase()).isEqualTo(IncidentPhase.CLOSED);
+ assertThatThrownBy(incidente::correlate).isInstanceOf(IllegalTransitionException.class);
+ assertThatThrownBy(() -> incidente.approve("qualquer")).isInstanceOf(IllegalTransitionException.class);
+ }
+}
diff --git a/src/test/java/br/fatec/esiii/socagent/strategy/TriagePlannerTest.java b/src/test/java/br/fatec/esiii/socagent/strategy/TriagePlannerTest.java
new file mode 100644
index 0000000..2781670
--- /dev/null
+++ b/src/test/java/br/fatec/esiii/socagent/strategy/TriagePlannerTest.java
@@ -0,0 +1,147 @@
+package br.fatec.esiii.socagent.strategy;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import br.fatec.esiii.socagent.command.SimulatedContainmentGateway;
+import br.fatec.esiii.socagent.domain.Alert;
+import br.fatec.esiii.socagent.domain.Ioc;
+import br.fatec.esiii.socagent.domain.Severity;
+import br.fatec.esiii.socagent.domain.TriageVerdict;
+import br.fatec.esiii.socagent.mitre.MitreRepository;
+import br.fatec.esiii.socagent.observer.AgentEventBus;
+import br.fatec.esiii.socagent.state.Incident;
+
+class TriagePlannerTest {
+
+ private CommandFactory commandFactory;
+
+ @BeforeEach
+ void setUp() {
+ commandFactory = new CommandFactory(new SimulatedContainmentGateway(), new MitreRepository());
+ }
+
+ private Incident incidente() {
+ Alert alerta = new Alert("ALR-1", "suricata", "WKS-4471",
+ "Trafego de exfiltracao", Severity.CRITICAL, Instant.now(),
+ List.of(Ioc.ip("185.220.101.7")));
+ return new Incident("INC-1", List.of(alerta), new AgentEventBus());
+ }
+
+ /** Analista falso e deterministico: isola os planejadores do modelo real. */
+ private static class AnalistaFalso implements ThreatAnalyst {
+ private final TriageVerdict verdict;
+ private final List plano;
+ private final List passos;
+ private int indice;
+
+ AnalistaFalso(TriageVerdict verdict, List plano, List passos) {
+ this.verdict = verdict;
+ this.plano = plano;
+ this.passos = new ArrayList<>(passos);
+ }
+
+ @Override
+ public TriageVerdict classify(Incident incident) {
+ return verdict;
+ }
+
+ @Override
+ public List proposePlan(Incident incident) {
+ return plano;
+ }
+
+ @Override
+ public Optional proposeNext(Incident incident, List observations) {
+ return indice < passos.size() ? Optional.of(passos.get(indice++)) : Optional.empty();
+ }
+ }
+
+ private TriageVerdict positivo() {
+ return new TriageVerdict(TriageVerdict.Classification.TRUE_POSITIVE, 0.9,
+ "Exfiltracao confirmada", List.of("T1041"));
+ }
+
+ @Test
+ @DisplayName("plan-then-execute traduz o plano inteiro e exige aprovacao se houver acao destrutiva")
+ void planoCompleto() {
+ var analista = new AnalistaFalso(positivo(), List.of(
+ new ProposedAction("collect_forensics", Map.of("hostname", "WKS-4471"), "preservar evidencia"),
+ new ProposedAction("isolate_host", Map.of("hostname", "WKS-4471"), "conter exfiltracao")),
+ List.of());
+
+ var plano = new PlanThenExecutePlanner(analista, commandFactory).plan(incidente());
+
+ assertThat(plano.commands()).hasSize(2);
+ assertThat(plano.requiresApproval()).isTrue();
+ }
+
+ @Test
+ @DisplayName("falso positivo nao gera nenhum comando")
+ void falsoPositivoNaoAge() {
+ var analista = new AnalistaFalso(
+ new TriageVerdict(TriageVerdict.Classification.FALSE_POSITIVE, 0.95,
+ "Backup legitimo", List.of()),
+ List.of(new ProposedAction("isolate_host", Map.of(), "nao deveria acontecer")),
+ List.of());
+
+ var plano = new PlanThenExecutePlanner(analista, commandFactory).plan(incidente());
+
+ assertThat(plano.commands()).isEmpty();
+ assertThat(plano.requiresApproval()).isFalse();
+ }
+
+ @Test
+ @DisplayName("ferramenta fora da lista de permissao e descartada")
+ void ferramentaNaoPermitida() {
+ var analista = new AnalistaFalso(positivo(), List.of(
+ new ProposedAction("delete_all_logs", Map.of(), "acao alucinada pelo modelo"),
+ new ProposedAction("collect_forensics", Map.of("hostname", "WKS-4471"), "legitima")),
+ List.of());
+
+ var plano = new PlanThenExecutePlanner(analista, commandFactory).plan(incidente());
+
+ assertThat(plano.commands()).hasSize(1);
+ assertThat(plano.commands().getFirst().name()).isEqualTo("collect_forensics");
+ }
+
+ @Test
+ @DisplayName("ReAct executa leitura no ciclo e acumula contencao para aprovacao")
+ void reactSeparaLeituraDeContencao() {
+ var analista = new AnalistaFalso(positivo(), List.of(), List.of(
+ new ProposedAction("lookup_mitre", Map.of("technique_id", "T1041"), "entender a tecnica"),
+ new ProposedAction("collect_forensics", Map.of("hostname", "WKS-4471"), "coletar"),
+ new ProposedAction("isolate_host", Map.of("hostname", "WKS-4471"), "conter")));
+
+ var plano = new ReActPlanner(analista, commandFactory).plan(incidente());
+
+ assertThat(plano.commands()).hasSize(1);
+ assertThat(plano.commands().getFirst().name()).isEqualTo("isolate_host");
+ assertThat(plano.requiresApproval()).isTrue();
+ }
+
+ @Test
+ @DisplayName("human-in-the-loop forca aprovacao mesmo para plano so de leitura")
+ void humanoNoCircuitoForcaAprovacao() {
+ var analista = new AnalistaFalso(positivo(), List.of(
+ new ProposedAction("collect_forensics", Map.of("hostname", "WKS-4471"), "coletar")),
+ List.of());
+ var delegado = new PlanThenExecutePlanner(analista, commandFactory);
+
+ var planoDireto = delegado.plan(incidente());
+ assertThat(planoDireto.requiresApproval()).isFalse();
+
+ var planoComGovernanca = new HumanInTheLoopPlanner(delegado).plan(incidente());
+ assertThat(planoComGovernanca.requiresApproval()).isTrue();
+ assertThat(planoComGovernanca.commands()).hasSize(1);
+ }
+}