Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
# Ares - Autonomous Security Operations Agent

<!-- BEGIN_AUTO_BADGES -->
<div align="center">

[![Pre-Commit](https://github.com/dreadnode/python-template/actions/workflows/pre-commit.yaml/badge.svg)](https://github.com/dreadnode/python-template/actions/workflows/pre-commit.yaml)
[![Renovate](https://github.com/dreadnode/python-template/actions/workflows/renovate.yaml/badge.svg)](https://github.com/dreadnode/python-template/actions/workflows/renovate.yaml)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)

</div>
<!-- END_AUTO_BADGES -->

[![Tests](https://github.com/dreadnode/ares/actions/workflows/tests.yaml/badge.svg)](https://github.com/dreadnode/ares/actions/workflows/tests.yaml)
[![Coverage](https://raw.githubusercontent.com/dreadnode/ares/main/.github/badges/coverage.svg)](https://github.com/dreadnode/ares/actions/workflows/coverage-badge.yaml)
[![Pre-Commit](https://github.com/dreadnode/ares/actions/workflows/pre-commit.yaml/badge.svg)](https://github.com/dreadnode/ares/actions/workflows/pre-commit.yaml)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Python](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
Expand Down
23 changes: 13 additions & 10 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ vars:
MODEL: '{{.MODEL | default "claude-sonnet-4-20250514"}}'
GRAFANA_URL: '{{.GRAFANA_URL | default "https://grafana.dev.plundr.ai"}}'
POLL_INTERVAL: '{{.POLL_INTERVAL | default "30"}}'
MAX_STEPS: '{{.MAX_STEPS | default "50"}}'
MAX_STEPS_ONCE: '{{.MAX_STEPS_ONCE | default "15"}}' # ~15 min max for once mode
MAX_STEPS_BLUE: '{{.MAX_STEPS_BLUE | default "50"}}'
MAX_STEPS_BLUE_ONCE: '{{.MAX_STEPS_BLUE_ONCE | default "15"}}' # ~15 min max for once mode
MAX_STEPS_RED: '{{.MAX_STEPS_RED | default "150"}}'
REPORT_DIR: '{{.REPORT_DIR | default "./reports"}}'
LOG_DIR: '{{.LOG_DIR | default "./logs"}}'
DREADNODE_SERVER: '{{.DREADNODE_SERVER | default "https://platform.dev.plundr.ai/"}}'
Expand Down Expand Up @@ -170,7 +171,7 @@ tasks:
--args.model {{.MODEL}} \
--args.grafana-url {{.GRAFANA_URL}} \
--args.poll-interval {{.POLL_INTERVAL}} \
--args.max-steps {{.MAX_STEPS}} \
--args.max-steps {{.MAX_STEPS_BLUE}} \
--args.report-dir {{.REPORT_DIR}} \
--dn-args.server {{.DREADNODE_SERVER}} \
--dn-args.token "$DREADNODE_API_KEY" \
Expand Down Expand Up @@ -200,7 +201,7 @@ tasks:
--args.model {{.MODEL}} \
--args.grafana-url {{.GRAFANA_URL}} \
--args.poll-interval {{.POLL_INTERVAL}} \
--args.max-steps {{.MAX_STEPS_ONCE}} \
--args.max-steps {{.MAX_STEPS_BLUE_ONCE}} \
--args.report-dir {{.REPORT_DIR}} \
--args.once \
--dn-args.server {{.DREADNODE_SERVER}} \
Expand Down Expand Up @@ -236,7 +237,7 @@ tasks:
--args.model {{.MODEL}} \
--args.grafana-url {{.GRAFANA_URL}} \
--args.poll-interval {{.POLL_INTERVAL}} \
--args.max-steps {{.MAX_STEPS}} \
--args.max-steps {{.MAX_STEPS_BLUE}} \
--args.report-dir {{.REPORT_DIR}} \
--dn-args.server {{.DREADNODE_SERVER}} \
--dn-args.organization {{.DREADNODE_ORGANIZATION}} \
Expand Down Expand Up @@ -270,7 +271,7 @@ tasks:
--args.model {{.MODEL}} \
--args.grafana-url {{.GRAFANA_URL}} \
--args.poll-interval {{.POLL_INTERVAL}} \
--args.max-steps {{.MAX_STEPS_ONCE}} \
--args.max-steps {{.MAX_STEPS_BLUE_ONCE}} \
--args.report-dir {{.REPORT_DIR}} \
--args.once \
--dn-args.server {{.DREADNODE_SERVER}} \
Expand Down Expand Up @@ -302,7 +303,7 @@ tasks:
uv run python -m ares investigate-alert {{.ALERT}} \
--args.model {{.MODEL}} \
--args.grafana-url {{.GRAFANA_URL}} \
--args.max-steps {{.MAX_STEPS_ONCE}} \
--args.max-steps {{.MAX_STEPS_BLUE_ONCE}} \
--args.report-dir {{.REPORT_DIR}} \
--dn-args.server {{.DREADNODE_SERVER}} \
--dn-args.token "$DREADNODE_API_KEY" \
Expand Down Expand Up @@ -384,7 +385,9 @@ tasks:
echo ""
echo "Agent Settings:"
echo " Model: {{.MODEL}}"
echo " Max Steps: {{.MAX_STEPS}}"
echo " Max Steps (Blue): {{.MAX_STEPS_BLUE}}"
echo " Max Steps (Blue Once): {{.MAX_STEPS_BLUE_ONCE}}"
echo " Max Steps (Red): {{.MAX_STEPS_RED}}"
echo " Poll Interval: {{.POLL_INTERVAL}}s"
echo ""
echo "Data Sources:"
Expand Down Expand Up @@ -642,7 +645,7 @@ tasks:

uv run python -m ares red-team "$RESOLVED_TARGET" \
--args.model {{.MODEL}} \
--args.max-steps {{.MAX_STEPS}} \
--args.max-steps {{.MAX_STEPS_RED}} \
--args.report-dir {{.REPORT_DIR}} \
--dn-args.server {{.DREADNODE_SERVER}} \
--dn-args.token "$DREADNODE_API_KEY" \
Expand Down Expand Up @@ -676,7 +679,7 @@ tasks:

uv run python -m ares red-team {{.TARGET}} \
--args.model {{.MODEL}} \
--args.max-steps {{.MAX_STEPS}} \
--args.max-steps {{.MAX_STEPS_RED}} \
--args.report-dir {{.REPORT_DIR}} \
--dn-args.server {{.DREADNODE_SERVER}} \
--dn-args.organization {{.DREADNODE_ORGANIZATION}} \
Expand Down
81 changes: 81 additions & 0 deletions src/ares/agents/blue/soc_investigator.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)
from ares.core.templates import get_template_loader
from ares.integrations.mitre import MITREAttackClient
from ares.tools.blue.grafana import GrafanaTools


class InvestigationTimeoutError(Exception):
Expand Down Expand Up @@ -172,6 +173,11 @@ def __init__(
self.max_steps = max_steps
self._mcp_client = None
self._mcp_tools = None
# Grafana tools for annotations
self._grafana_tools = GrafanaTools(
base_url=grafana_url,
api_key=grafana_api_key,
)

async def _ensure_mcp_connection(self) -> None:
"""Ensure MCP connection is established (with 60s timeout)."""
Expand Down Expand Up @@ -271,6 +277,9 @@ async def investigate(self, alert: dict) -> dict:
# Ensure MCP connection is ready
await self._ensure_mcp_connection()

# Post "investigation started" annotation to Grafana
await self._post_started_annotation(investigation_id, alert)

# Auto-extract and record MITRE technique from alert
labels = alert.get("labels", {})
annotations = alert.get("annotations", {})
Expand Down Expand Up @@ -357,6 +366,11 @@ async def investigate(self, alert: dict) -> dict:
# Persist investigation for learning
self._persist_investigation(state, status)

# Post "investigation completed" annotation to Grafana
await self._post_completed_annotation(
investigation_id, alert_name, status, state
)

dn.log_output("report_path", str(report_path))
dn.log_metric("investigation_success", 1)

Expand All @@ -383,6 +397,11 @@ async def investigate(self, alert: dict) -> dict:
# Persist investigation for learning (even on timeout)
self._persist_investigation(state, "timeout")

# Post "investigation timeout" annotation to Grafana
await self._post_completed_annotation(
investigation_id, alert_name, "timeout", state
)

return {
"investigation_id": investigation_id,
"status": "timeout",
Expand All @@ -398,12 +417,74 @@ async def investigate(self, alert: dict) -> dict:

# Persist failed investigation
self._persist_investigation(state, "failed")

# Post "investigation failed" annotation to Grafana
await self._post_completed_annotation(
investigation_id, alert_name, "failed", state
)
raise

finally:
# Always cancel the watchdog on normal completion
watchdog.cancel()

async def _post_started_annotation(self, investigation_id: str, alert: dict) -> None:
"""Post investigation started annotation to Grafana.

Args:
investigation_id: Unique investigation identifier.
alert: Alert dictionary.
"""
try:
labels = alert.get("labels", {})
alert_name = labels.get("alertname", "unknown")
severity = labels.get("severity", "unknown")

await self._grafana_tools.post_investigation_started(
investigation_id=investigation_id,
alert_name=alert_name,
severity=severity,
)
except Exception as e:
# Don't fail the investigation if annotation fails
logger.warning(f"Failed to post started annotation: {e}")

async def _post_completed_annotation(
self,
investigation_id: str,
alert_name: str,
status: str,
state: InvestigationState,
) -> None:
"""Post investigation completed annotation to Grafana.

Args:
investigation_id: Unique investigation identifier.
alert_name: Name of the alert investigated.
status: Final status.
state: Investigation state.
"""
try:
# Get summary from state if available
summary = None
if state.attack_synopsis:
summary = state.attack_synopsis
elif state.recommendations:
summary = f"Recommendations: {', '.join(state.recommendations[:3])}"

await self._grafana_tools.post_investigation_completed(
investigation_id=investigation_id,
alert_name=alert_name,
status=status,
evidence_count=len(state.evidence),
techniques=list(state.identified_techniques),
pyramid_level=state.highest_pyramid_level,
summary=summary,
)
except Exception as e:
# Don't fail the investigation if annotation fails
logger.warning(f"Failed to post completed annotation: {e}")

def _create_alert_timeline_event(self, state: InvestigationState, alert: dict) -> None:
"""Create an initial timeline event from the alert."""
labels = alert.get("labels", {})
Expand Down
Loading