diff --git a/.gitignore b/.gitignore index 524f096..130692e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,17 @@ +# Build output +target/ + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.classpath +.project + +# macOS +.DS_Store + # Compiled class file *.class diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/README.md b/README.md index 8f5374b..fa93bf3 100644 --- a/README.md +++ b/README.md @@ -1 +1,220 @@ -# ES-III \ No newline at end of file +# Agente de Triagem de Incidentes de Segurança + +Agente autônomo que classifica alertas de segurança, correlaciona evidências e +propõe ações de contenção — com **aprovação humana obrigatória** antes de +qualquer ação destrutiva. + +Construído com Spring AI e modelos de linguagem abertos executados localmente. +Nenhuma chamada a serviço externo, nenhuma chave de API. + +> Trabalho da disciplina de Engenharia de Software III — FATEC + +--- + +## O que este projeto demonstra + +Cinco padrões de projeto do GoF aplicados onde o domínio realmente os exige: + +| Padrão | Onde vive | O que resolve aqui | +|---|---|---| +| **State** | `state/` | Ciclo de vida do incidente; torna impossível conter um host antes da aprovação | +| **Command** | `command/` | Ações de resposta enfileiráveis, auditáveis e reversíveis | +| **Strategy** | `strategy/` | Três políticas de planejamento trocáveis em tempo de execução | +| **Observer** | `observer/` | Trilha de auditoria, log de console e painel alimentados pelos mesmos eventos | +| **Composite** | `gui/tree/` | Árvore de evidências na interface gráfica | + +A justificativa detalhada de cada escolha, com os diagramas UML, está em +**[docs/arquitetura.md](docs/arquitetura.md)**. + +--- + +## Stack — tudo software livre + +| Camada | Componente | Licença | +|---|---|---| +| Framework | Spring Boot 3.5.3 + Spring AI 1.0.0 | Apache 2.0 | +| Runtime do modelo | Ollama | MIT | +| Modelo de linguagem | Qwen 2.5 7B | Apache 2.0 | +| Interface gráfica | Swing (OpenJDK) | GPLv2 + Classpath Exception | +| Build | Maven | Apache 2.0 | +| Testes | JUnit 5 + AssertJ | EPL 2.0 / Apache 2.0 | +| Diagramas | Mermaid | MIT | + +O modelo padrão é o **Qwen 2.5 7B**, sob Apache 2.0 — licença aprovada pela OSI. +O Llama 3.1 funciona igualmente bem e pode ser configurado, mas sua licença é +*source-available*, não open source no sentido estrito: traz restrição de uso +acima de 700 milhões de usuários mensais e obrigação de atribuição. + +--- + +## Pré-requisitos + +- JDK 21 +- Ollama +- ~5 GB de disco para o modelo + +```bash +brew install openjdk@21 ollama +``` + +--- + +## Como executar + +**1. Suba o Ollama e baixe o modelo** + +```bash +ollama serve +``` + +```bash +ollama pull qwen2.5:7b +``` + +**2. Rode a interface gráfica** + +```bash +./mvnw spring-boot:run +``` + +**3. Ou rode em terminal, sem interface** + +```bash +./mvnw spring-boot:run -Dspring-boot.run.arguments="--spring.profiles.active=headless" +``` + +O agente para na aprovação, como pararia em produção. Para simular a decisão +humana e ver a contenção executar, acrescente `--aprovar`: + +```bash +./mvnw spring-boot:run -Dspring-boot.run.arguments="--spring.profiles.active=headless --aprovar" +``` + +Para escolher o cenário, basta um trecho do nome — a linha de comando quebra o +argumento nos espaços: + +```bash +./mvnw spring-boot:run -Dspring-boot.run.arguments="--spring.profiles.active=headless --cenario=Cifragem" +``` + +**4. Testes** — rodam offline, sem Ollama: + +```bash +./mvnw test +``` + +--- + +## Usando a interface + +1. Escolha um dos quatro cenários no seletor superior +2. Escolha a estratégia de planejamento — o efeito é visível no resultado +3. Clique em **Triar incidente** +4. O agente classifica, correlaciona e **para**, aguardando decisão +5. **Aprovar** executa a contenção; **Negar** encerra sem qualquer ação +6. **Desfazer** reverte a última contenção aplicada + +O painel esquerdo mostra a árvore de evidências (Composite) e o direito a +sequência de eventos (Observer). Os botões habilitam e desabilitam conforme a +fase do incidente (State). + +--- + +## Cenários incluídos + +| Cenário | Técnica ATT&CK | Observado com Qwen 2.5 7B | +|---|---|---| +| Exfiltração via canal C2 | T1041 | Verdadeiro positivo, confiança 0,90 — contenção proposta | +| Força bruta em VPN | T1110 | — | +| Cifragem em massa | T1486 | — | +| Backup noturno | — | **Verdadeiro positivo, confiança 0,80 — classificação incorreta** | + +Hosts, contas e endereços são fictícios. As linhas sem observação ainda não +foram medidas; a saída do modelo varia entre execuções. + +### O falso positivo que o modelo não reconhece + +O cenário "Backup noturno" descreve uma transferência legítima de 12 GB em +janela de manutenção. Um analista humano encerraria em segundos. O Qwen 2.5 7B +classifica como verdadeiro positivo e propõe **isolar o servidor de backup**. + +Isso não é um defeito a esconder — é a demonstração mais direta do porquê da +arquitetura. O modelo errou; a barreira de aprovação impediu o estrago. Se o +agente tivesse autonomia para executar, teria derrubado a infraestrutura de +backup da organização durante a janela de manutenção. + +A causa raiz é falta de contexto: o agente recebe o alerta, mas não sabe que +`BKP-SRV-01` é um servidor de backup nem que existe janela de manutenção +aprovada. Um SOC real resolve isso com enriquecimento — inventário de ativos e +calendário de mudanças alimentando o prompt. Fica registrado como evolução +natural do projeto. + +--- + +## Configuração + +Em `src/main/resources/application.yml`: + +```yaml +soc-agent: + planner: human-in-the-loop # ou plan-then-execute, react + minimum-confidence: 0.6 + default-approver: analista.local + +spring: + ai: + ollama: + chat: + options: + model: qwen2.5:7b +``` + +Também por variável de ambiente: `SOC_AGENT_MODEL` e `OLLAMA_BASE_URL`. + +--- + +## Decisões de projeto que valem destaque + +**O modelo não executa nada.** Ele classifica, justifica e sugere *nomes* de +ferramenta. A `CommandFactory` é o único ponto onde uma sugestão vira ação, e +opera por lista de permissão — uma ferramenta alucinada pelo modelo é +descartada com registro em log, nunca interpretada. Há teste automatizado para +esse caso. + +**A barreira de aprovação é redundante de propósito.** O `State` só libera a +transição para contenção após decisão humana, e o `CommandInvoker` recusa +comandos aprováveis fora dessa fase. Se um planejador futuro esquecer de marcar +uma ação como destrutiva, a outra camada ainda barra. + +**Falha degrada para revisão humana.** Se o modelo não responde, responde fora +do formato ou devolve classificação desconhecida, o veredito vira +`NEEDS_HUMAN_REVIEW`. Um agente de segurança que não consegue concluir precisa +escalar, nunca adivinhar. + +**Nenhum observador derruba o agente.** O barramento isola exceções de +observadores individualmente: um erro de renderização na interface não pode +custar a trilha de auditoria. + +--- + +## Estrutura + +``` +src/main/java/br/fatec/esiii/socagent/ +├── domain/ Alert, Ioc, Severity, MitreTechnique, TriageVerdict +├── state/ Incident (Context) + IncidentState e estados concretos +├── command/ AgentCommand, CommandInvoker, ContainmentGateway +├── strategy/ TriagePlanner, três planejadores, CommandFactory +├── observer/ AgentEventBus, listeners de auditoria e console +├── ai/ LlmThreatAnalyst — integração Spring AI +├── mitre/ MitreRepository — base ATT&CK offline +├── gui/ SocDashboardFrame (Swing) +│ └── tree/ EvidenceNode, EvidenceLeaf, EvidenceGroup +└── service/ IncidentTriageService, PlannerRegistry, cenários +``` + +--- + +## Licença + +MIT — veja [LICENSE](LICENSE). diff --git a/docs/arquitetura.md b/docs/arquitetura.md new file mode 100644 index 0000000..b7b8a8f --- /dev/null +++ b/docs/arquitetura.md @@ -0,0 +1,543 @@ +# Arquitetura e Padrões de Projeto + +Agente de triagem de incidentes de segurança construído com Spring AI e modelos +abertos executados localmente via Ollama. + +Categoria de caso de uso: **Security Agents** — investigar alertas, correlacionar +eventos e acionar contenção, com governança estrita e aprovação humana +obrigatória para ações críticas. + +--- + +## 1. Visão geral em camadas + +```mermaid +flowchart TB + subgraph GUI["Camada de apresentação"] + Frame["SocDashboardFrame
Swing"] + Tree["EvidenceNode
COMPOSITE"] + end + + subgraph APP["Camada de orquestração"] + Service["IncidentTriageService"] + Registry["PlannerRegistry"] + end + + subgraph PATTERNS["Núcleo de domínio"] + State["Incident + IncidentState
STATE"] + Cmd["AgentCommand + CommandInvoker
COMMAND"] + Strat["TriagePlanner
STRATEGY"] + Bus["AgentEventBus
OBSERVER"] + end + + subgraph INFRA["Infraestrutura"] + Llm["LlmThreatAnalyst
Spring AI"] + Ollama["Ollama
qwen2.5:7b"] + Gateway["ContainmentGateway
EDR / firewall / NAC"] + Mitre["MitreRepository
ATT&CK offline"] + end + + Frame --> Service + Frame --> Tree + Frame -. observa .-> Bus + Service --> Registry + Registry --> Strat + Service --> State + Service --> Cmd + State -. publica .-> Bus + Cmd -. publica .-> Bus + Strat --> Llm + Llm --> Ollama + Cmd --> Gateway + Cmd --> Mitre + Tree --> State +``` + +**Regra de dependência:** o núcleo de domínio não conhece Spring AI, Swing nem +Ollama. As setas para a infraestrutura atravessam interfaces (`ThreatAnalyst`, +`ContainmentGateway`), o que permite testar todos os padrões sem modelo e sem +rede — os 24 testes automatizados rodam offline. + +--- + +## 2. Onde cada padrão está + +| Padrão | Pacote | Papel na arquitetura | +|---|---|---| +| **State** | `state` | Ciclo de vida do incidente; barra ação destrutiva antes da aprovação | +| **Command** | `command` | Cada ação de resposta como objeto: enfileirável, auditável, reversível | +| **Strategy** | `strategy` | Três políticas de planejamento intercambiáveis em tempo de execução | +| **Observer** | `observer` | Difusão de eventos para auditoria, console e painel | +| **Composite** | `gui.tree` | Árvore de evidências exibida na GUI | + +--- + +## 3. STATE — ciclo de vida do incidente + +### 3.1 Diagrama de classes + +```mermaid +classDiagram + class Incident { + -IncidentState state + -List~Alert~ alerts + -AgentEventBus eventBus + +triage(TriageVerdict) + +correlate() + +requestApproval() + +approve(String) + +deny(String, String) + +close(String) + +phase() IncidentPhase + +allowsContainmentCommands() boolean + -transitionTo(IncidentState, String) + } + + class IncidentState { + <> + +phase() IncidentPhase + +triage(Incident, TriageVerdict) IncidentState + +correlate(Incident) IncidentState + +requestApproval(Incident) IncidentState + +approve(Incident, String) IncidentState + +deny(Incident, String, String) IncidentState + +close(Incident, String) IncidentState + +allowsContainmentCommands() boolean + } + + class ReceivedState + class TriagingState + class CorrelatingState + class AwaitingApprovalState + class ContainingState + class ClosedState + + Incident o--> IncidentState : delega comportamento + IncidentState <|.. ReceivedState + IncidentState <|.. TriagingState + IncidentState <|.. CorrelatingState + IncidentState <|.. AwaitingApprovalState + IncidentState <|.. ContainingState + IncidentState <|.. ClosedState +``` + +**Decisão de projeto.** Todas as operações são declaradas na interface com +implementação padrão que lança `IllegalTransitionException`. Cada estado +concreto sobrescreve **apenas** o que permite. Consequência: transição ilegal +deixa de ser um `if` espalhado pelo serviço e passa a ser impossível por +construção. Não existe nenhum `switch` sobre a fase no código — acrescentar +uma fase significa criar uma classe, sem tocar no `Incident`. + +### 3.2 Diagrama de máquina de estados + +```mermaid +stateDiagram-v2 + [*] --> RECEIVED + RECEIVED --> TRIAGING : triage(verdict) + + TRIAGING --> CORRELATING : correlate() + TRIAGING --> CLOSED : close()
falso positivo + + CORRELATING --> AWAITING_APPROVAL : requestApproval() + CORRELATING --> CLOSED : close()
sem ação aplicável + + AWAITING_APPROVAL --> CONTAINING : approve(analista) + AWAITING_APPROVAL --> CLOSED : deny(analista, motivo) + + CONTAINING --> CLOSED : close(resumo) + CLOSED --> [*] + + note right of AWAITING_APPROVAL + Barreira de governança. + Única transição para CONTAINING + exige decisão humana explícita. + end note + + note right of CONTAINING + Único estado em que + allowsContainmentCommands() + devolve true. + end note +``` + +--- + +## 4. COMMAND — ações de resposta + +```mermaid +classDiagram + class AgentCommand { + <> + +name() String + +description() String + +execute() CommandResult + +requiresApproval() boolean + +undoable() boolean + +undo() CommandResult + } + + class CommandInvoker { + -Deque~AgentCommand~ queue + -Deque~AgentCommand~ undoStack + -List~ExecutionRecord~ history + +enqueue(Incident, AgentCommand) + +execute(Incident, AgentCommand) ExecutionRecord + +executeQueue(Incident) List~ExecutionRecord~ + +undoLast(Incident) ExecutionRecord + } + + class IsolateHostCommand { + +requiresApproval() boolean + +undoable() boolean + +undo() CommandResult + } + class BlockIpCommand { + +requiresApproval() boolean + +undoable() boolean + +undo() CommandResult + } + class CollectForensicsCommand { + +execute() CommandResult + } + class LookupMitreCommand { + +execute() CommandResult + } + + class ContainmentGateway { + <> + +isolateHost(String, String) String + +restoreHost(String) String + +blockIp(String, String) String + +unblockIp(String) String + +collectForensics(String) String + } + + AgentCommand <|.. IsolateHostCommand + AgentCommand <|.. BlockIpCommand + AgentCommand <|.. CollectForensicsCommand + AgentCommand <|.. LookupMitreCommand + + CommandInvoker o--> AgentCommand : enfileira e executa + CommandInvoker ..> Incident : consulta o State + CommandInvoker ..> AgentEventBus : publica + + IsolateHostCommand --> ContainmentGateway + BlockIpCommand --> ContainmentGateway + CollectForensicsCommand --> ContainmentGateway +``` + +**Três decisões que valem a defesa oral:** + +1. **Comandos são código Java, nunca texto gerado pelo modelo.** O modelo + escolhe *qual* comando invocar e com *quais parâmetros*; o que cada comando + faz é fixado em tempo de compilação. +2. **A barreira de aprovação vive no `CommandInvoker`, não em cada comando.** + Um comando novo já nasce protegido — não há como esquecer de checar. +3. **`undo()` é ação compensatória real.** Reverter uma contenção equivocada + rápido vale tanto quanto aplicá-la; `IsolateHostCommand.undo()` reconecta o + host de fato. + +--- + +## 5. STRATEGY — políticas de planejamento + +```mermaid +classDiagram + class TriagePlanner { + <> + +id() String + +displayName() String + +plan(Incident) Plan + } + + class Plan { + <> + +TriageVerdict verdict + +List~AgentCommand~ commands + +boolean requiresApproval + } + + class PlanThenExecutePlanner { + +plan(Incident) Plan + } + class ReActPlanner { + -int MAX_STEPS + +plan(Incident) Plan + } + class HumanInTheLoopPlanner { + -TriagePlanner delegate + +plan(Incident) Plan + } + + class ThreatAnalyst { + <> + +classify(Incident) TriageVerdict + +proposePlan(Incident) List~ProposedAction~ + +proposeNext(Incident, List~String~) Optional~ProposedAction~ + } + + class CommandFactory { + -ALLOWED_TOOLS Set~String~ + +create(ProposedAction, String) Optional~AgentCommand~ + } + + class LlmThreatAnalyst { + -ChatClient chatClient + } + + class PlannerRegistry { + -TriagePlanner active + +activate(String) + +available() List~TriagePlanner~ + } + + TriagePlanner <|.. PlanThenExecutePlanner + TriagePlanner <|.. ReActPlanner + TriagePlanner <|.. HumanInTheLoopPlanner + HumanInTheLoopPlanner o--> PlanThenExecutePlanner : decora + TriagePlanner ..> Plan : produz + + PlanThenExecutePlanner --> ThreatAnalyst + ReActPlanner --> ThreatAnalyst + ThreatAnalyst <|.. LlmThreatAnalyst + + PlanThenExecutePlanner --> CommandFactory + ReActPlanner --> CommandFactory + CommandFactory ..> AgentCommand : traduz + + PlannerRegistry o--> TriagePlanner : seleciona em runtime +``` + +**`HumanInTheLoopPlanner` é Strategy que também decora** — envolve outro +planejador e força aprovação sem duplicar a lógica de planejamento. + +**`CommandFactory` é o único ponto onde saída de modelo vira ação**, e opera por +lista de permissão. Um modelo que alucine `delete_all_logs` não encontra +tradutor: a ação é descartada com registro em log. Existe teste automatizado +para esse caso. + +**`ReActPlanner` executa apenas comandos de leitura durante o ciclo** e acumula +os destrutivos para depois da aprovação. Sem essa separação, o ciclo adaptativo +contornaria a barreira de governança. + +--- + +## 6. OBSERVER — difusão de eventos + +```mermaid +classDiagram + class AgentEventBus { + -List~AgentEventListener~ listeners + +subscribe(AgentEventListener) + +unsubscribe(AgentEventListener) + +publish(AgentEvent) + } + + class AgentEventListener { + <> + +onEvent(AgentEvent) + +supports(AgentEvent) boolean + +listenerName() String + } + + class AgentEvent { + <> + +EventType type + +String incidentId + +String title + +Instant occurredAt + } + + class AuditTrailListener { + +entries() List~String~ + } + class ConsoleListener { + +onEvent(AgentEvent) + } + class SocDashboardFrame { + +onEvent(AgentEvent) + } + class EventListenerRegistrar { + +EventListenerRegistrar(AgentEventBus, List) + } + + AgentEventBus o--> AgentEventListener + AgentEventBus ..> AgentEvent : difunde + AgentEventListener <|.. AuditTrailListener + AgentEventListener <|.. ConsoleListener + AgentEventListener <|.. SocDashboardFrame + EventListenerRegistrar ..> AgentEventBus : registra + + Incident ..> AgentEventBus : transições + CommandInvoker ..> AgentEventBus : execuções +``` + +**Falha de um observador nunca interrompe a difusão para os demais.** Em +segurança, perder a trilha de auditoria por causa de um erro de renderização na +GUI seria inaceitável — o barramento captura a exceção, registra e continua. + +A estrutura usa `CopyOnWriteArrayList` porque a GUI se inscreve pela *Event +Dispatch Thread* do Swing enquanto o agente publica de uma thread de trabalho. + +--- + +## 7. COMPOSITE — árvore de evidências na GUI + +```mermaid +classDiagram + class EvidenceNode { + <> + +label() String + +severity() Severity + +children() List~EvidenceNode~ + +isLeaf() boolean + +add(EvidenceNode) + +leafCount() int + +highestSeverity() Severity + +depth() int + +render(int) String + } + + class EvidenceLeaf { + <> + +String label + +Severity severity + +children() List~EvidenceNode~ + } + + class EvidenceGroup { + -List~EvidenceNode~ children + +add(EvidenceNode) + +with(EvidenceNode) EvidenceGroup + } + + class EvidenceTreeBuilder { + +build(Incident) EvidenceNode + } + + class SocDashboardFrame { + -toSwingNode(EvidenceNode) TreeNode + } + + EvidenceNode <|.. EvidenceLeaf + EvidenceNode <|.. EvidenceGroup + EvidenceGroup o--> EvidenceNode : filhos + EvidenceTreeBuilder ..> EvidenceGroup : monta + SocDashboardFrame ..> EvidenceNode : renderiza no JTree +``` + +Estrutura produzida em tempo de execução: + +``` ++ INC-0001 | Encerrado | host WKS-4471 [CRITICAL] + + [ALR-1001] Volume de saída 480 MB para destino externo (suricata) [CRITICAL] + - IP: 185.220.101.7 [CRITICAL] + + [ALR-1002] Conexão TLS com certificado autoassinado (zeek) [HIGH] + - IP: 185.220.101.7 [HIGH] + - PROCESS: rundll32.exe [HIGH] + + Técnicas MITRE ATT&CK [MEDIUM] + - T1041 - Exfiltration Over C2 Channel (Exfiltration) [MEDIUM] + + Veredito: TRUE_POSITIVE (confiança 0,90) [INFO] + - [INFO] + + Decisão humana [INFO] + - Aprovado por demo.operador [INFO] +``` + +**As operações recursivas são métodos padrão da interface**, então folha e +composto respondem identicamente a `leafCount()`, `highestSeverity()`, +`depth()` e `render()`. A severidade agrega de baixo para cima: um grupo +marcado como `LOW` contendo um IOC `CRITICAL` aparece como `CRITICAL` no +painel, sem que ninguém recalcule isso manualmente. + +**A folha recusa filhos** lançando `UnsupportedOperationException`. É a variante +*transparente* do padrão descrita no GoF: interface uniforme, com a segurança +verificada em tempo de execução. + +--- + +## 8. Fluxo completo — diagrama de sequência + +```mermaid +sequenceDiagram + actor Analista + participant GUI as SocDashboardFrame + participant Svc as IncidentTriageService + participant Plan as TriagePlanner
(STRATEGY) + participant Llm as LlmThreatAnalyst + participant Inc as Incident
(STATE) + participant Inv as CommandInvoker
(COMMAND) + participant Bus as AgentEventBus
(OBSERVER) + + Analista->>GUI: seleciona cenário e estratégia + GUI->>Svc: triage(incident) + Svc->>Inc: open() + Inc-->>Bus: INCIDENT_CREATED + + Svc->>Plan: plan(incident) + Plan->>Llm: classify(incident) + Llm-->>Plan: TriageVerdict + Plan->>Llm: proposePlan(incident) + Llm-->>Plan: lista de ações propostas + Note over Plan: CommandFactory traduz
por lista de permissão + Plan-->>Svc: Plan(verdict, commands, requiresApproval) + Svc-->>Bus: PLAN_CREATED + + Svc->>Inc: triage(verdict) + Inc-->>Bus: STATE_CHANGED (Recebido → Em triagem) + Svc->>Inc: correlate() + Inc-->>Bus: STATE_CHANGED (→ Correlacionando) + + Svc->>Inv: enqueue(comandos) + Inv-->>Bus: COMMAND_QUEUED (×N) + + Svc->>Inc: requestApproval() + Inc-->>Bus: APPROVAL_REQUESTED + Bus-->>GUI: habilita botão "Aprovar" + + Note over Analista,Inv: O agente para aqui.
Nenhuma ação destrutiva ocorreu. + + Analista->>GUI: Aprovar contenção + GUI->>Svc: approve(incident, analista) + Svc->>Inc: approve(analista) + Inc-->>Bus: STATE_CHANGED (→ Em contenção) + + Svc->>Inv: executeQueue(incident) + Inv->>Inc: allowsContainmentCommands()? + Inc-->>Inv: true + Inv-->>Bus: COMMAND_EXECUTED (×N) + + Svc->>Inc: close(resumo) + Inc-->>Bus: INCIDENT_CLOSED + Bus-->>GUI: atualiza árvore e log +``` + +O ponto central do diagrama é a pausa antes da aprovação. Se o analista negar, +`deny()` leva direto a `CLOSED` e a fila **nunca** é executada. + +--- + +## 9. Verificação da barreira de governança + +Dois mecanismos independentes protegem a mesma invariante — nenhuma ação +destrutiva sem decisão humana: + +| Camada | Mecanismo | Teste | +|---|---|---| +| State | `AwaitingApprovalState` é a única porta para `CONTAINING` | `IncidentStateTest.contencaoExigeAprovacao` | +| Command | `CommandInvoker` recusa comando aprovável fora da fase de contenção | `CommandInvokerTest.recusaSemAprovacao` | + +A redundância é intencional: se um planejador futuro esquecer de marcar +`requiresApproval`, o State ainda barra; se um estado novo permitir a transição +por engano, o Invoker ainda recusa. + +--- + +## 10. Padrões adicionais presentes + +Além dos cinco exigidos, a arquitetura usa: + +- **Decorator** — `HumanInTheLoopPlanner` envolve outro planejador +- **Factory** — `CommandFactory` traduz sugestão em comando por lista de permissão +- **Repository** — `MitreRepository` encapsula a base ATT&CK +- **Ports and Adapters** — `ThreatAnalyst` e `ContainmentGateway` isolam modelo e infraestrutura +- **Injeção de dependência** — todos os componentes são beans Spring diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..5761d94 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +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); + } +}